From 85dc167449c27972d78b2bd73dd876b04031cd5a Mon Sep 17 00:00:00 2001 From: itqop Date: Wed, 17 Dec 2025 17:37:32 +0300 Subject: [PATCH] first version --- .claude/settings.local.json | 15 + .env.example | 39 ++ .gitignore | 62 +++ CLAUDE.md | 244 ++++++++++++ DB_API_CONTRACT.md | 308 +++++++++++++++ DEVELOPMENT_PLAN.md | 682 ++++++++++++++++++++++++++++++++ Dockerfile | 27 ++ MIGRATION_GUIDE.md | 677 +++++++++++++++++++++++++++++++ PROJECT_STATUS.md | 446 +++++++++++++++++++++ README.md | 181 +++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/v1/__init__.py | 7 + app/api/v1/analysis.py | 182 +++++++++ app/api/v1/auth.py | 53 +++ app/api/v1/query.py | 202 ++++++++++ app/api/v1/settings.py | 95 +++++ app/config.py | 57 +++ app/dependencies.py | 48 +++ app/interfaces/__init__.py | 0 app/interfaces/base.py | 277 +++++++++++++ app/interfaces/db_api_client.py | 103 +++++ app/main.py | 50 +++ app/middleware/__init__.py | 0 app/models/__init__.py | 0 app/models/analysis.py | 43 ++ app/models/auth.py | 35 ++ app/models/query.py | 35 ++ app/models/settings.py | 30 ++ app/services/__init__.py | 0 app/services/auth_service.py | 54 +++ app/services/rag_service.py | 316 +++++++++++++++ app/utils/__init__.py | 0 app/utils/security.py | 58 +++ docker-compose.yml | 20 + requirements.txt | 24 ++ 36 files changed, 4370 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 DB_API_CONTRACT.md create mode 100644 DEVELOPMENT_PLAN.md create mode 100644 Dockerfile create mode 100644 MIGRATION_GUIDE.md create mode 100644 PROJECT_STATUS.md create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/analysis.py create mode 100644 app/api/v1/auth.py create mode 100644 app/api/v1/query.py create mode 100644 app/api/v1/settings.py create mode 100644 app/config.py create mode 100644 app/dependencies.py create mode 100644 app/interfaces/__init__.py create mode 100644 app/interfaces/base.py create mode 100644 app/interfaces/db_api_client.py create mode 100644 app/main.py create mode 100644 app/middleware/__init__.py create mode 100644 app/models/__init__.py create mode 100644 app/models/analysis.py create mode 100644 app/models/auth.py create mode 100644 app/models/query.py create mode 100644 app/models/settings.py create mode 100644 app/services/__init__.py create mode 100644 app/services/auth_service.py create mode 100644 app/services/rag_service.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/security.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ac3c37b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(python -m py_compile:*)", + "Bash(test:*)", + "Bash(python -c:*)", + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(grep:*)", + "Bash(cat:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2038dbc --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# Application +DEBUG=false +APP_NAME=Brief Bench API + +# JWT Authentication +JWT_SECRET_KEY=your-super-secret-key-change-in-production +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=43200 # 30 days + +# DB API Service (external) +DB_API_URL=http://db-api-service:8080/api/v1 +DB_API_TIMEOUT=30 + +# RAG Backend - IFT Environment +IFT_RAG_HOST=ift-rag.example.com +IFT_RAG_PORT=8443 +IFT_RAG_ENDPOINT=api/rag/bench +IFT_RAG_CERT_CA=/app/certs/ift/ca.crt +IFT_RAG_CERT_KEY=/app/certs/ift/client.key +IFT_RAG_CERT_CERT=/app/certs/ift/client.crt + +# RAG Backend - PSI Environment +PSI_RAG_HOST=psi-rag.example.com +PSI_RAG_PORT=8443 +PSI_RAG_ENDPOINT=api/rag/bench +PSI_RAG_CERT_CA=/app/certs/psi/ca.crt +PSI_RAG_CERT_KEY=/app/certs/psi/client.key +PSI_RAG_CERT_CERT=/app/certs/psi/client.crt + +# RAG Backend - PROD Environment +PROD_RAG_HOST=prod-rag.example.com +PROD_RAG_PORT=8443 +PROD_RAG_ENDPOINT=api/rag/bench +PROD_RAG_CERT_CA=/app/certs/prod/ca.crt +PROD_RAG_CERT_KEY=/app/certs/prod/client.key +PROD_RAG_CERT_CERT=/app/certs/prod/client.crt + +# Request Timeouts (seconds) +RAG_REQUEST_TIMEOUT=1800 # 30 minutes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc85559 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# Environment variables +.env +.env.local + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Certificates (sensitive!) +certs/ +*.pem +*.key +*.crt +*.cert + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Docker +docker-compose.override.yml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6beeae0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,244 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Brief Bench FastAPI is a FastAPI backend for a RAG (Retrieval-Augmented Generation) testing system with multi-user support. The system allows users to test RAG backends across three environments (IFT, PSI, PROD) in two modes: Bench mode (batch testing) and Backend mode (bot simulation). + +## Key Concepts + +### Multi-Environment Architecture +- **Three environments**: IFT, PSI, PROD (each with separate RAG backend hosts) +- **Per-user settings**: Each user has individual settings for each environment (stored in external DB API) +- **Server configuration**: RAG hosts, ports, endpoints, and mTLS cert paths are defined in `.env` (not user-editable) + +### Two Query Modes +1. **Bench Mode**: Send all questions in a single batch request to RAG backend + - Endpoint: `POST /api/v1/query/bench` + - Uses headers: `Request-Id`, `System-Id`, `Authorization`, `System-Platform` + +2. **Backend Mode**: Send questions one-by-one, simulating bot behavior + - Endpoint: `POST /api/v1/query/backend` + - Can reset session after each question (controlled by `resetSessionMode` setting) + - Uses headers: `Platform-User-Id`, `Platform-Id`, `Authorization` + +### Authentication Flow +1. User sends `POST /api/v1/auth/login?login=12345678` (8-digit login) +2. FastAPI forwards to DB API: `POST /users/login` +3. DB API returns user info (user_id, login, timestamps) +4. FastAPI generates JWT token (30-day expiration) with {user_id, login, exp} +5. Returns {access_token, user} to client +6. All subsequent requests use `Authorization: Bearer {token}` +7. Middleware `get_current_user` validates JWT on protected endpoints + +## Development Commands + +### Local Development +```bash +# Create virtual environment +python -m venv venv +source venv/bin/activate # Linux/Mac +venv\Scripts\activate # Windows + +# Install dependencies +pip install -r requirements.txt + +# Run development server (with auto-reload) +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Docker +```bash +# Build and run +docker-compose up -d + +# View logs +docker-compose logs -f fastapi + +# Stop +docker-compose down +``` + +### Environment Setup +```bash +# Copy example and edit +cp .env.example .env + +# Required variables: +# - JWT_SECRET_KEY (generate a new secret!) +# - DB_API_URL (external DB API service URL) +# - IFT_RAG_HOST, PSI_RAG_HOST, PROD_RAG_HOST +# - Port and endpoint configs for each environment +# - Optional: mTLS certificate paths (certs/ directory) +``` + +## Architecture Details + +### Layered Structure +``` +app/ +├── api/v1/ # API endpoints (FastAPI routers) +├── models/ # Pydantic models for validation +├── services/ # Business logic layer +├── interfaces/ # External API clients +├── middleware/ # Request/response middleware +├── utils/ # Utilities (JWT, security) +├── config.py # Settings from .env (pydantic-settings) +├── dependencies.py # Dependency injection +└── main.py # FastAPI app entry point +``` + +### TgBackendInterface Pattern +The `TgBackendInterface` class ([app/interfaces/base.py](app/interfaces/base.py)) is the base for all HTTP API clients: +- Wraps `httpx.AsyncClient` with Pydantic model serialization/deserialization +- Provides `get()`, `post()`, `put()`, `delete()` methods +- Handles error logging and HTTP status errors +- Auto-serializes Pydantic models to JSON and deserializes JSON to Pydantic models +- Initialized with `api_prefix` (base URL) + +**DBApiClient** ([app/interfaces/db_api_client.py](app/interfaces/db_api_client.py)) extends this for DB API integration: +- `login_user()` - authenticate user +- `get_user_settings()`, `update_user_settings()` - manage per-user settings +- `save_session()`, `get_sessions()`, `get_session()`, `delete_session()` - analysis sessions + +See [DB_API_CONTRACT.md](DB_API_CONTRACT.md) for full DB API specification. + +### RagService Pattern +The `RagService` class ([app/services/rag_service.py](app/services/rag_service.py)) manages RAG backend communication: +- Creates separate `httpx.AsyncClient` for each environment (IFT, PSI, PROD) +- Configures mTLS certificates from `.env` settings +- `send_bench_query()` - batch mode requests +- `send_backend_query()` - sequential requests with optional session reset +- Builds environment-specific headers from user settings + +### Dependency Injection +Located in [app/dependencies.py](app/dependencies.py): +- `get_db_client()` - Returns DBApiClient instance +- `get_current_user()` - JWT auth middleware, validates Bearer token, returns {user_id, login} + +Use in endpoints: +```python +@router.get("/protected") +async def protected_endpoint( + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + user_id = current_user["user_id"] + # ... +``` + +### User Settings vs Server Configuration +**Server Config (`.env`)**: RAG hosts, ports, endpoints, mTLS cert paths - applies to all users +**User Settings (DB API)**: Per-environment settings for each user: +- `apiMode`: "bench" or "backend" +- `bearerToken`: Authorization token for RAG backend +- `systemPlatform`, `systemPlatformUser`: Platform identifiers +- `platformUserId`, `platformId`: Platform-specific IDs +- `withClassify`: Enable classification in backend mode +- `resetSessionMode`: Reset session after each question in backend mode + +## Important Implementation Notes + +### mTLS Certificate Handling +- Certificate paths are configured per-environment in `.env` +- Format: `/app/certs/{env}/ca.crt`, `/app/certs/{env}/client.key`, `/app/certs/{env}/client.crt` +- RagService loads certificates from these paths when creating httpx clients +- Certificates are mounted read-only in Docker: `./certs:/app/certs:ro` + +### Error Handling +- All API client methods can raise `httpx.HTTPStatusError` for HTTP errors +- Endpoints should catch these and convert to FastAPI `HTTPException` +- Use `status.HTTP_502_BAD_GATEWAY` for external service errors +- Use `status.HTTP_500_INTERNAL_SERVER_ERROR` for unexpected errors +- Always log errors with context (user_id, environment, request details) + +### Async Context Managers +Both TgBackendInterface and RagService support async context managers: +```python +async with RagService() as rag_service: + response = await rag_service.send_bench_query(...) +# Client automatically closed +``` + +Or manually close: +```python +rag_service = RagService() +try: + response = await rag_service.send_bench_query(...) +finally: + await rag_service.close() +``` + +### Long-Running Requests +- RAG requests can take up to 30 minutes (`RAG_REQUEST_TIMEOUT=1800`) +- RagService uses 30-minute timeout for httpx clients +- DB API has separate timeout (`DB_API_TIMEOUT=30`, default 30s) + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/login?login={8-digit}` - Login with 8-digit code, returns JWT token + +### Settings +- `GET /api/v1/settings` - Get current user's settings for all environments +- `PUT /api/v1/settings` - Update user settings + +### Query +- `POST /api/v1/query/bench` - Send batch query (bench mode) +- `POST /api/v1/query/backend` - Send sequential queries (backend mode) + +### Analysis Sessions +- `POST /api/v1/analysis/sessions` - Save analysis session +- `GET /api/v1/analysis/sessions` - List sessions (filterable by environment) +- `GET /api/v1/analysis/sessions/{session_id}` - Get specific session +- `DELETE /api/v1/analysis/sessions/{session_id}` - Delete session + +### Health +- `GET /health` - Health check +- `GET /` - API info + +## Testing Strategy + +When adding tests: +1. Unit test services in isolation (mock httpx responses) +2. Integration test API endpoints (use TestClient from FastAPI) +3. Mock external dependencies (DB API, RAG backends) +4. Test error paths (network failures, invalid tokens, missing settings) + +## Common Development Scenarios + +### Adding a New API Endpoint +1. Define Pydantic models in `app/models/` +2. Create endpoint in appropriate `app/api/v1/` router +3. Add business logic to `app/services/` if needed +4. Register router in `app/main.py` with `app.include_router()` + +### Modifying DB API Integration +1. Update contract in [DB_API_CONTRACT.md](DB_API_CONTRACT.md) +2. Update Pydantic models in `app/models/` +3. Add/modify methods in [app/interfaces/db_api_client.py](app/interfaces/db_api_client.py) + +### Adding RAG Backend Endpoints +1. Add endpoint config to `.env.example` and `app/config.py` +2. Update user settings model if needed (`app/models/settings.py`) +3. Modify `RagService` methods to use new endpoints +4. Update query endpoint logic in `app/api/v1/query.py` + +## Security Considerations + +- JWT tokens have 30-day expiration (configurable via `JWT_EXPIRE_MINUTES`) +- mTLS certificates are stored server-side only (never sent to client) +- CORS is currently set to `allow_origins=["*"]` - configure properly for production +- Never commit `.env` file (use `.env.example` as template) +- Secrets (JWT_SECRET_KEY, bearer tokens) must be kept secure +- User settings may contain sensitive tokens - handle with care + +## Project Status + +See [PROJECT_STATUS.md](PROJECT_STATUS.md) for detailed implementation status and TODOs. Key points: +- Core infrastructure is complete (auth, DB API client, RAG service) +- All main API endpoints are implemented +- TgBackendInterface is fully implemented (not a stub) +- Frontend integration pending (static/ directory is empty) +- No tests yet (tests/ directory is empty) diff --git a/DB_API_CONTRACT.md b/DB_API_CONTRACT.md new file mode 100644 index 0000000..ab9a4a3 --- /dev/null +++ b/DB_API_CONTRACT.md @@ -0,0 +1,308 @@ +# DB API Contract + +Контракт для взаимодействия FastAPI приложения Brief Bench с внешним DB API сервисом. + +## Base URL + +``` +{DB_API_URL} # Из .env, например: http://db-api-service:8080/api/v1 +``` + +--- + +## Authentication & Users + +### POST /users/login + +Авторизация пользователя и запись логина. + +**Request:** +```json +{ + "login": "12345678", // 8-значный логин (строка) + "client_ip": "192.168.1.100" +} +``` + +**Response 200:** +```json +{ + "user_id": "uuid-string", + "login": "12345678", + "last_login_at": "2025-12-17T12:00:00Z", + "created_at": "2025-12-01T10:00:00Z" +} +``` + +**Errors:** +- 400: Неверный формат логина +- 500: Ошибка сервера + +--- + +## User Settings + +### GET /users/{user_id}/settings + +Получить настройки пользователя для всех окружений (IFT, PSI, PROD). + +**Path Parameters:** +- `user_id`: UUID пользователя + +**Response 200:** +```json +{ + "user_id": "uuid-string", + "settings": { + "ift": { + "apiMode": "bench", + "bearerToken": "...", + "systemPlatform": "...", + "systemPlatformUser": "...", + "platformUserId": "...", + "platformId": "...", + "withClassify": false, + "resetSessionMode": true + }, + "psi": { ... }, + "prod": { ... } + }, + "updated_at": "2025-12-17T12:00:00Z" +} +``` + +**Errors:** +- 404: Пользователь не найден +- 500: Ошибка сервера + +--- + +### PUT /users/{user_id}/settings + +Обновить настройки пользователя. + +**Path Parameters:** +- `user_id`: UUID пользователя + +**Request:** +```json +{ + "settings": { + "ift": { + "apiMode": "bench", + "bearerToken": "...", + "systemPlatform": "...", + "systemPlatformUser": "...", + "platformUserId": "...", + "platformId": "...", + "withClassify": false, + "resetSessionMode": true + }, + "psi": { ... }, + "prod": { ... } + } +} +``` + +**Response 200:** +```json +{ + "user_id": "uuid-string", + "settings": { ... }, + "updated_at": "2025-12-17T12:05:00Z" +} +``` + +**Errors:** +- 404: Пользователь не найден +- 400: Неверный формат настроек +- 500: Ошибка сервера + +--- + +## Analysis Sessions + +### POST /users/{user_id}/sessions + +Создать новую сессию анализа. + +**Path Parameters:** +- `user_id`: UUID пользователя + +**Request:** +```json +{ + "environment": "ift", // ift | psi | prod + "api_mode": "bench", // bench | backend + "request": [ + { "body": "Вопрос 1", "with_docs": true }, + { "body": "Вопрос 2", "with_docs": true } + ], + "response": { + "answers": [ ... ] // RagResponseBenchList format + }, + "annotations": { + "0": { + "overall": { "rating": "correct", "comment": "..." }, + "body_research": { "issues": [], "comment": "" }, + ... + } + } +} +``` + +**Response 201:** +```json +{ + "session_id": "uuid-string", + "user_id": "uuid-string", + "environment": "ift", + "api_mode": "bench", + "request": [ ... ], + "response": { ... }, + "annotations": { ... }, + "created_at": "2025-12-17T12:00:00Z", + "updated_at": "2025-12-17T12:00:00Z" +} +``` + +**Errors:** +- 404: Пользователь не найден +- 400: Неверный формат данных +- 500: Ошибка сервера + +--- + +### GET /users/{user_id}/sessions + +Получить список сессий пользователя. + +**Path Parameters:** +- `user_id`: UUID пользователя + +**Query Parameters:** +- `environment` (optional): Фильтр по окружению (ift/psi/prod) +- `limit` (optional): Лимит результатов (default: 50) +- `offset` (optional): Смещение для пагинации (default: 0) + +**Response 200:** +```json +{ + "sessions": [ + { + "session_id": "uuid-string", + "environment": "ift", + "created_at": "2025-12-17T12:00:00Z" + }, + ... + ], + "total": 123 +} +``` + +**Errors:** +- 404: Пользователь не найден +- 500: Ошибка сервера + +--- + +### GET /users/{user_id}/sessions/{session_id} + +Получить конкретную сессию по ID. + +**Path Parameters:** +- `user_id`: UUID пользователя +- `session_id`: UUID сессии + +**Response 200:** +```json +{ + "session_id": "uuid-string", + "user_id": "uuid-string", + "environment": "ift", + "api_mode": "bench", + "request": [ ... ], + "response": { ... }, + "annotations": { ... }, + "created_at": "2025-12-17T12:00:00Z", + "updated_at": "2025-12-17T12:00:00Z" +} +``` + +**Errors:** +- 404: Сессия или пользователь не найдены +- 500: Ошибка сервера + +--- + +### PUT /users/{user_id}/sessions/{session_id} + +Обновить сессию (например, аннотации). + +**Path Parameters:** +- `user_id`: UUID пользователя +- `session_id`: UUID сессии + +**Request:** +```json +{ + "annotations": { + "0": { ... }, + "1": { ... } + } +} +``` + +**Response 200:** +```json +{ + "session_id": "uuid-string", + "user_id": "uuid-string", + ... + "updated_at": "2025-12-17T12:05:00Z" +} +``` + +**Errors:** +- 404: Сессия или пользователь не найдены +- 400: Неверный формат данных +- 500: Ошибка сервера + +--- + +### DELETE /users/{user_id}/sessions/{session_id} + +Удалить сессию. + +**Path Parameters:** +- `user_id`: UUID пользователя +- `session_id`: UUID сессии + +**Response 204:** +Нет тела ответа. + +**Errors:** +- 404: Сессия или пользователь не найдены +- 500: Ошибка сервера + +--- + +## Common Error Format + +Все ошибки возвращаются в едином формате: + +```json +{ + "detail": "Описание ошибки", + "error_code": "OPTIONAL_ERROR_CODE" +} +``` + +--- + +## Notes + +1. Все даты в формате ISO 8601: `YYYY-MM-DDTHH:MM:SSZ` +2. UUID используется для идентификации пользователей и сессий +3. Все запросы должны включать `Content-Type: application/json` +4. Пустые строки в настройках означают "не задано" (опциональные поля) diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..f53613d --- /dev/null +++ b/DEVELOPMENT_PLAN.md @@ -0,0 +1,682 @@ +# План развития Brief Bench FastAPI + +**Дата создания:** 17 декабря 2025 +**Статус:** Backend готов, требуется frontend интеграция и тестирование + +--- + +## Текущее состояние + +### ✅ Готово (Backend) +- Структура FastAPI приложения +- JWT авторизация (8-значный логин) +- TgBackendInterface (полная реализация с httpx) +- DBApiClient (интеграция с DB API) +- RagService (интеграция с RAG backends, mTLS) +- API endpoints: + - `POST /api/v1/auth/login` - авторизация + - `GET/PUT /api/v1/settings` - настройки пользователя + - `POST /api/v1/query/bench` - batch запросы + - `POST /api/v1/query/backend` - последовательные запросы + - `POST/GET/DELETE /api/v1/analysis/sessions` - сессии анализа +- Docker setup (Dockerfile, docker-compose.yml) +- Документация (README.md, DB_API_CONTRACT.md, CLAUDE.md) + +### ❌ Требуется доделать +- Frontend файлы (перенос из rag-bench-old-version) +- API client для frontend +- Интеграция frontend с новым API +- Тестирование +- Production deployment конфигурация + +--- + +## Этап 1: Подготовка Frontend + +### 1.1 Перенос статических файлов из rag-bench-old-version + +**Старая архитектура:** +- Статический SPA напрямую делал fetch запросы к RAG backends +- Настройки хранились в localStorage (отдельно для каждого окружения) +- Встроенная поддержка 3 окружений (IFT, PSI, PROD) +- Два режима: Bench (batch) и Backend (sequential) +- Аннотации, экспорт/импорт, детальный UI для анализа ответов + +**Файлы для переноса:** +```bash +# Скопировать из rag-bench-old-version/ в static/ +static/ +├── index.html # Основная страница (24KB, 495 строк) +├── styles.css # Material Design стили (23KB) +├── app.js # Основная логика (61KB, ~1500+ строк) +├── settings.js # Дефолтные настройки (3KB) +└── api-client.js # НОВЫЙ ФАЙЛ - будет создан +``` + +**Ключевые изменения в архитектуре:** +1. **Было**: `Browser → RAG Backend` (прямой fetch) +2. **Стало**: `Browser → FastAPI → RAG Backend` +3. **Было**: Настройки в localStorage +4. **Стало**: Настройки в DB API (персональные для каждого пользователя) +5. **Добавлено**: JWT авторизация с 8-значным логином + +### 1.2 Создать API client для frontend + +**Файл:** `static/api-client.js` + +**Полная реализация:** +```javascript +/** + * Brief Bench API Client + * Взаимодействие с FastAPI backend + */ +class BriefBenchAPI { + constructor() { + this.baseURL = '/api/v1' + } + + // ============================================ + // Internal Helpers + // ============================================ + + _getToken() { + return localStorage.getItem('access_token') + } + + _setToken(token) { + localStorage.setItem('access_token', token) + } + + _clearToken() { + localStorage.removeItem('access_token') + } + + _getHeaders(includeAuth = true) { + const headers = { + 'Content-Type': 'application/json' + } + + if (includeAuth) { + const token = this._getToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + } + + return headers + } + + async _handleResponse(response) { + // Handle 401 Unauthorized + if (response.status === 401) { + this._clearToken() + throw new Error('Сессия истекла. Пожалуйста, войдите снова.') + } + + // Handle 502 Bad Gateway (RAG backend error) + if (response.status === 502) { + throw new Error('RAG backend недоступен или вернул ошибку') + } + + // Handle other errors + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`) + } + + // Handle 204 No Content + if (response.status === 204) { + return null + } + + return await response.json() + } + + async _request(endpoint, options = {}) { + const url = `${this.baseURL}${endpoint}` + + try { + const response = await fetch(url, options) + return await this._handleResponse(response) + } catch (error) { + console.error(`API request failed: ${endpoint}`, error) + throw error + } + } + + // ============================================ + // Auth API + // ============================================ + + /** + * Авторизация с 8-значным логином + * @param {string} login - 8-значный логин + * @returns {Promise<{access_token: string, user: object}>} + */ + async login(login) { + const response = await this._request(`/auth/login?login=${login}`, { + method: 'POST', + headers: this._getHeaders(false) + }) + + // Сохранить токен + this._setToken(response.access_token) + + return response + } + + /** + * Выход (очистка токена) + */ + logout() { + this._clearToken() + window.location.reload() + } + + /** + * Проверка авторизации + * @returns {boolean} + */ + isAuthenticated() { + return !!this._getToken() + } + + // ============================================ + // Settings API + // ============================================ + + /** + * Получить настройки пользователя для всех окружений + * @returns {Promise<{user_id: string, settings: object, updated_at: string}>} + */ + async getSettings() { + return await this._request('/settings', { + method: 'GET', + headers: this._getHeaders() + }) + } + + /** + * Обновить настройки пользователя + * @param {object} settings - Объект с настройками для окружений + * @returns {Promise<{user_id: string, settings: object, updated_at: string}>} + */ + async updateSettings(settings) { + return await this._request('/settings', { + method: 'PUT', + headers: this._getHeaders(), + body: JSON.stringify({ settings }) + }) + } + + // ============================================ + // Query API + // ============================================ + + /** + * Отправить batch запрос (Bench mode) + * @param {string} environment - Окружение (ift/psi/prod) + * @param {Array<{body: string, with_docs: boolean}>} questions - Массив вопросов + * @returns {Promise<{request_id: string, timestamp: string, environment: string, response: object}>} + */ + async benchQuery(environment, questions) { + return await this._request('/query/bench', { + method: 'POST', + headers: this._getHeaders(), + body: JSON.stringify({ + environment, + questions + }) + }) + } + + /** + * Отправить последовательные запросы (Backend mode) + * @param {string} environment - Окружение (ift/psi/prod) + * @param {Array<{body: string, with_docs: boolean}>} questions - Массив вопросов + * @param {boolean} resetSession - Сбрасывать ли сессию после каждого вопроса + * @returns {Promise<{request_id: string, timestamp: string, environment: string, response: object}>} + */ + async backendQuery(environment, questions, resetSession = true) { + return await this._request('/query/backend', { + method: 'POST', + headers: this._getHeaders(), + body: JSON.stringify({ + environment, + questions, + reset_session: resetSession + }) + }) + } + + // ============================================ + // Analysis Sessions API + // ============================================ + + /** + * Сохранить сессию анализа + * @param {object} sessionData - Данные сессии + * @returns {Promise} + */ + async saveSession(sessionData) { + return await this._request('/analysis/sessions', { + method: 'POST', + headers: this._getHeaders(), + body: JSON.stringify(sessionData) + }) + } + + /** + * Получить список сессий + * @param {string|null} environment - Фильтр по окружению (опционально) + * @param {number} limit - Лимит результатов + * @param {number} offset - Смещение для пагинации + * @returns {Promise<{sessions: Array, total: number}>} + */ + async getSessions(environment = null, limit = 50, offset = 0) { + const params = new URLSearchParams({ limit, offset }) + if (environment) { + params.append('environment', environment) + } + + return await this._request(`/analysis/sessions?${params}`, { + method: 'GET', + headers: this._getHeaders() + }) + } + + /** + * Получить конкретную сессию + * @param {string} sessionId - ID сессии + * @returns {Promise} + */ + async getSession(sessionId) { + return await this._request(`/analysis/sessions/${sessionId}`, { + method: 'GET', + headers: this._getHeaders() + }) + } + + /** + * Удалить сессию + * @param {string} sessionId - ID сессии + * @returns {Promise} + */ + async deleteSession(sessionId) { + return await this._request(`/analysis/sessions/${sessionId}`, { + method: 'DELETE', + headers: this._getHeaders() + }) + } +} + +// Export API client instance +const api = new BriefBenchAPI() +``` + +**Особенности реализации:** +- Автоматическое добавление `Authorization: Bearer {token}` из localStorage +- Обработка всех HTTP ошибок (401 → logout + reload, 502 → RAG error) +- Singleton instance (`const api = new BriefBenchAPI()`) +- Методы соответствуют FastAPI endpoints один-к-одному + +--- + +## Этап 2: Адаптация Frontend под новую архитектуру + +### 2.1 Добавить Login Screen + +**Изменения в `index.html`:** +```html + +
+

Brief Bench

+ + +
+
+ + + +``` + +**Логика в `app.js`:** +- При загрузке проверить наличие токена в localStorage +- Если нет → показать login screen +- Если есть → валидировать токен (попробовать загрузить настройки) +- Если токен невалидный → показать login screen + +### 2.2 Переписать вызовы API + +**Старый код (прямые fetch):** +```javascript +// Было +const response = await fetch('https://rag-backend/api/bench', { + method: 'POST', + headers: { ... }, + body: JSON.stringify(questions) +}) +``` + +**Новый код (через API client):** +```javascript +// Стало +const api = new BriefBenchAPI() +const response = await api.benchQuery('ift', questions) +``` + +**Что нужно переписать:** +- Загрузка настроек пользователя (при старте приложения) +- Отправка bench/backend запросов +- Сохранение сессий анализа +- Загрузка истории сессий +- Экспорт данных + +### 2.3 Управление Settings через UI + +**Новая логика:** +1. При загрузке приложения: `GET /api/v1/settings` → загрузить настройки для всех 3 окружений +2. Отобразить в UI (вкладки для IFT/PSI/PROD) +3. При изменении: `PUT /api/v1/settings` → сохранить в DB API + +**Поля настроек (для каждого окружения):** +- API Mode: bench / backend (radio buttons) +- Bearer Token (input, password type) +- System Platform (input) +- System Platform User (input) +- Platform User ID (input) +- Platform ID (input) +- With Classify (checkbox, только для backend mode) +- Reset Session Mode (checkbox, только для backend mode) + +### 2.4 Интеграция History (сессии анализа) + +**Функционал:** +1. После выполнения анализа → кнопка "Сохранить сессию" +2. При сохранении: `POST /api/v1/analysis/sessions` + - Отправить environment, api_mode, request, response, annotations +3. Вкладка "История": + - Загрузить список: `GET /api/v1/analysis/sessions?environment=ift` + - Отобразить в виде таблицы (дата, окружение, режим, кол-во вопросов) + - При клике → загрузить полную сессию: `GET /api/v1/analysis/sessions/{id}` + - Кнопка удаления: `DELETE /api/v1/analysis/sessions/{id}` + +--- + +## Этап 3: Environment Selector + +### 3.1 UI для выбора окружения + +**Добавить в `index.html`:** +```html +
+ + +
+``` + +**Логика:** +- При выборе окружения → загрузить настройки для этого окружения +- Отобразить текущий apiMode (bench/backend) +- При отправке запроса → использовать выбранное окружение + +### 3.2 Валидация перед отправкой + +**Проверки:** +1. Выбрано окружение +2. Настройки для окружения существуют +3. apiMode соответствует выбранному режиму (bench/backend) +4. Если требуется bearerToken → он заполнен + +**Показывать ошибки:** +- "Настройки для окружения ИФТ не найдены. Перейдите в Settings." +- "Окружение ПСИ настроено на Backend mode, но вы выбрали Bench режим." + +--- + +## Этап 4: Тестирование + +### 4.1 Ручное тестирование + +**Сценарии:** +1. **Авторизация:** + - Успешный вход (8 цифр) + - Ошибка (невалидный логин) + - Истечение токена (через 30 дней или вручную испортить токен) + +2. **Настройки:** + - Загрузка настроек для всех окружений + - Изменение настроек для одного окружения + - Сохранение → перезагрузка страницы → настройки сохранились + +3. **Bench Query:** + - Выбрать IFT, bench mode + - Отправить несколько вопросов + - Проверить что ответ отображается корректно + - Проверить headers в Network tab (Request-Id, System-Id, Bearer token) + +4. **Backend Query:** + - Выбрать PSI, backend mode + - Отправить вопросы последовательно + - Проверить что между вопросами происходит reset session + +5. **Сессии анализа:** + - Сохранить сессию после анализа + - Открыть историю → найти сессию + - Загрузить сессию → проверить что данные корректны + - Удалить сессию + +6. **Ошибки:** + - DB API недоступен → показать ошибку + - RAG backend недоступен → показать ошибку 502 + - Невалидный токен → редирект на login + +### 4.2 Автоматическое тестирование (опционально) + +**Backend tests (pytest):** +```bash +tests/ +├── test_auth.py # Тесты авторизации +├── test_settings.py # Тесты settings endpoints +├── test_query.py # Тесты query endpoints +├── test_analysis.py # Тесты analysis endpoints +└── conftest.py # Fixtures (mock DB API, mock RAG) +``` + +**Установка:** +```bash +pip install pytest pytest-asyncio httpx +``` + +**Запуск:** +```bash +pytest tests/ -v +``` + +--- + +## Этап 5: Production Deployment + +### 5.1 Настройка .env для production + +**Критичные изменения:** +```bash +# Application +DEBUG=false + +# JWT (сгенерировать новый секретный ключ!) +JWT_SECRET_KEY=<сгенерированный-секрет-256-бит> + +# DB API (production URL) +DB_API_URL=https://db-api.production.com/api/v1 + +# RAG Backends (production hosts) +IFT_RAG_HOST=ift-rag.production.com +PSI_RAG_HOST=psi-rag.production.com +PROD_RAG_HOST=prod-rag.production.com + +# mTLS сертификаты (положить в certs/) +IFT_RAG_CERT_CA=/app/certs/ift/ca.crt +IFT_RAG_CERT_KEY=/app/certs/ift/client.key +IFT_RAG_CERT_CERT=/app/certs/ift/client.crt +# (аналогично для PSI и PROD) +``` + +### 5.2 CORS configuration + +**Изменить `app/main.py`:** +```python +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://brief-bench.production.com", # Production domain + "http://localhost:8000" # Для локальной разработки + ], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Authorization", "Content-Type"], +) +``` + +### 5.3 Размещение mTLS сертификатов + +```bash +certs/ +├── ift/ +│ ├── ca.crt +│ ├── client.key +│ ├── client.crt +├── psi/ +│ ├── ca.crt +│ ├── client.key +│ ├── client.crt +└── prod/ + ├── ca.crt + ├── client.key + └── client.crt +``` + +**Важно:** Убедиться что права доступа `600` или `400` (только чтение владельцем) + +### 5.4 Docker deployment + +**Запуск:** +```bash +# Создать .env +cp .env.example .env +nano .env # Заполнить production значения + +# Разместить сертификаты +mkdir -p certs/ift certs/psi certs/prod +# Скопировать сертификаты в соответствующие папки + +# Запустить +docker-compose up -d + +# Проверить логи +docker-compose logs -f fastapi + +# Проверить здоровье +curl http://localhost:8000/health +``` + +### 5.5 Logging и Monitoring + +**Добавить middleware для логирования (опционально):** +```python +# app/middleware/logging_middleware.py +import logging +import time +from fastapi import Request + +logger = logging.getLogger(__name__) + +async def log_requests(request: Request, call_next): + start_time = time.time() + + logger.info(f"Request: {request.method} {request.url}") + + response = await call_next(request) + + process_time = time.time() - start_time + logger.info( + f"Response: {response.status_code} " + f"({process_time:.2f}s)" + ) + + return response +``` + +**Зарегистрировать в `app/main.py`:** +```python +from app.middleware.logging_middleware import log_requests + +app.middleware("http")(log_requests) +``` + +--- + +## Этап 6: Дополнительные улучшения (опционально) + +### 6.1 Rate Limiting +- Ограничить количество запросов от одного пользователя +- Использовать `slowapi` или redis-based rate limiter + +### 6.2 WebSocket для real-time updates +- Показывать прогресс для backend mode (вопрос N из M) +- Использовать FastAPI WebSocket + +### 6.3 Export функционал +- CSV экспорт сессий анализа +- JSON экспорт для интеграции с другими системами + +### 6.4 Advanced Analytics +- Dashboard с метриками (кол-во запросов, успешность, время ответа) +- Графики по окружениям + +--- + +## Приоритезация задач + +### 🔴 Критично (сделать в первую очередь) +1. Перенос статических файлов из rag-bench-old-version → `static/` +2. Создание `api-client.js` +3. Добавление login screen в `index.html` +4. Переписывание вызовов API в `app.js` +5. Тестирование auth flow + +### 🟡 Важно (сделать после критичного) +6. Интеграция Settings UI +7. Environment selector +8. Сохранение и загрузка сессий анализа +9. Ручное тестирование всех сценариев + +### 🟢 Желательно (если есть время) +10. Автоматические тесты (pytest) +11. Production deployment настройка +12. Logging middleware +13. Rate limiting +14. Export функционал + +--- + +## Следующие шаги + +**Рекомендуемый порядок:** +1. Посмотреть структуру старого проекта `rag-bench-old-version` +2. Скопировать статические файлы в `static/` +3. Создать `api-client.js` с методами для всех endpoints +4. Модифицировать `index.html` (добавить login screen) +5. Переписать `app.js` для работы с новым API +6. Протестировать локально с `uvicorn app.main:app --reload` +7. Исправить найденные проблемы +8. Подготовить production deployment + +**Готовы начать с первого этапа?** diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cffc301 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Multi-stage build for Brief Bench FastAPI + +FROM python:3.11-slim as base + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app /app/app +COPY static /app/static + +# Expose port +EXPOSE 8000 + +# Run uvicorn +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..b2f618d --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,677 @@ +# Руководство по миграции с rag-bench-old-version на FastAPI + +Этот документ содержит детальные инструкции по переносу статического SPA на новую архитектуру с FastAPI backend. + +--- + +## Основные изменения в архитектуре + +### Старая версия (rag-bench-old-version) +``` +Browser (index.html + app.js) + ↓ fetch (HTTPS + mTLS) +RAG Backend (IFT/PSI/PROD) + +Настройки: localStorage +Авторизация: нет +``` + +### Новая версия (brief-bench-fastapi) +``` +Browser (index.html + app.js + api-client.js) + ↓ fetch (HTTP, JWT token) +FastAPI Backend + ↓ httpx (HTTPS + mTLS) +RAG Backend (IFT/PSI/PROD) + +Настройки: DB API (per-user) +Авторизация: JWT (8-значный логин) +``` + +--- + +## Шаг 1: Копирование статических файлов + +```bash +# Из корня проекта brief-bench-fastapi +cp rag-bench-old-version/index.html static/ +cp rag-bench-old-version/styles.css static/ +cp rag-bench-old-version/app.js static/ +cp rag-bench-old-version/settings.js static/ +``` + +--- + +## Шаг 2: Создание api-client.js + +См. полную реализацию в [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md#12-создать-api-client-для-frontend). + +Создать файл `static/api-client.js` с классом `BriefBenchAPI`. + +--- + +## Шаг 3: Модификация index.html + +### 3.1 Добавить подключение api-client.js + +**Изменить:** +```html + + + +``` + +**На:** +```html + + + + +``` + +### 3.2 Добавить Login Screen + +**Добавить ПЕРЕД `
`:** +```html + + +``` + +### 3.3 Добавить стили для Login Screen + +**Добавить в конец `styles.css`:** +```css +/* Login Screen */ +.login-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.login-card { + background: white; + padding: var(--md-spacing-xxl); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + max-width: 400px; + width: 90%; +} + +.login-card h2 { + color: #667eea; + font-weight: 500; +} + +.login-card .form-input:focus { + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.login-card .btn-filled { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + margin-top: var(--md-spacing-md); +} + +.login-card .btn-filled:hover { + opacity: 0.9; +} +``` + +### 3.4 Удалить настройки сертификатов из UI + +Сертификаты теперь настраиваются в `.env` на сервере, поэтому пользователь не должен их редактировать. + +**Удалить из Settings Dialog:** +```html +
Сертификаты (для прокси)
+
...
+
+ + ... +
+ +``` + +### 3.5 Убрать поля host/port/endpoint из Settings Dialog + +Эти поля теперь тоже настраиваются на сервере. Пользователь редактирует только: +- API Mode (bench/backend) +- Bearer Token +- System Platform / System Platform User +- Platform User ID / Platform ID +- With Classify / Reset Session Mode + +**Удалить:** +```html +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+``` + +### 3.6 Добавить кнопку Logout + +**Добавить в App Bar Actions:** +```html + +``` + +--- + +## Шаг 4: Модификация app.js + +### 4.1 Удалить старые API функции + +**Удалить функции:** +- `buildApiUrl()` +- `sendQuery()` +- `buildBackendApiUrl()` +- `buildBackendHeaders()` +- `sendBackendAsk()` +- `sendBackendReset()` +- `sendBackendSequential()` + +### 4.2 Добавить Login Flow + +**Добавить в начало файла (после объявления AppState):** +```javascript +// ============================================ +// Authentication +// ============================================ + +/** + * Проверить авторизацию при загрузке страницы + */ +async function checkAuth() { + if (!api.isAuthenticated()) { + showLoginScreen() + return false + } + + // Попробовать загрузить настройки (валидация токена) + try { + await loadSettingsFromServer() + return true + } catch (error) { + console.error('Token validation failed:', error) + showLoginScreen() + return false + } +} + +/** + * Показать экран авторизации + */ +function showLoginScreen() { + document.getElementById('login-screen').style.display = 'flex' + document.getElementById('app').style.display = 'none' +} + +/** + * Скрыть экран авторизации и показать приложение + */ +function hideLoginScreen() { + document.getElementById('login-screen').style.display = 'none' + document.getElementById('app').style.display = 'block' +} + +/** + * Обработка авторизации + */ +async function handleLogin() { + const loginInput = document.getElementById('login-input') + const loginError = document.getElementById('login-error') + const loginBtn = document.getElementById('login-submit-btn') + + const login = loginInput.value.trim() + + // Валидация + if (!/^[0-9]{8}$/.test(login)) { + loginError.textContent = 'Логин должен состоять из 8 цифр' + loginError.style.display = 'block' + return + } + + loginError.style.display = 'none' + loginBtn.disabled = true + loginBtn.textContent = 'Вход...' + + try { + const response = await api.login(login) + console.log('Login successful:', response.user) + + // Загрузить настройки с сервера + await loadSettingsFromServer() + + // Скрыть login screen, показать приложение + hideLoginScreen() + loginInput.value = '' + } catch (error) { + console.error('Login failed:', error) + loginError.textContent = error.message || 'Ошибка авторизации' + loginError.style.display = 'block' + } finally { + loginBtn.disabled = false + loginBtn.textContent = 'Войти' + } +} + +/** + * Выход из системы + */ +function handleLogout() { + if (confirm('Вы уверены, что хотите выйти?')) { + api.logout() + } +} +``` + +### 4.3 Изменить управление настройками + +**Заменить функции:** + +```javascript +/** + * Загрузить настройки с сервера (DB API) + */ +async function loadSettingsFromServer() { + try { + const response = await api.getSettings() + + // Преобразовать в формат AppState.settings + AppState.settings = { + activeEnvironment: AppState.currentEnvironment, + environments: { + ift: { + name: 'ИФТ', + ...response.settings.ift + }, + psi: { + name: 'ПСИ', + ...response.settings.psi + }, + prod: { + name: 'ПРОМ', + ...response.settings.prod + } + }, + requestTimeout: 1800000, // 30 минут (фиксировано) + } + + console.log('Settings loaded from server:', AppState.settings) + } catch (error) { + console.error('Failed to load settings from server:', error) + throw error + } +} + +/** + * Сохранить настройки на сервер (DB API) + */ +async function saveSettingsToServer(settings) { + try { + // Извлечь только поля, которые сервер ожидает + const settingsToSave = { + ift: extractEnvironmentSettings(settings.environments.ift), + psi: extractEnvironmentSettings(settings.environments.psi), + prod: extractEnvironmentSettings(settings.environments.prod) + } + + await api.updateSettings(settingsToSave) + AppState.settings = settings + + console.log('Settings saved to server') + } catch (error) { + console.error('Failed to save settings to server:', error) + throw error + } +} + +/** + * Извлечь только нужные поля для сервера + */ +function extractEnvironmentSettings(envSettings) { + return { + apiMode: envSettings.apiMode, + bearerToken: envSettings.bearerToken || '', + systemPlatform: envSettings.systemPlatform || '', + systemPlatformUser: envSettings.systemPlatformUser || '', + platformUserId: envSettings.platformUserId || '', + platformId: envSettings.platformId || '', + withClassify: envSettings.withClassify || false, + resetSessionMode: envSettings.resetSessionMode !== false + } +} + +// УДАЛИТЬ старые функции loadSettings() и saveSettings() +``` + +### 4.4 Заменить вызовы API для Query + +**Было:** +```javascript +// Старый код +async function handleSendQuery() { + // ... + const response = await sendQuery(requestBody) // Прямой fetch к RAG + // ... +} +``` + +**Стало:** +```javascript +async function handleSendQuery() { + // Получить текущие настройки окружения + const envSettings = getCurrentEnvSettings() + const env = AppState.currentEnvironment + + // Проверить режим API + if (envSettings.apiMode === 'bench') { + // Batch mode + const response = await api.benchQuery(env, requestBody) + processResponse(response.response) + } else if (envSettings.apiMode === 'backend') { + // Sequential mode + const resetSession = envSettings.resetSessionMode !== false + const response = await api.backendQuery(env, requestBody, resetSession) + processResponse(response.response) + } +} +``` + +### 4.5 Изменить Save Settings в Settings Dialog + +**Заменить обработчик:** +```javascript +// Было +document.getElementById('save-settings-btn').addEventListener('click', () => { + const updatedSettings = readSettingsFromDialog() + saveSettings(updatedSettings) // localStorage + showToast('Настройки сохранены') + closeSettingsDialog() +}) + +// Стало +document.getElementById('save-settings-btn').addEventListener('click', async () => { + const saveBtn = document.getElementById('save-settings-btn') + saveBtn.disabled = true + saveBtn.textContent = 'Сохранение...' + + try { + const updatedSettings = readSettingsFromDialog() + await saveSettingsToServer(updatedSettings) + showToast('Настройки сохранены на сервере') + closeSettingsDialog() + } catch (error) { + console.error('Failed to save settings:', error) + showToast(`Ошибка сохранения: ${error.message}`, 'error') + } finally { + saveBtn.disabled = false + saveBtn.textContent = 'Сохранить' + } +}) +``` + +### 4.6 Обновить populateSettingsDialog() + +**Удалить строки для полей, которые больше не редактируются:** +```javascript +function populateSettingsDialog() { + const env = AppState.currentEnvironment + const envSettings = AppState.settings.environments[env] + + // Set environment selector + document.getElementById('settings-env-selector').value = env + + // API Mode + const apiMode = envSettings.apiMode || 'bench' + document.getElementById('setting-api-mode').value = apiMode + toggleBackendSettings(apiMode === 'backend') + + // Populate environment-specific fields (только те что остались!) + document.getElementById('setting-bearer-token').value = envSettings.bearerToken || '' + document.getElementById('setting-system-platform').value = envSettings.systemPlatform || '' + document.getElementById('setting-system-platform-user').value = envSettings.systemPlatformUser || '' + + // Backend mode fields + document.getElementById('setting-platform-user-id').value = envSettings.platformUserId || '' + document.getElementById('setting-platform-id').value = envSettings.platformId || '' + document.getElementById('setting-with-classify').checked = envSettings.withClassify || false + document.getElementById('setting-reset-session-mode').checked = envSettings.resetSessionMode !== false + + // Убрать global timeout (теперь не редактируется) +} +``` + +### 4.7 Обновить readSettingsFromDialog() + +**Изменить:** +```javascript +function readSettingsFromDialog() { + const env = document.getElementById('settings-env-selector').value + + // Update environment-specific settings + const updatedSettings = JSON.parse(JSON.stringify(AppState.settings)) // Deep copy + updatedSettings.environments[env] = { + name: updatedSettings.environments[env].name, + apiMode: document.getElementById('setting-api-mode').value, + bearerToken: document.getElementById('setting-bearer-token').value.trim(), + systemPlatform: document.getElementById('setting-system-platform').value.trim(), + systemPlatformUser: document.getElementById('setting-system-platform-user').value.trim(), + platformUserId: document.getElementById('setting-platform-user-id').value.trim(), + platformId: document.getElementById('setting-platform-id').value.trim(), + withClassify: document.getElementById('setting-with-classify').checked, + resetSessionMode: document.getElementById('setting-reset-session-mode').checked, + } + + return updatedSettings +} +``` + +### 4.8 Добавить event listeners + +**Добавить в секцию Event Listeners:** +```javascript +// Login +document.getElementById('login-submit-btn').addEventListener('click', handleLogin) +document.getElementById('login-input').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + handleLogin() + } +}) + +// Logout +document.getElementById('logout-btn').addEventListener('click', handleLogout) +``` + +### 4.9 Изменить инициализацию приложения + +**Было:** +```javascript +// Initialize app on load +document.addEventListener('DOMContentLoaded', () => { + AppState.settings = loadSettings() + // ... + initApp() +}) +``` + +**Стало:** +```javascript +// Initialize app on load +document.addEventListener('DOMContentLoaded', async () => { + // Проверить авторизацию + const isAuthenticated = await checkAuth() + + if (isAuthenticated) { + // Пользователь авторизован, инициализировать приложение + initApp() + } +}) +``` + +--- + +## Шаг 5: Изменения в settings.js + +**Упростить defaultSettings:** + +Удалить поля, которые теперь настраиваются на сервере (host, port, endpoint, certPaths, systemId, requestIdTemplate). + +Оставить только дефолты для тех полей, которые пользователь может редактировать: +```javascript +const defaultSettings = { + activeEnvironment: 'ift', + + environments: { + ift: { + name: 'ИФТ', + apiMode: 'bench', + bearerToken: '', + systemPlatform: '', + systemPlatformUser: '', + platformUserId: '', + platformId: '', + withClassify: false, + resetSessionMode: true, + }, + psi: { + name: 'ПСИ', + apiMode: 'bench', + bearerToken: '', + systemPlatform: '', + systemPlatformUser: '', + platformUserId: '', + platformId: '', + withClassify: false, + resetSessionMode: true, + }, + prod: { + name: 'ПРОМ', + apiMode: 'bench', + bearerToken: '', + systemPlatform: '', + systemPlatformUser: '', + platformUserId: '', + platformId: '', + withClassify: false, + resetSessionMode: true, + } + }, + + requestTimeout: 1800000, // 30 minutes (не редактируется) +} +``` + +--- + +## Шаг 6: Раскомментировать static files в main.py + +**В `app/main.py`:** +```python +# Serve static files (frontend) +app.mount("/static", StaticFiles(directory="static"), name="static") +``` + +Раскомментировать эту строку. + +--- + +## Шаг 7: Тестирование + +### 7.1 Запустить FastAPI локально +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 7.2 Открыть в браузере +``` +http://localhost:8000/static/index.html +``` + +### 7.3 Тестовые сценарии +1. Login screen должен показаться +2. Ввести 8-значный логин → успешный вход +3. Настройки должны загрузиться с сервера +4. Изменить настройки → сохранить → перезагрузить → настройки сохранились +5. Отправить bench query → получить ответ +6. Отправить backend query → получить ответ +7. Нажать Logout → вернуться на login screen + +--- + +## Ключевые отличия для разработчика + +| Аспект | Старая версия | Новая версия | +|--------|--------------|--------------| +| Авторизация | Нет | JWT (8 цифр) | +| Настройки | localStorage | DB API (per-user) | +| API вызовы | fetch → RAG напрямую | fetch → FastAPI → RAG | +| mTLS | В браузере (невозможно) | На FastAPI сервере | +| Endpoints | RAG endpoints | FastAPI endpoints | +| Пользователи | Один (все настройки общие) | Множество (настройки персональные) | + +--- + +## Что НЕ нужно менять + +- Весь UI (остаётся как есть) +- Логика отображения ответов +- Аннотации +- Экспорт/импорт анализа (работает через сохранение сессий на сервер) +- Material Design стили diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md new file mode 100644 index 0000000..4a8982a --- /dev/null +++ b/PROJECT_STATUS.md @@ -0,0 +1,446 @@ +# Brief Bench FastAPI - Текущий статус проекта + +**Дата:** 17 декабря 2025 +**Статус:** Базовая структура реализована, готова к продолжению + +--- + +## 📋 Что реализовано + +### 1. Структура проекта + +``` +brief-bench-fastapi/ +├── app/ +│ ├── api/ +│ │ └── v1/ +│ │ ├── __init__.py +│ │ └── auth.py ✅ POST /api/v1/auth/login +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── auth.py ✅ LoginRequest, LoginResponse, UserResponse +│ │ ├── settings.py ✅ EnvironmentSettings, UserSettings +│ │ ├── analysis.py ✅ SessionCreate, SessionResponse, SessionList +│ │ └── query.py ✅ BenchQueryRequest, BackendQueryRequest +│ ├── services/ +│ │ ├── __init__.py +│ │ └── auth_service.py ✅ AuthService (login logic) +│ ├── interfaces/ +│ │ ├── __init__.py +│ │ ├── base.py ⚠️ TgBackendInterface (ЗАГЛУШКА - нужна реализация) +│ │ └── db_api_client.py ✅ DBApiClient (методы для DB API) +│ ├── middleware/ +│ │ └── __init__.py +│ ├── utils/ +│ │ ├── __init__.py +│ │ └── security.py ✅ JWT encode/decode +│ ├── __init__.py +│ ├── config.py ✅ Settings из .env +│ ├── dependencies.py ✅ DI: get_db_client, get_current_user +│ └── main.py ✅ FastAPI app с CORS +├── static/ ❌ Пусто (нужно скопировать из rag-bench) +├── tests/ ❌ Пусто +├── certs/ ❌ Не создана (для mTLS) +├── .env.example ✅ +├── .gitignore ✅ +├── requirements.txt ✅ +├── Dockerfile ✅ +├── docker-compose.yml ✅ +├── DB_API_CONTRACT.md ✅ Полный контракт для DB API +├── README.md ✅ +└── PROJECT_STATUS.md ✅ Этот файл +``` + +--- + +## ✅ Реализованные компоненты + +### 1. Configuration (app/config.py) +- Загрузка из .env через pydantic-settings +- Настройки для 3 окружений RAG (IFT, PSI, PROD) +- JWT конфигурация +- DB API URL + +### 2. Pydantic Models (app/models/) + +**auth.py:** +- `LoginRequest` - login (8 цифр) + client_ip +- `UserResponse` - user_id, login, last_login_at, created_at +- `LoginResponse` - access_token + user +- `TokenPayload` - user_id, login, exp + +**settings.py:** +- `EnvironmentSettings` - настройки для одного окружения (apiMode, bearerToken, platformUserId, etc.) +- `UserSettings` - настройки пользователя для всех 3 окружений +- `UserSettingsUpdate` - обновление настроек + +**analysis.py:** +- `SessionCreate` - создание сессии анализа +- `SessionResponse` - полная сессия +- `SessionListItem` - элемент списка сессий +- `SessionList` - список с total + +**query.py:** +- `QuestionRequest` - body + with_docs +- `BenchQueryRequest` - environment + questions[] +- `BackendQueryRequest` - environment + questions[] + reset_session +- `QueryResponse` - request_id, timestamp, environment, response + +### 3. Interfaces (app/interfaces/) + +**base.py (⚠️ ЗАГЛУШКА!):** +```python +class TgBackendInterface: + def __init__(self, api_prefix: str, **kwargs) + async def get(path, params, response_model) + async def post(path, body, response_model) + async def put(path, body, response_model) + async def delete(path) +``` +**Требует реализации пользователем!** Должна включать: +- httpx.AsyncClient для HTTP запросов +- Автоматическую сериализацию/десериализацию Pydantic моделей +- Error handling и retry logic +- Логирование + +**db_api_client.py:** +Наследуется от TgBackendInterface, методы: +- `login_user(LoginRequest) -> UserResponse` +- `get_user_settings(user_id) -> UserSettings` +- `update_user_settings(user_id, settings) -> UserSettings` +- `save_session(user_id, session_data) -> SessionResponse` +- `get_sessions(user_id, environment, limit, offset) -> SessionList` +- `get_session(user_id, session_id) -> SessionResponse` +- `delete_session(user_id, session_id) -> dict` + +### 4. Services (app/services/) + +**auth_service.py:** +- `AuthService.login(login, client_ip)` - авторизация через DB API + генерация JWT + +### 5. Security & Auth (app/utils/security.py) +- `create_access_token(data, expires_delta)` - создание JWT +- `decode_access_token(token)` - валидация JWT + +### 6. Dependencies (app/dependencies.py) +- `get_db_client()` - DI для DBApiClient +- `get_current_user(credentials)` - middleware для auth + +### 7. API Endpoints (app/api/v1/) + +**auth.py:** +- `POST /api/v1/auth/login?login=12345678` - авторизация + - Возвращает JWT token + user info + - Фиксирует IP клиента + +### 8. Main App (app/main.py) +- FastAPI app с CORS +- Include router для auth +- Health endpoint: GET /health +- Root endpoint: GET / + +### 9. Docker (Dockerfile, docker-compose.yml) +- Multi-stage build +- Порт 8000 +- Volumes: certs/ (ro), static/ +- Network: brief-bench-network + +### 10. Documentation +- **DB_API_CONTRACT.md** - полный контракт для DB API сервиса +- **README.md** - инструкции по запуску и разработке +- **.env.example** - пример конфигурации + +--- + +## ❌ Что НЕ реализовано (TODO) + +### 1. TgBackendInterface реализация (КРИТИЧНО!) +Файл: `app/interfaces/base.py` + +Нужно реализовать: +```python +class TgBackendInterface: + def __init__(self, api_prefix: str, **kwargs): + self.api_prefix = api_prefix + self.client = httpx.AsyncClient( + timeout=kwargs.get('timeout', 30), + # ... другие настройки + ) + + async def get(self, path, params=None, response_model=None, **kwargs): + url = f"{self.api_prefix}{path}" + response = await self.client.get(url, params=params) + response.raise_for_status() + data = response.json() + if response_model: + return response_model(**data) + return data + + # аналогично post, put, delete +``` + +### 2. API Endpoints для Settings +Файл: `app/api/v1/settings.py` (создать!) + +```python +@router.get("/settings") +async def get_settings(user: dict = Depends(get_current_user)): + # Получить настройки пользователя из DB API + +@router.put("/settings") +async def update_settings(settings: UserSettingsUpdate, user: dict = Depends(get_current_user)): + # Обновить настройки через DB API +``` + +### 3. API Endpoints для Query +Файл: `app/api/v1/query.py` (создать!) + +```python +@router.post("/query/bench") +async def bench_query(request: BenchQueryRequest, user: dict = Depends(get_current_user)): + # Отправить batch запрос к RAG backend через RagService + +@router.post("/query/backend") +async def backend_query(request: BackendQueryRequest, user: dict = Depends(get_current_user)): + # Отправить вопросы по одному через RagService +``` + +### 4. API Endpoints для Analysis Sessions +Файл: `app/api/v1/analysis.py` (создать!) + +```python +@router.post("/analysis/sessions") +async def create_session(session: SessionCreate, user: dict = Depends(get_current_user)): + # Сохранить сессию через DB API + +@router.get("/analysis/sessions") +async def get_sessions(environment: str = None, user: dict = Depends(get_current_user)): + # Получить список сессий + +@router.get("/analysis/sessions/{session_id}") +async def get_session(session_id: str, user: dict = Depends(get_current_user)): + # Получить конкретную сессию + +@router.delete("/analysis/sessions/{session_id}") +async def delete_session(session_id: str, user: dict = Depends(get_current_user)): + # Удалить сессию +``` + +### 5. RAG Service +Файл: `app/services/rag_service.py` (создать!) + +```python +class RagService: + def __init__(self): + # Создать 3 httpx.AsyncClient для IFT, PSI, PROD + # Настроить mTLS из config + + async def send_bench_query(self, environment: str, questions: list, user_settings: dict): + # Отправить batch запрос к RAG backend + # Headers: Request-Id, System-Id, Bearer token (из user_settings) + + async def send_backend_query(self, environment: str, questions: list, user_settings: dict): + # Отправить вопросы по одному + # После каждого вопроса: reset session если resetSessionMode=true +``` + +### 6. Services для Settings и Analysis +Файлы: +- `app/services/settings_service.py` (создать!) +- `app/services/analysis_service.py` (создать!) + +### 7. Frontend Integration +- Скопировать `index.html`, `app.js`, `styles.css` из rag-bench в `static/` +- Создать `static/api-client.js` для работы с FastAPI API +- Добавить login screen в index.html +- Обновить app.js для использования api-client.js + +### 8. Middleware (опционально) +- `app/middleware/logging.py` - логирование запросов +- `app/middleware/error_handler.py` - глобальная обработка ошибок + +### 9. Tests +- Unit tests для services +- Integration tests для API endpoints + +--- + +## 🔑 Важные детали для продолжения + +### Архитектура авторизации +1. Пользователь отправляет POST /api/v1/auth/login?login=12345678 +2. FastAPI вызывает DB API: POST /users/login +3. DB API возвращает UserResponse (user_id, login, ...) +4. FastAPI генерирует JWT token с {user_id, login, exp} +5. Возвращает {access_token, user} +6. Клиент сохраняет token в localStorage +7. Все последующие запросы: Authorization: Bearer {token} +8. Middleware get_current_user проверяет token + +### Настройки пользователя +- **В .env** (не редактируются из UI): хосты RAG, порты, endpoint'ы, пути к сертификатам +- **В БД** (индивидуальны для каждого пользователя): bearerToken, systemPlatform, platformUserId, withClassify, resetSessionMode +- Каждый пользователь имеет свои настройки для 3 окружений (IFT, PSI, PROD) + +### RAG Backend Integration +**Bench mode:** +```python +POST https://{IFT_RAG_HOST}:{IFT_RAG_PORT}/{IFT_RAG_ENDPOINT} +Headers: + Content-Type: application/json + Request-Id: {uuid} + System-Id: brief-bench-ift + Authorization: Bearer {user_settings.bearerToken} # если задан + System-Platform: {user_settings.systemPlatform} # если задан +Body: [ + {"body": "вопрос", "with_docs": true}, + ... +] +``` + +**Backend mode:** +```python +# Для каждого вопроса: +POST https://{host}:{port}/{backendAskEndpoint} +Headers: + Platform-User-Id: {user_settings.platformUserId} + Platform-Id: {user_settings.platformId} + Authorization: Bearer {user_settings.bearerToken} # если задан +Body: { + "question": "вопрос", + "user_message_id": 1, + "user_message_datetime": "2025-12-17T12:00:00Z", + "with_classify": false +} + +# Если resetSessionMode=true: +POST https://{host}:{port}/{backendResetEndpoint} +Body: { + "user_message_datetime": "2025-12-17T12:00:00Z" +} +``` + +### DB API Endpoints (см. DB_API_CONTRACT.md) +- POST /users/login +- GET /users/{user_id}/settings +- PUT /users/{user_id}/settings +- POST /users/{user_id}/sessions +- GET /users/{user_id}/sessions +- GET /users/{user_id}/sessions/{session_id} +- DELETE /users/{user_id}/sessions/{session_id} + +--- + +## 🚀 План дальнейшей работы + +### Этап 1: Реализовать TgBackendInterface +**Приоритет:** ВЫСОКИЙ +**Файл:** app/interfaces/base.py + +Без этого DB API клиент не будет работать. + +### Этап 2: Добавить недостающие API endpoints +**Приоритет:** ВЫСОКИЙ +**Файлы:** +- app/api/v1/settings.py +- app/api/v1/query.py +- app/api/v1/analysis.py + +Зарегистрировать в app/main.py: +```python +from app.api.v1 import auth, settings, query, analysis + +app.include_router(settings.router, prefix="/api/v1") +app.include_router(query.router, prefix="/api/v1") +app.include_router(analysis.router, prefix="/api/v1") +``` + +### Этап 3: Реализовать RAG Service +**Приоритет:** ВЫСОКИЙ +**Файл:** app/services/rag_service.py + +Нужно для query endpoints. + +### Этап 4: Реализовать Settings & Analysis Services +**Приоритет:** СРЕДНИЙ +**Файлы:** +- app/services/settings_service.py +- app/services/analysis_service.py + +### Этап 5: Frontend Integration +**Приоритет:** СРЕДНИЙ +- Скопировать static/ файлы из rag-bench +- Создать api-client.js +- Добавить login screen +- Обновить app.js + +### Этап 6: Testing & Deployment +**Приоритет:** НИЗКИЙ +- Создать .env из .env.example +- Добавить mTLS сертификаты в certs/ +- docker-compose up +- Тестирование + +--- + +## 📦 Dependencies (requirements.txt) + +``` +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 +httpx==0.25.2 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +anyio==4.1.0 +python-dotenv==1.0.0 +fastapi-cors==0.0.6 +``` + +--- + +## 🔧 Команды для разработки + +```bash +# Установить зависимости +pip install -r requirements.txt + +# Запустить локально +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# Docker +docker-compose up -d +docker-compose logs -f fastapi +docker-compose down + +# Проверить здоровье +curl http://localhost:8000/health +``` + +--- + +## 📝 Примечания + +1. **TgBackendInterface** - это ваша реализация, которая будет использоваться во всех клиентах (DBApiClient, возможно RagClient в будущем) + +2. **api_prefix** - каждый клиент инициализируется с базовым URL (например, http://db-api:8080/api/v1) + +3. **Pydantic схемы** - используются везде для type-safety и валидации + +4. **JWT токены** - 30-дневная экспирация, содержат user_id и login + +5. **mTLS** - настраивается в .env, пути к сертификатам для каждого окружения + +6. **CORS** - сейчас allow_origins=["*"], нужно настроить для production + +7. **Ошибки** - возвращаются в формате FastAPI HTTPException с detail + +--- + +## 🎯 Готово к продолжению! + +Вся базовая структура создана. Следующий шаг - реализация TgBackendInterface и остальных endpoints. + +**Следующий чат:** начните с реализации TgBackendInterface или создания недостающих API endpoints. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8beb9c4 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# Brief Bench FastAPI + +FastAPI backend для системы тестирования RAG с multi-user поддержкой. + +## Возможности + +- 🔐 JWT авторизация (8-значный логин) +- 🌐 Multi-environment: ИФТ, ПСИ, ПРОМ +- 📊 Bench mode: batch тестирование +- 🤖 Backend mode: имитация бота (вопросы по одному) +- 💾 Сохранение сессий анализа +- 🔒 mTLS для RAG backend +- 📝 Аннотации и экспорт + +## Требования + +- Python 3.11+ +- Docker & Docker Compose (для деплоя) +- Доступ к DB API сервису +- mTLS сертификаты для RAG backend (опционально) + +## Быстрый старт + +### 1. Клонировать репозиторий + +```bash +cd /path/to/brief-bench-fastapi +``` + +### 2. Настроить окружение + +Скопируйте `.env.example` в `.env` и заполните: + +```bash +cp .env.example .env +nano .env +``` + +**Обязательные переменные:** +- `JWT_SECRET_KEY` - секретный ключ для JWT (сгенерируйте новый!) +- `DB_API_URL` - URL DB API сервиса +- `IFT_RAG_HOST`, `PSI_RAG_HOST`, `PROD_RAG_HOST` - хосты RAG backend + +### 3. Разместить сертификаты (если используется mTLS) + +``` +certs/ + ift/ + ca.crt + client.key + client.crt + psi/ + ... + prod/ + ... +``` + +### 4. Запустить с Docker Compose + +```bash +docker-compose up -d +``` + +Приложение доступно на `http://localhost:8000` + +### 5. Проверить здоровье + +```bash +curl http://localhost:8000/health +``` + +## Разработка + +### Локальный запуск (без Docker) + +```bash +# Создать виртуальное окружение +python -m venv venv +source venv/bin/activate # Linux/Mac +# или +venv\Scripts\activate # Windows + +# Установить зависимости +pip install -r requirements.txt + +# Запустить +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Структура проекта + +``` +brief-bench-fastapi/ +├── app/ +│ ├── api/v1/ # API endpoints +│ ├── models/ # Pydantic models +│ ├── services/ # Business logic +│ ├── interfaces/ # API clients (DB, RAG) +│ ├── middleware/ # Middleware +│ ├── utils/ # Utilities (JWT, etc.) +│ ├── config.py # Configuration +│ ├── dependencies.py # DI +│ └── main.py # FastAPI app +├── static/ # Frontend files +├── tests/ # Tests +├── certs/ # mTLS certificates +├── .env # Environment variables +├── requirements.txt # Python dependencies +├── Dockerfile # Docker image +├── docker-compose.yml # Docker Compose +└── DB_API_CONTRACT.md # DB API contract +``` + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/login` - Авторизация (8-значный логин) + +### Health +- `GET /health` - Health check +- `GET /` - Root info + +## DB API Contract + +См. [DB_API_CONTRACT.md](DB_API_CONTRACT.md) для полного описания контракта с DB API сервисом. + +## Интерфейсы (Interfaces) + +### TgBackendInterface + +Базовый класс для всех API клиентов. **Требует реализации пользователем!** + +См. `app/interfaces/base.py` для заглушки и требований. + +### DBApiClient + +Наследуется от `TgBackendInterface`, предоставляет методы для работы с DB API: +- `login_user()` +- `get_user_settings()`, `update_user_settings()` +- `save_session()`, `get_sessions()`, `get_session()`, `delete_session()` + +## Deployment + +### Docker Compose + +```bash +docker-compose up -d +``` + +### Logs + +```bash +docker-compose logs -f fastapi +``` + +### Остановить + +```bash +docker-compose down +``` + +## Security + +- JWT токены с 30-дневной экспирацией +- mTLS сертификаты только на сервере +- Secrets в .env (не коммитить в git!) +- CORS настроен (обновить в production) + +## TODO + +- [ ] Реализовать `TgBackendInterface` (пользователь) +- [ ] Добавить endpoints для settings, query, analysis +- [ ] Добавить RAG service +- [ ] Добавить frontend интеграцию +- [ ] Добавить тесты +- [ ] Rate limiting +- [ ] Logging middleware + +## License + +Proprietary diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..bd0f6ee --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,7 @@ +""" +API v1 routers. +""" + +from app.api.v1 import auth, settings, query, analysis + +__all__ = ["auth", "settings", "query", "analysis"] diff --git a/app/api/v1/analysis.py b/app/api/v1/analysis.py new file mode 100644 index 0000000..e5704ec --- /dev/null +++ b/app/api/v1/analysis.py @@ -0,0 +1,182 @@ +""" +Analysis Sessions API endpoints. + +Управление сессиями анализа (создание, получение, удаление). +""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from typing import Optional +from app.models.analysis import SessionCreate, SessionResponse, SessionList +from app.interfaces.db_api_client import DBApiClient +from app.dependencies import get_db_client, get_current_user +import httpx +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/analysis", tags=["analysis"]) + + +@router.post("/sessions", response_model=SessionResponse, status_code=status.HTTP_201_CREATED) +async def create_session( + session: SessionCreate, + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + """ + Создать новую сессию анализа. + + Args: + session: Данные сессии (environment, api_mode, request, response, annotations) + + Returns: + SessionResponse: Созданная сессия с session_id + """ + user_id = current_user["user_id"] + + try: + created_session = await db_client.save_session(user_id, session) + return created_session + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + elif e.response.status_code == 400: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid session data format" + ) + logger.error(f"Failed to create session: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to create session in DB API" + ) + except Exception as e: + logger.error(f"Unexpected error creating session: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + + +@router.get("/sessions", response_model=SessionList) +async def get_sessions( + environment: Optional[str] = Query(None, description="Фильтр по окружению (ift/psi/prod)"), + limit: int = Query(50, ge=1, le=200, description="Лимит результатов"), + offset: int = Query(0, ge=0, description="Смещение для пагинации"), + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + """ + Получить список сессий пользователя. + + Args: + environment: Фильтр по окружению (опционально) + limit: Лимит результатов (default: 50, max: 200) + offset: Смещение для пагинации (default: 0) + + Returns: + SessionList: Список сессий с total count + """ + user_id = current_user["user_id"] + + try: + sessions = await db_client.get_sessions(user_id, environment, limit, offset) + return sessions + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + logger.error(f"Failed to get sessions: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to retrieve sessions from DB API" + ) + except Exception as e: + logger.error(f"Unexpected error getting sessions: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + + +@router.get("/sessions/{session_id}", response_model=SessionResponse) +async def get_session( + session_id: str, + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + """ + Получить конкретную сессию по ID. + + Args: + session_id: UUID сессии + + Returns: + SessionResponse: Полная информация о сессии + """ + user_id = current_user["user_id"] + + try: + session = await db_client.get_session(user_id, session_id) + return session + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + logger.error(f"Failed to get session {session_id}: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to retrieve session from DB API" + ) + except Exception as e: + logger.error(f"Unexpected error getting session {session_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + + +@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_session( + session_id: str, + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + """ + Удалить сессию. + + Args: + session_id: UUID сессии + + Returns: + 204 No Content при успехе + """ + user_id = current_user["user_id"] + + try: + await db_client.delete_session(user_id, session_id) + return None + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + logger.error(f"Failed to delete session {session_id}: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to delete session in DB API" + ) + except Exception as e: + logger.error(f"Unexpected error deleting session {session_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..7670606 --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,53 @@ +"""Authentication API endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from app.dependencies import get_db_client +from app.interfaces.db_api_client import DBApiClient +from app.models.auth import LoginResponse +from app.services.auth_service import AuthService + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +async def login( + request: Request, + login: str, + db_client: DBApiClient = Depends(get_db_client) +): + """ + Authenticate user with 8-digit login. + + Args: + request: FastAPI request object (to get client IP) + login: 8-digit login string + db_client: DB API client instance + + Returns: + LoginResponse with JWT token and user info + + Raises: + HTTPException 400: If login format is invalid + HTTPException 500: If DB API call fails + """ + # Get client IP + client_ip = request.client.host + + # Create auth service + auth_service = AuthService(db_client) + + try: + # Authenticate user + response = await auth_service.login(login, client_ip) + return response + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Authentication failed: {str(e)}" + ) diff --git a/app/api/v1/query.py b/app/api/v1/query.py new file mode 100644 index 0000000..4d0e3d8 --- /dev/null +++ b/app/api/v1/query.py @@ -0,0 +1,202 @@ +""" +Query API endpoints. + +Отправка запросов к RAG backends в двух режимах: +1. Bench mode - batch запросы +2. Backend mode - последовательные запросы +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from typing import Dict, Any +from app.models.query import BenchQueryRequest, BackendQueryRequest, QueryResponse +from app.interfaces.db_api_client import DBApiClient +from app.services.rag_service import RagService +from app.dependencies import get_db_client, get_current_user +import httpx +import logging +from datetime import datetime +import uuid + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/query", tags=["query"]) + + +def get_rag_service() -> RagService: + """Dependency для получения RAG service.""" + return RagService() + + +@router.post("/bench", response_model=QueryResponse) +async def bench_query( + request: BenchQueryRequest, + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client), + rag_service: RagService = Depends(get_rag_service) +): + """ + Отправить batch запрос к RAG backend (bench mode). + + Отправляет все вопросы одним запросом. + + Args: + request: Запрос с окружением и списком вопросов + + Returns: + QueryResponse: Ответ от RAG backend с metadata + """ + user_id = current_user["user_id"] + environment = request.environment.lower() + + # Валидация окружения + if environment not in ['ift', 'psi', 'prod']: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid environment. Must be one of: ift, psi, prod" + ) + + try: + # Получить настройки пользователя из DB API + user_settings_response = await db_client.get_user_settings(user_id) + env_settings = user_settings_response.settings.get(environment) + + if not env_settings: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Settings not found for environment: {environment}" + ) + + # Проверить что apiMode = bench + if env_settings.apiMode != 'bench': + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Environment {environment} is not configured for bench mode" + ) + + # Сгенерировать request_id + request_id = str(uuid.uuid4()) + + logger.info( + f"User {user_id} sending bench query to {environment}: " + f"{len(request.questions)} questions, request_id={request_id}" + ) + + # Отправить запрос к RAG backend + env_settings_dict = env_settings.model_dump() + response_data = await rag_service.send_bench_query( + environment=environment, + questions=request.questions, + user_settings=env_settings_dict, + request_id=request_id + ) + + # Формируем ответ + return QueryResponse( + request_id=request_id, + timestamp=datetime.utcnow().isoformat() + "Z", + environment=environment, + response=response_data + ) + + except httpx.HTTPStatusError as e: + logger.error(f"RAG backend error for bench query: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"RAG backend returned error: {e.response.status_code}" + ) + except Exception as e: + logger.error(f"Unexpected error in bench query: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + finally: + await rag_service.close() + + +@router.post("/backend", response_model=QueryResponse) +async def backend_query( + request: BackendQueryRequest, + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client), + rag_service: RagService = Depends(get_rag_service) +): + """ + Отправить запросы к RAG backend (backend mode). + + Отправляет вопросы по одному с возможностью сброса сессии. + + Args: + request: Запрос с окружением, вопросами и флагом reset_session + + Returns: + QueryResponse: Список ответов от RAG backend с metadata + """ + user_id = current_user["user_id"] + environment = request.environment.lower() + + # Валидация окружения + if environment not in ['ift', 'psi', 'prod']: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid environment. Must be one of: ift, psi, prod" + ) + + try: + # Получить настройки пользователя из DB API + user_settings_response = await db_client.get_user_settings(user_id) + env_settings = user_settings_response.settings.get(environment) + + if not env_settings: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Settings not found for environment: {environment}" + ) + + # Проверить что apiMode = backend + if env_settings.apiMode != 'backend': + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Environment {environment} is not configured for backend mode" + ) + + # Сгенерировать request_id + request_id = str(uuid.uuid4()) + + logger.info( + f"User {user_id} sending backend query to {environment}: " + f"{len(request.questions)} questions, request_id={request_id}, " + f"reset_session={request.reset_session}" + ) + + # Отправить запросы к RAG backend + env_settings_dict = env_settings.model_dump() + response_data = await rag_service.send_backend_query( + environment=environment, + questions=request.questions, + user_settings=env_settings_dict, + reset_session=request.reset_session + ) + + # Формируем ответ + return QueryResponse( + request_id=request_id, + timestamp=datetime.utcnow().isoformat() + "Z", + environment=environment, + response={"answers": response_data} # Оборачиваем в объект + ) + + except httpx.HTTPStatusError as e: + logger.error(f"RAG backend error for backend query: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"RAG backend returned error: {e.response.status_code}" + ) + except Exception as e: + logger.error(f"Unexpected error in backend query: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + finally: + await rag_service.close() diff --git a/app/api/v1/settings.py b/app/api/v1/settings.py new file mode 100644 index 0000000..e2152cb --- /dev/null +++ b/app/api/v1/settings.py @@ -0,0 +1,95 @@ +""" +Settings API endpoints. + +Управление настройками пользователя для всех трех окружений (IFT, PSI, PROD). +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from app.models.settings import UserSettings, UserSettingsUpdate +from app.interfaces.db_api_client import DBApiClient +from app.dependencies import get_db_client, get_current_user +import httpx +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("", response_model=UserSettings) +async def get_settings( + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + """ + Получить настройки пользователя для всех окружений. + + Returns: + UserSettings: Настройки пользователя для IFT, PSI, PROD + """ + user_id = current_user["user_id"] + + try: + settings = await db_client.get_user_settings(user_id) + return settings + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User settings not found" + ) + logger.error(f"Failed to get user settings: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to retrieve settings from DB API" + ) + except Exception as e: + logger.error(f"Unexpected error getting user settings: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + + +@router.put("", response_model=UserSettings) +async def update_settings( + settings_update: UserSettingsUpdate, + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + """ + Обновить настройки пользователя. + + Args: + settings_update: Новые настройки для одного или нескольких окружений + + Returns: + UserSettings: Обновленные настройки + """ + user_id = current_user["user_id"] + + try: + updated_settings = await db_client.update_user_settings(user_id, settings_update) + return updated_settings + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + elif e.response.status_code == 400: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid settings format" + ) + logger.error(f"Failed to update user settings: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to update settings in DB API" + ) + except Exception as e: + logger.error(f"Unexpected error updating user settings: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d565176 --- /dev/null +++ b/app/config.py @@ -0,0 +1,57 @@ +"""Application configuration loaded from environment variables.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from .env file.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # Application settings + APP_NAME: str = "Brief Bench API" + DEBUG: bool = False + + # JWT Authentication + JWT_SECRET_KEY: str + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRE_MINUTES: int = 43200 # 30 days + + # DB API Service (external) + DB_API_URL: str + DB_API_TIMEOUT: int = 30 + + # RAG Backend - IFT Environment + IFT_RAG_HOST: str + IFT_RAG_PORT: int + IFT_RAG_ENDPOINT: str + IFT_RAG_CERT_CA: str = "" + IFT_RAG_CERT_KEY: str = "" + IFT_RAG_CERT_CERT: str = "" + + # RAG Backend - PSI Environment + PSI_RAG_HOST: str + PSI_RAG_PORT: int + PSI_RAG_ENDPOINT: str + PSI_RAG_CERT_CA: str = "" + PSI_RAG_CERT_KEY: str = "" + PSI_RAG_CERT_CERT: str = "" + + # RAG Backend - PROD Environment + PROD_RAG_HOST: str + PROD_RAG_PORT: int + PROD_RAG_ENDPOINT: str + PROD_RAG_CERT_CA: str = "" + PROD_RAG_CERT_KEY: str = "" + PROD_RAG_CERT_CERT: str = "" + + # Request Timeouts + RAG_REQUEST_TIMEOUT: int = 1800 # 30 minutes in seconds + + +# Global settings instance +settings = Settings() diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..8d1ddc2 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,48 @@ +"""Dependency injection for FastAPI.""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from app.config import settings +from app.interfaces.db_api_client import DBApiClient +from app.utils.security import decode_access_token + +security = HTTPBearer() + + +def get_db_client() -> DBApiClient: + """ + Get DB API client instance. + + Returns: + DBApiClient configured with api_prefix from settings + """ + return DBApiClient(api_prefix=settings.DB_API_URL) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> dict: + """ + Get current user from JWT token. + + Args: + credentials: HTTP Bearer credentials from request + + Returns: + Token payload with user_id and login + + Raises: + HTTPException: If token is invalid or expired + """ + token = credentials.credentials + payload = decode_access_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return payload diff --git a/app/interfaces/__init__.py b/app/interfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/interfaces/base.py b/app/interfaces/base.py new file mode 100644 index 0000000..9e2a58b --- /dev/null +++ b/app/interfaces/base.py @@ -0,0 +1,277 @@ +""" +TgBackendInterface base class implementation. + +Базовый интерфейс для всех HTTP API клиентов с использованием httpx. +Предоставляет автоматическую сериализацию/десериализацию Pydantic моделей, +error handling и логирование. +""" + +import logging +from typing import Optional, Type, TypeVar, Any, Dict +from pydantic import BaseModel, ValidationError +import httpx + +T = TypeVar('T', bound=BaseModel) + +logger = logging.getLogger(__name__) + + +class TgBackendInterface: + """ + Базовый интерфейс для HTTP API клиентов. + + Возможности: + - httpx.AsyncClient для асинхронных HTTP запросов + - Автоматическая сериализация Pydantic моделей в JSON + - Автоматическая десериализация JSON в Pydantic модели + - Error handling с детальными логами + - Настраиваемые timeout и retry параметры + """ + + def __init__( + self, + api_prefix: str, + timeout: float = 30.0, + max_retries: int = 3, + **kwargs + ): + """ + Инициализация клиента. + + Args: + api_prefix: Базовый URL API (например, http://db-api:8080/api/v1) + timeout: Таймаут запроса в секундах (default: 30) + max_retries: Максимальное количество повторных попыток (default: 3) + **kwargs: Дополнительные параметры для httpx.AsyncClient + """ + self.api_prefix = api_prefix.rstrip('/') + + # Настройка httpx.AsyncClient + self.client = httpx.AsyncClient( + timeout=httpx.Timeout(timeout), + transport=httpx.AsyncHTTPTransport(retries=max_retries), + follow_redirects=True, + **kwargs + ) + + logger.info(f"TgBackendInterface initialized with api_prefix: {self.api_prefix}") + + async def close(self): + """Закрыть HTTP клиент.""" + await self.client.aclose() + logger.info("TgBackendInterface client closed") + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() + + def _build_url(self, path: str) -> str: + """ + Построить полный URL из api_prefix и path. + + Args: + path: Путь эндпоинта (может начинаться с / или без) + + Returns: + Полный URL + """ + if not path.startswith('/'): + path = '/' + path + return f"{self.api_prefix}{path}" + + def _serialize_body(self, body: Optional[BaseModel]) -> Optional[Dict]: + """ + Сериализовать Pydantic модель в dict для JSON. + + Args: + body: Pydantic модель + + Returns: + Dict с данными или None + """ + if body is None: + return None + return body.model_dump(mode='json', exclude_none=False) + + def _deserialize_response( + self, + data: Any, + response_model: Optional[Type[T]] + ) -> Any: + """ + Десериализовать JSON ответ в Pydantic модель. + + Args: + data: Данные из JSON ответа + response_model: Pydantic модель для валидации + + Returns: + Экземпляр Pydantic модели или исходные данные + """ + if response_model is None: + return data + + try: + return response_model(**data) if isinstance(data, dict) else response_model(data) + except ValidationError as e: + logger.error(f"Validation error for {response_model.__name__}: {e}") + raise + + async def _handle_response( + self, + response: httpx.Response, + response_model: Optional[Type[T]] = None + ) -> Any: + """ + Обработать HTTP ответ. + + Args: + response: HTTP ответ от httpx + response_model: Pydantic модель для валидации + + Returns: + Десериализованные данные + + Raises: + httpx.HTTPStatusError: При HTTP ошибках + """ + try: + response.raise_for_status() + except httpx.HTTPStatusError as e: + logger.error( + f"HTTP error {e.response.status_code} for {e.request.url}: " + f"{e.response.text}" + ) + raise + + # Если ответ пустой (204 No Content), вернуть пустой dict + if response.status_code == 204 or len(response.content) == 0: + return {} + + # Парсим JSON + try: + data = response.json() + except Exception as e: + logger.error(f"Failed to parse JSON response: {e}") + logger.debug(f"Response content: {response.text}") + raise + + # Десериализуем в Pydantic модель если нужно + return self._deserialize_response(data, response_model) + + async def get( + self, + path: str, + params: Optional[dict] = None, + response_model: Optional[Type[T]] = None, + **kwargs + ) -> Any: + """ + HTTP GET запрос к {api_prefix}{path}. + + Args: + path: Путь эндпоинта + params: Query параметры + response_model: Pydantic модель для валидации ответа + **kwargs: Дополнительные параметры для httpx + + Returns: + Десериализованный ответ (Pydantic модель или dict) + + Raises: + httpx.HTTPStatusError: При HTTP ошибках + ValidationError: При ошибках валидации Pydantic + """ + url = self._build_url(path) + logger.debug(f"GET {url} with params={params}") + + response = await self.client.get(url, params=params, **kwargs) + return await self._handle_response(response, response_model) + + async def post( + self, + path: str, + body: Optional[BaseModel] = None, + response_model: Optional[Type[T]] = None, + **kwargs + ) -> Any: + """ + HTTP POST запрос к {api_prefix}{path}. + + Args: + path: Путь эндпоинта + body: Pydantic модель для тела запроса + response_model: Pydantic модель для валидации ответа + **kwargs: Дополнительные параметры для httpx + + Returns: + Десериализованный ответ (Pydantic модель или dict) + + Raises: + httpx.HTTPStatusError: При HTTP ошибках + ValidationError: При ошибках валидации Pydantic + """ + url = self._build_url(path) + json_body = self._serialize_body(body) + logger.debug(f"POST {url} with body={json_body}") + + response = await self.client.post(url, json=json_body, **kwargs) + return await self._handle_response(response, response_model) + + async def put( + self, + path: str, + body: Optional[BaseModel] = None, + response_model: Optional[Type[T]] = None, + **kwargs + ) -> Any: + """ + HTTP PUT запрос к {api_prefix}{path}. + + Args: + path: Путь эндпоинта + body: Pydantic модель для тела запроса + response_model: Pydantic модель для валидации ответа + **kwargs: Дополнительные параметры для httpx + + Returns: + Десериализованный ответ (Pydantic модель или dict) + + Raises: + httpx.HTTPStatusError: При HTTP ошибках + ValidationError: При ошибках валидации Pydantic + """ + url = self._build_url(path) + json_body = self._serialize_body(body) + logger.debug(f"PUT {url} with body={json_body}") + + response = await self.client.put(url, json=json_body, **kwargs) + return await self._handle_response(response, response_model) + + async def delete( + self, + path: str, + **kwargs + ) -> Any: + """ + HTTP DELETE запрос к {api_prefix}{path}. + + Args: + path: Путь эндпоинта + **kwargs: Дополнительные параметры для httpx + + Returns: + Ответ от сервера (обычно пустой dict для 204) + + Raises: + httpx.HTTPStatusError: При HTTP ошибках + """ + url = self._build_url(path) + logger.debug(f"DELETE {url}") + + response = await self.client.delete(url, **kwargs) + return await self._handle_response(response) diff --git a/app/interfaces/db_api_client.py b/app/interfaces/db_api_client.py new file mode 100644 index 0000000..890e736 --- /dev/null +++ b/app/interfaces/db_api_client.py @@ -0,0 +1,103 @@ +"""DB API client using TgBackendInterface.""" + +from app.interfaces.base import TgBackendInterface +from app.models.auth import LoginRequest, UserResponse +from app.models.settings import UserSettings, UserSettingsUpdate +from app.models.analysis import SessionCreate, SessionResponse, SessionList + + +class DBApiClient(TgBackendInterface): + """ + Клиент для DB API сервиса. + + Использует Pydantic схемы для type-safety. + Методы self.get(), self.post(), self.put(), self.delete() от TgBackendInterface. + """ + + async def login_user(self, request: LoginRequest) -> UserResponse: + """ + POST {api_prefix}/users/login + + Авторизация пользователя и запись логина. + """ + return await self.post("/users/login", body=request, response_model=UserResponse) + + async def get_user_settings(self, user_id: str) -> UserSettings: + """ + GET {api_prefix}/users/{user_id}/settings + + Получить настройки пользователя для всех окружений. + """ + return await self.get(f"/users/{user_id}/settings", response_model=UserSettings) + + async def update_user_settings( + self, + user_id: str, + settings: UserSettingsUpdate + ) -> UserSettings: + """ + PUT {api_prefix}/users/{user_id}/settings + + Обновить настройки пользователя. + """ + return await self.put( + f"/users/{user_id}/settings", + body=settings, + response_model=UserSettings + ) + + async def save_session( + self, + user_id: str, + session_data: SessionCreate + ) -> SessionResponse: + """ + POST {api_prefix}/users/{user_id}/sessions + + Сохранить сессию анализа. + """ + return await self.post( + f"/users/{user_id}/sessions", + body=session_data, + response_model=SessionResponse + ) + + async def get_sessions( + self, + user_id: str, + environment: str = None, + limit: int = 50, + offset: int = 0 + ) -> SessionList: + """ + GET {api_prefix}/users/{user_id}/sessions + + Получить список сессий пользователя. + """ + params = {"limit": limit, "offset": offset} + if environment: + params["environment"] = environment + return await self.get( + f"/users/{user_id}/sessions", + params=params, + response_model=SessionList + ) + + async def get_session(self, user_id: str, session_id: str) -> SessionResponse: + """ + GET {api_prefix}/users/{user_id}/sessions/{session_id} + + Получить конкретную сессию. + """ + return await self.get( + f"/users/{user_id}/sessions/{session_id}", + response_model=SessionResponse + ) + + async def delete_session(self, user_id: str, session_id: str) -> dict: + """ + DELETE {api_prefix}/users/{user_id}/sessions/{session_id} + + Удалить сессию. + """ + return await self.delete(f"/users/{user_id}/sessions/{session_id}") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4f1adda --- /dev/null +++ b/app/main.py @@ -0,0 +1,50 @@ +"""FastAPI application entry point.""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.api.v1 import auth, settings as settings_router, query, analysis +from app.config import settings + +# Create FastAPI app +app = FastAPI( + title=settings.APP_NAME, + debug=settings.DEBUG, + version="1.0.0" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # TODO: Configure properly in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API v1 routes +app.include_router(auth.router, prefix="/api/v1") +app.include_router(settings_router.router, prefix="/api/v1") +app.include_router(query.router, prefix="/api/v1") +app.include_router(analysis.router, prefix="/api/v1") + +# Serve static files (frontend) +# app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/") +async def root(): + """Root endpoint.""" + return {"message": "Brief Bench API", "version": "1.0.0"} + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/analysis.py b/app/models/analysis.py new file mode 100644 index 0000000..8ef06e0 --- /dev/null +++ b/app/models/analysis.py @@ -0,0 +1,43 @@ +"""Analysis session Pydantic models.""" + +from typing import Any +from pydantic import BaseModel + + +class SessionCreate(BaseModel): + """Create new analysis session.""" + + environment: str + api_mode: str + request: list[Any] + response: dict + annotations: dict + + +class SessionResponse(BaseModel): + """Analysis session response.""" + + session_id: str + user_id: str + environment: str + api_mode: str + request: list[Any] + response: dict + annotations: dict + created_at: str + updated_at: str + + +class SessionListItem(BaseModel): + """Session list item for listing sessions.""" + + session_id: str + environment: str + created_at: str + + +class SessionList(BaseModel): + """List of sessions with total count.""" + + sessions: list[SessionListItem] + total: int diff --git a/app/models/auth.py b/app/models/auth.py new file mode 100644 index 0000000..d24f851 --- /dev/null +++ b/app/models/auth.py @@ -0,0 +1,35 @@ +"""Authentication Pydantic models.""" + +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + """Login request model.""" + + login: str = Field(..., pattern=r'^\d{8}$', description="8-значный логин") + client_ip: str = Field(..., description="IP адрес клиента") + + +class UserResponse(BaseModel): + """User response model.""" + + user_id: str + login: str + last_login_at: str + created_at: str + + +class LoginResponse(BaseModel): + """Login response with JWT token.""" + + access_token: str + token_type: str = "bearer" + user: UserResponse + + +class TokenPayload(BaseModel): + """JWT token payload.""" + + user_id: str + login: str + exp: int # Expiration timestamp diff --git a/app/models/query.py b/app/models/query.py new file mode 100644 index 0000000..d771cba --- /dev/null +++ b/app/models/query.py @@ -0,0 +1,35 @@ +"""Query request/response Pydantic models.""" + +from typing import Any +from pydantic import BaseModel + + +class QuestionRequest(BaseModel): + """Single question for batch query.""" + + body: str + with_docs: bool = True + + +class BenchQueryRequest(BaseModel): + """Bench mode query request.""" + + environment: str # ift, psi, prod + questions: list[QuestionRequest] + + +class BackendQueryRequest(BaseModel): + """Backend mode query request (one-by-one).""" + + environment: str # ift, psi, prod + questions: list[str] + reset_session: bool = True + + +class QueryResponse(BaseModel): + """Query response with metadata.""" + + request_id: str + timestamp: str + environment: str + response: dict # RagResponseBenchList or converted format diff --git a/app/models/settings.py b/app/models/settings.py new file mode 100644 index 0000000..103c8b6 --- /dev/null +++ b/app/models/settings.py @@ -0,0 +1,30 @@ +"""User settings Pydantic models.""" + +from pydantic import BaseModel + + +class EnvironmentSettings(BaseModel): + """Settings for a specific environment (IFT/PSI/PROD).""" + + apiMode: str = "bench" + bearerToken: str = "" + systemPlatform: str = "" + systemPlatformUser: str = "" + platformUserId: str = "" + platformId: str = "" + withClassify: bool = False + resetSessionMode: bool = True + + +class UserSettings(BaseModel): + """User settings for all environments.""" + + user_id: str + settings: dict[str, EnvironmentSettings] # ift, psi, prod + updated_at: str + + +class UserSettingsUpdate(BaseModel): + """Update user settings request.""" + + settings: dict[str, EnvironmentSettings] diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..70790b0 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,54 @@ +"""Authentication service.""" + +from app.interfaces.db_api_client import DBApiClient +from app.models.auth import LoginRequest, LoginResponse, UserResponse +from app.utils.security import create_access_token + + +class AuthService: + """Service for user authentication.""" + + def __init__(self, db_client: DBApiClient): + """ + Initialize auth service. + + Args: + db_client: DB API client instance + """ + self.db_client = db_client + + async def login(self, login: str, client_ip: str) -> LoginResponse: + """ + Authenticate user and generate JWT token. + + Args: + login: 8-digit login + client_ip: Client IP address + + Returns: + LoginResponse with JWT token and user info + + Raises: + ValueError: If login format is invalid + Exception: If DB API call fails + """ + # Validate login format + if not (login.isdigit() and len(login) == 8): + raise ValueError("Login must be 8 digits") + + # Call DB API to validate and record login + request = LoginRequest(login=login, client_ip=client_ip) + user: UserResponse = await self.db_client.login_user(request) + + # Generate JWT token + token_data = { + "user_id": user.user_id, + "login": user.login + } + access_token = create_access_token(token_data) + + return LoginResponse( + access_token=access_token, + token_type="bearer", + user=user + ) diff --git a/app/services/rag_service.py b/app/services/rag_service.py new file mode 100644 index 0000000..4cdbe89 --- /dev/null +++ b/app/services/rag_service.py @@ -0,0 +1,316 @@ +""" +RAG Service for interacting with RAG backends. + +Поддерживает два режима: +1. Bench mode - batch запросы (все вопросы сразу) +2. Backend mode - вопросы по одному с возможностью сброса сессии +""" + +import logging +import httpx +import uuid +from typing import List, Dict, Optional, Any +from datetime import datetime +from app.config import settings +from app.models.query import QuestionRequest + +logger = logging.getLogger(__name__) + + +class RagService: + """ + Сервис для взаимодействия с RAG backend для трех окружений (IFT, PSI, PROD). + + Поддерживает mTLS, настраиваемые headers и два режима работы: + - bench: batch запросы + - backend: последовательные запросы с reset session + """ + + def __init__(self): + """Инициализация клиентов для всех трех окружений.""" + self.clients = { + 'ift': self._create_client('ift'), + 'psi': self._create_client('psi'), + 'prod': self._create_client('prod') + } + logger.info("RagService initialized for all environments") + + def _create_client(self, environment: str) -> httpx.AsyncClient: + """ + Создать HTTP клиент для указанного окружения с mTLS поддержкой. + + Args: + environment: Окружение (ift/psi/prod) + + Returns: + Настроенный httpx.AsyncClient + """ + env_upper = environment.upper() + + # Получить пути к сертификатам из config + cert_cert_path = getattr(settings, f"{env_upper}_RAG_CERT_CERT", "") + cert_key_path = getattr(settings, f"{env_upper}_RAG_CERT_KEY", "") + cert_ca_path = getattr(settings, f"{env_upper}_RAG_CERT_CA", "") + + # Настройка mTLS + cert = None + verify = True + + if cert_cert_path and cert_key_path: + cert = (cert_cert_path, cert_key_path) + logger.info(f"mTLS enabled for {environment} with cert: {cert_cert_path}") + + if cert_ca_path: + verify = cert_ca_path + logger.info(f"Custom CA for {environment}: {cert_ca_path}") + + return httpx.AsyncClient( + timeout=httpx.Timeout(1800.0), # 30 minutes for long-running requests + cert=cert, + verify=verify, + follow_redirects=True + ) + + async def close(self): + """Закрыть все HTTP клиенты.""" + for env, client in self.clients.items(): + await client.aclose() + logger.info(f"Client closed for {env}") + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() + + def _get_base_url(self, environment: str) -> str: + """ + Получить базовый URL для окружения. + + Args: + environment: Окружение (ift/psi/prod) + + Returns: + Базовый URL (https://host:port) + """ + env_upper = environment.upper() + host = getattr(settings, f"{env_upper}_RAG_HOST") + port = getattr(settings, f"{env_upper}_RAG_PORT") + return f"https://{host}:{port}" + + def _get_bench_endpoint(self, environment: str) -> str: + """ + Получить endpoint для bench mode. + + Args: + environment: Окружение (ift/psi/prod) + + Returns: + Полный URL для bench запросов + """ + env_upper = environment.upper() + base_url = self._get_base_url(environment) + endpoint = getattr(settings, f"{env_upper}_RAG_ENDPOINT") + return f"{base_url}/{endpoint.lstrip('/')}" + + def _get_backend_endpoints(self, environment: str, user_settings: Dict) -> Dict[str, str]: + """ + Получить endpoints для backend mode (ask + reset). + + Args: + environment: Окружение (ift/psi/prod) + user_settings: Настройки пользователя для окружения + + Returns: + Dict с ask_endpoint и reset_endpoint + """ + base_url = self._get_base_url(environment) + + # Endpoints из настроек пользователя или дефолтные + ask_endpoint = user_settings.get('backendAskEndpoint', 'ask') + reset_endpoint = user_settings.get('backendResetEndpoint', 'reset') + + return { + 'ask': f"{base_url}/{ask_endpoint.lstrip('/')}", + 'reset': f"{base_url}/{reset_endpoint.lstrip('/')}" + } + + def _build_bench_headers( + self, + environment: str, + user_settings: Dict, + request_id: Optional[str] = None + ) -> Dict[str, str]: + """ + Построить headers для bench mode запроса. + + Args: + environment: Окружение (ift/psi/prod) + user_settings: Настройки пользователя + request_id: Request ID (генерируется если не задан) + + Returns: + Dict с headers + """ + headers = { + "Content-Type": "application/json", + "Request-Id": request_id or str(uuid.uuid4()), + "System-Id": f"brief-bench-{environment}" + } + + # Добавить опциональные headers из настроек пользователя + if user_settings.get('bearerToken'): + headers["Authorization"] = f"Bearer {user_settings['bearerToken']}" + + if user_settings.get('systemPlatform'): + headers["System-Platform"] = user_settings['systemPlatform'] + + return headers + + def _build_backend_headers(self, user_settings: Dict) -> Dict[str, str]: + """ + Построить headers для backend mode запроса. + + Args: + user_settings: Настройки пользователя + + Returns: + Dict с headers + """ + headers = { + "Content-Type": "application/json" + } + + if user_settings.get('bearerToken'): + headers["Authorization"] = f"Bearer {user_settings['bearerToken']}" + + if user_settings.get('platformUserId'): + headers["Platform-User-Id"] = user_settings['platformUserId'] + + if user_settings.get('platformId'): + headers["Platform-Id"] = user_settings['platformId'] + + return headers + + async def send_bench_query( + self, + environment: str, + questions: List[QuestionRequest], + user_settings: Dict, + request_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Отправить batch запрос к RAG backend (bench mode). + + Args: + environment: Окружение (ift/psi/prod) + questions: Список вопросов + user_settings: Настройки пользователя для окружения + request_id: Request ID (опционально) + + Returns: + Dict с ответом от RAG backend + + Raises: + httpx.HTTPStatusError: При HTTP ошибках + """ + client = self.clients[environment.lower()] + url = self._get_bench_endpoint(environment) + headers = self._build_bench_headers(environment, user_settings, request_id) + + # Сериализуем вопросы в JSON + body = [q.model_dump() for q in questions] + + logger.info(f"Sending bench query to {environment}: {len(questions)} questions") + logger.debug(f"URL: {url}, Headers: {headers}") + + try: + response = await client.post(url, json=body, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + logger.error(f"Bench query failed for {environment}: {e.response.status_code} - {e.response.text}") + raise + except Exception as e: + logger.error(f"Unexpected error in bench query for {environment}: {e}") + raise + + async def send_backend_query( + self, + environment: str, + questions: List[QuestionRequest], + user_settings: Dict, + reset_session: bool = True + ) -> List[Dict[str, Any]]: + """ + Отправить вопросы по одному к RAG backend (backend mode). + + После каждого вопроса может сбросить сессию (если resetSessionMode=true). + + Args: + environment: Окружение (ift/psi/prod) + questions: Список вопросов + user_settings: Настройки пользователя для окружения + reset_session: Сбрасывать ли сессию после каждого вопроса + + Returns: + List[Dict] с ответами от RAG backend + + Raises: + httpx.HTTPStatusError: При HTTP ошибках + """ + client = self.clients[environment.lower()] + endpoints = self._get_backend_endpoints(environment, user_settings) + headers = self._build_backend_headers(user_settings) + + logger.info( + f"Sending backend query to {environment}: {len(questions)} questions " + f"(reset_session={reset_session})" + ) + + responses = [] + + for idx, question in enumerate(questions, start=1): + # Формируем body для запроса + now = datetime.utcnow().isoformat() + "Z" + body = { + "question": question.body, + "user_message_id": idx, + "user_message_datetime": now, + "with_classify": user_settings.get('withClassify', False) + } + + logger.debug(f"Sending question {idx}/{len(questions)}: {question.body[:50]}...") + + try: + # Отправляем вопрос + response = await client.post(endpoints['ask'], json=body, headers=headers) + response.raise_for_status() + response_data = response.json() + responses.append(response_data) + + # Сбрасываем сессию если нужно + if reset_session and user_settings.get('resetSessionMode', True): + reset_body = {"user_message_datetime": now} + logger.debug(f"Resetting session after question {idx}") + reset_response = await client.post( + endpoints['reset'], + json=reset_body, + headers=headers + ) + reset_response.raise_for_status() + + except httpx.HTTPStatusError as e: + logger.error( + f"Backend query failed for {environment} (question {idx}): " + f"{e.response.status_code} - {e.response.text}" + ) + raise + except Exception as e: + logger.error(f"Unexpected error in backend query for {environment} (question {idx}): {e}") + raise + + logger.info(f"Backend query completed for {environment}: {len(responses)} responses") + return responses diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000..a37c1bf --- /dev/null +++ b/app/utils/security.py @@ -0,0 +1,58 @@ +"""JWT token encoding/decoding utilities.""" + +from datetime import datetime, timedelta +from typing import Optional + +from jose import JWTError, jwt + +from app.config import settings + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """ + Create JWT access token. + + Args: + data: Data to encode in token (user_id, login, etc.) + expires_delta: Token expiration time (default: from settings) + + Returns: + Encoded JWT token + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) + + to_encode.update({"exp": int(expire.timestamp())}) + + encoded_jwt = jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + + return encoded_jwt + + +def decode_access_token(token: str) -> Optional[dict]: + """ + Decode and validate JWT token. + + Args: + token: JWT token to decode + + Returns: + Decoded token payload or None if invalid + """ + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM] + ) + return payload + except JWTError: + return None diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23aa586 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + fastapi: + build: . + container_name: brief-bench-fastapi + ports: + - "8000:8000" + env_file: + - .env + volumes: + - ./certs:/app/certs:ro # mTLS сертификаты (read-only) + - ./static:/app/static # Frontend files + restart: unless-stopped + networks: + - brief-bench-network + +networks: + brief-bench-network: + driver: bridge diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..88b8b57 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +# FastAPI & web server +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 + +# HTTP клиенты +httpx==0.25.2 + +# Pydantic для валидации +pydantic==2.5.0 +pydantic-settings==2.1.0 + +# JWT & Security +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 + +# Async +anyio==4.1.0 + +# Environment variables +python-dotenv==1.0.0 + +# CORS +fastapi-cors==0.0.6