diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 60630f4..e683e06 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,14 @@ "Bash(.venv/Scripts/python.exe -m pytest tests/ -v --tb=line)", "Bash(.venv/Scripts/python.exe -m pytest:*)", "Bash(.venvScriptspython.exe -m pytest tests/unit/ -v --cov=app --cov-report=term-missing)", - "Bash(.\\.venv\\Scripts\\python.exe -m pytest:*)" + "Bash(.\\.venv\\Scripts\\python.exe -m pytest:*)", + "Bash(.\\\\.venv\\\\Scripts\\\\python.exe -m pytest:*)", + "Bash(.venvScriptspython.exe -m pytest tests/unit/test_query.py::TestBenchQueryEndpoint::test_bench_query_success -v)", + "Bash(.venvScriptspython.exe -m pytest tests/unit/ -v --tb=short)", + "Bash(.\\\\\\\\.venv\\\\\\\\Scripts\\\\\\\\python.exe -m pytest:*)", + "Bash(..venvScriptsactivate)", + "Bash(pytest:*)", + "Bash(source:*)" ], "deny": [], "ask": [] diff --git a/DB_API_CONTRACT_V2.md b/DB_API_CONTRACT_V2.md new file mode 100644 index 0000000..9042aed --- /dev/null +++ b/DB_API_CONTRACT_V2.md @@ -0,0 +1,710 @@ +```markdown +# SBS Bench RAG API Contract + +API для управления пользователями, настройками окружений и сессиями анализа в системе Brief Bench. + +## Содержание + +- [Общая информация](#общая-информация) +- [Аутентификация и пользователи](#аутентификация-и-пользователи) +- [Настройки пользователя](#настройки-пользователя) +- [Сессии анализа](#сессии-анализа) +- [Форматы данных](#форматы-данных) +- [Обработка ошибок](#обработка-ошибок) + +--- + +## Общая информация + +### Base URL + +``` +/api/v1 +``` + +### Форматы данных + +- **Content-Type**: `application/json; charset=utf-8` +- **Даты**: ISO 8601 с таймзоной UTC (`YYYY-MM-DDTHH:MM:SSZ`) +- **UUID**: строка в формате `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` + +### Окружения + +API поддерживает три окружения (enum `environment`): +- `ift` — IFT окружение +- `psi` — PSI окружение +- `prod` — Production окружение + +### Режимы API + +Два режима работы (enum `api_mode`): +- `bench` — режим тестирования +- `backend` — backend режим + +--- + +## Аутентификация и пользователи + +### POST /users/login + +Авторизация пользователя и запись информации о логине. + +#### Request + +``` +{ + "login": "12345678", + "client_ip": "MTkyLjE2OC4xLjEwMA==" +} +``` + +**Параметры:** +- `login` (string, required): 8-значный логин (строка из цифр) +- `client_ip` (string, required): IP-адрес в кодировке base64 + +#### Response 200 + +``` +{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "login": "12345678", + "last_login_at": "2025-12-24T12:00:00Z", + "created_at": "2025-12-01T10:00:00Z" +} +``` + +#### Errors + +- **400 Bad Request**: Неверный формат логина или client_ip +- **500 Internal Server Error**: Ошибка сервера + +--- + +## Настройки пользователя + +### GET /users/{user_id}/settings + +Получить настройки пользователя для всех окружений. + +#### Path Parameters + +- `user_id` (UUID, required): UUID пользователя + +#### Response 200 + +``` +{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "settings": { + "ift": { + "apiMode": "bench", + "bearerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "systemPlatform": "platform-ift", + "systemPlatformUser": "user-123", + "platformUserId": "p-user-456", + "platformId": "platform-789", + "withClassify": false, + "resetSessionMode": true + }, + "psi": { + "apiMode": "bench", + "bearerToken": null, + "systemPlatform": null, + "systemPlatformUser": null, + "platformUserId": null, + "platformId": null, + "withClassify": false, + "resetSessionMode": true + }, + "prod": { + "apiMode": "backend", + "bearerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "systemPlatform": "platform-prod", + "systemPlatformUser": "user-prod", + "platformUserId": "p-user-prod", + "platformId": "platform-prod-id", + "withClassify": true, + "resetSessionMode": false + } + }, + "updated_at": "2025-12-24T12:30:00Z" +} +``` + +#### Errors + +- **404 Not Found**: Пользователь не найден +- **500 Internal Server Error**: Ошибка сервера + +--- + +### PATCH /users/{user_id}/settings + +Частично обновить настройки пользователя. Обновляются только переданные поля. + +#### Path Parameters + +- `user_id` (UUID, required): UUID пользователя + +#### Request + +``` +{ + "settings": { + "ift": { + "bearerToken": "new-token-value", + "withClassify": true + }, + "prod": { + "apiMode": "bench" + } + } +} +``` + +**Примечания:** +- Передавайте только те окружения и поля, которые нужно изменить +- Непереданные поля остаются без изменений +- Для сброса значения в `null` явно передайте `null` + +#### Response 200 + +``` +{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "settings": { + "ift": { + "apiMode": "bench", + "bearerToken": "new-token-value", + "systemPlatform": "platform-ift", + "systemPlatformUser": "user-123", + "platformUserId": "p-user-456", + "platformId": "platform-789", + "withClassify": true, + "resetSessionMode": true + }, + "psi": { + "apiMode": "bench", + "bearerToken": null, + "systemPlatform": null, + "systemPlatformUser": null, + "platformUserId": null, + "platformId": null, + "withClassify": false, + "resetSessionMode": true + }, + "prod": { + "apiMode": "bench", + "bearerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "systemPlatform": "platform-prod", + "systemPlatformUser": "user-prod", + "platformUserId": "p-user-prod", + "platformId": "platform-prod-id", + "withClassify": true, + "resetSessionMode": false + } + }, + "updated_at": "2025-12-24T13:00:00Z" +} +``` + +#### Errors + +- **400 Bad Request**: Неверный формат настроек +- **404 Not Found**: Пользователь не найден +- **500 Internal Server Error**: Ошибка сервера + +--- + +## Сессии анализа + +### POST /users/{user_id}/sessions + +Создать новую сессию анализа. + +#### Path Parameters + +- `user_id` (UUID, required): UUID пользователя + +#### Request + +``` +{ + "environment": "ift", + "api_mode": "bench", + "request": [ + { + "body": "Как получить кредит на недвижимость?", + "with_docs": true + }, + { + "body": "Какие документы нужны?", + "with_docs": true + } + ], + "response": { + "answers": [ + { + "question": "Как получить кредит на недвижимость?", + "answer": "Для получения кредита...", + "sources": ["doc1.pdf", "doc2.pdf"] + } + ], + "metadata": { + "processing_time_ms": 1250 + } + }, + "annotations": { + "0": { + "overall": { + "rating": "correct", + "comment": "Ответ корректный и полный" + }, + "body_research": { + "issues": [], + "comment": "" + } + } + } +} +``` + +**Параметры:** +- `environment` (string, required): Окружение (`ift`, `psi`, `prod`) +- `api_mode` (string, required): Режим API (`bench`, `backend`) +- `request` (array, required): Массив запросов + - `body` (string, required): Текст вопроса + - `with_docs` (boolean, optional): Использовать документы (default: `true`) +- `response` (object, required): Ответ системы (произвольная структура) +- `annotations` (object, optional): Аннотации по индексам вопросов + - Ключи должны быть числовыми строками (`"0"`, `"1"`, ...) + +#### Response 201 + +``` +{ + "session_id": "660e8400-e29b-41d4-a716-446655440000", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "environment": "ift", + "api_mode": "bench", + "request": [ + { + "body": "Как получить кредит на недвижимость?", + "with_docs": true + }, + { + "body": "Какие документы нужны?", + "with_docs": true + } + ], + "response": { + "answers": [ + { + "question": "Как получить кредит на недвижимость?", + "answer": "Для получения кредита...", + "sources": ["doc1.pdf", "doc2.pdf"] + } + ], + "metadata": { + "processing_time_ms": 1250 + } + }, + "annotations": { + "0": { + "overall": { + "rating": "correct", + "comment": "Ответ корректный и полный" + }, + "body_research": { + "issues": [], + "comment": "" + } + } + }, + "created_at": "2025-12-24T14:00:00Z", + "updated_at": "2025-12-24T14:00:00Z" +} +``` + +#### Errors + +- **400 Bad Request**: Неверный формат данных +- **404 Not Found**: Пользователь не найден +- **500 Internal Server Error**: Ошибка сервера + +--- + +### GET /users/{user_id}/sessions + +Получить список сессий пользователя с пагинацией. + +#### Path Parameters + +- `user_id` (UUID, required): UUID пользователя + +#### Query Parameters + +- `environment` (string, optional): Фильтр по окружению (`ift`, `psi`, `prod`) +- `limit` (integer, optional): Лимит результатов (1-200, default: 50) +- `offset` (integer, optional): Смещение для пагинации (default: 0) + +#### Response 200 + +``` +{ + "sessions": [ + { + "session_id": "660e8400-e29b-41d4-a716-446655440000", + "environment": "ift", + "created_at": "2025-12-24T14:00:00Z" + }, + { + "session_id": "770e8400-e29b-41d4-a716-446655440000", + "environment": "ift", + "created_at": "2025-12-23T10:30:00Z" + } + ], + "total": 123 +} +``` + +**Сортировка:** По дате создания (от новых к старым) + +#### Errors + +- **404 Not Found**: Пользователь не найден +- **500 Internal Server Error**: Ошибка сервера + +--- + +### GET /users/{user_id}/sessions/{session_id} + +Получить детальную информацию о сессии. + +#### Path Parameters + +- `user_id` (UUID, required): UUID пользователя +- `session_id` (UUID, required): UUID сессии + +#### Response 200 + +``` +{ + "session_id": "660e8400-e29b-41d4-a716-446655440000", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "environment": "ift", + "api_mode": "bench", + "request": [ + { + "body": "Как получить кредит на недвижимость?", + "with_docs": true + } + ], + "response": { + "answers": [ + { + "question": "Как получить кредит на недвижимость?", + "answer": "Для получения кредита...", + "sources": ["doc1.pdf"] + } + ] + }, + "annotations": { + "0": { + "overall": { + "rating": "correct", + "comment": "Ответ корректный" + } + } + }, + "created_at": "2025-12-24T14:00:00Z", + "updated_at": "2025-12-24T14:00:00Z" +} +``` + +#### Errors + +- **404 Not Found**: Сессия или пользователь не найдены +- **500 Internal Server Error**: Ошибка сервера + +--- + +### PATCH /users/{user_id}/sessions/{session_id} + +Обновить аннотации сессии (например, после ревью). + +#### Path Parameters + +- `user_id` (UUID, required): UUID пользователя +- `session_id` (UUID, required): UUID сессии + +#### Request + +``` +{ + "annotations": { + "0": { + "overall": { + "rating": "incorrect", + "comment": "Ответ неполный, не хватает информации о процентах" + }, + "body_research": { + "issues": ["missing_info"], + "comment": "Не указаны процентные ставки" + } + }, + "1": { + "overall": { + "rating": "correct", + "comment": "Ответ корректный" + } + } + } +} +``` + +**Примечания:** +- Ключи `annotations` должны быть числовыми строками (`"0"`, `"1"`, ...) +- Полностью заменяет существующие аннотации + +#### Response 200 + +``` +{ + "session_id": "660e8400-e29b-41d4-a716-446655440000", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "environment": "ift", + "api_mode": "bench", + "request": [...], + "response": {...}, + "annotations": { + "0": { + "overall": { + "rating": "incorrect", + "comment": "Ответ неполный, не хватает информации о процентах" + }, + "body_research": { + "issues": ["missing_info"], + "comment": "Не указаны процентные ставки" + } + }, + "1": { + "overall": { + "rating": "correct", + "comment": "Ответ корректный" + } + } + }, + "created_at": "2025-12-24T14:00:00Z", + "updated_at": "2025-12-24T14:30:00Z" +} +``` + +#### Errors + +- **400 Bad Request**: Неверный формат данных +- **404 Not Found**: Сессия или пользователь не найдены +- **500 Internal Server Error**: Ошибка сервера + +--- + +### DELETE /users/{user_id}/sessions/{session_id} + +Удалить сессию. + +#### Path Parameters + +- `user_id` (UUID, required): UUID пользователя +- `session_id` (UUID, required): UUID сессии + +#### Response 204 + +Нет тела ответа при успешном удалении. + +#### Errors + +- **404 Not Found**: Сессия или пользователь не найдены +- **500 Internal Server Error**: Ошибка сервера + +--- + +## Форматы данных + +### Environment Settings Object + +``` +{ + "apiMode": "bench", + "bearerToken": "token-value", + "systemPlatform": "platform-name", + "systemPlatformUser": "user-name", + "platformUserId": "user-id", + "platformId": "platform-id", + "withClassify": false, + "resetSessionMode": true +} +``` + +**Поля:** +- `apiMode` (string): `bench` или `backend` +- `bearerToken` (string, nullable): Bearer токен для API +- `systemPlatform` (string, nullable): Название платформы +- `systemPlatformUser` (string, nullable): Пользователь платформы +- `platformUserId` (string, nullable): ID пользователя в платформе +- `platformId` (string, nullable): ID платформы +- `withClassify` (boolean): Использовать классификацию +- `resetSessionMode` (boolean): Режим сброса сессии + +**Default значения:** +- `apiMode`: `"bench"` +- `withClassify`: `false` +- `resetSessionMode`: `true` +- Все остальные: `null` + +--- + +## Обработка ошибок + +Все ошибки возвращаются в едином формате: + +``` +{ + "detail": "Описание ошибки", + "error_code": "OPTIONAL_ERROR_CODE" +} +``` + +### Коды ошибок + +| HTTP Code | Описание | Когда возникает | +|-----------|----------|-----------------| +| 400 | Bad Request | Неверный формат данных в запросе | +| 404 | Not Found | Пользователь или сессия не найдены | +| 422 | Unprocessable Entity | Ошибка валидации Pydantic | +| 500 | Internal Server Error | Внутренняя ошибка сервера | + +### Примеры ошибок + +**400 - Неверный формат логина:** +``` +{ + "detail": "login: String should match pattern '^\\d{8}$'", + "error_code": null +} +``` + +**404 - Пользователь не найден:** +``` +{ + "detail": "User 550e8400-e29b-41d4-a716-446655440000 not found", + "error_code": null +} +``` + +**422 - Ошибка валидации annotations:** +``` +{ + "detail": [ + { + "loc": ["body", "annotations"], + "msg": "annotations keys must be numeric strings (e.g. '0', '1')", + "type": "value_error" + } + ] +} +``` + +--- + +## Примеры использования + +### Сценарий 1: Создание пользователя и сохранение настроек + +``` +# 1. Логин пользователя +curl -X POST /api/v1/users/login \ + -H "Content-Type: application/json" \ + -d '{ + "login": "12345678", + "client_ip": "MTkyLjE2OC4xLjEwMA==" + }' + +# Response: {"user_id": "550e8400-...", ...} + +# 2. Настройка окружения IFT +curl -X PATCH /api/v1/users/550e8400-e29b-41d4-a716-446655440000/settings \ + -H "Content-Type: application/json" \ + -d '{ + "settings": { + "ift": { + "bearerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "systemPlatform": "platform-ift", + "withClassify": true + } + } + }' +``` + +### Сценарий 2: Создание и аннотация сессии + +``` +# 1. Создание сессии анализа +curl -X POST /api/v1/users/550e8400-e29b-41d4-a716-446655440000/sessions \ + -H "Content-Type: application/json" \ + -d '{ + "environment": "ift", + "api_mode": "bench", + "request": [ + { + "body": "Как получить кредит?", + "with_docs": true + } + ], + "response": { + "answers": [{ + "question": "Как получить кредит?", + "answer": "Для получения кредита..." + }] + } + }' + +# Response: {"session_id": "660e8400-...", ...} + +# 2. Добавление аннотаций после ревью +curl -X PATCH /api/v1/users/550e8400-e29b-41d4-a716-446655440000/sessions/660e8400-e29b-41d4-a716-446655440000 \ + -H "Content-Type: application/json" \ + -d '{ + "annotations": { + "0": { + "overall": { + "rating": "correct", + "comment": "Ответ полный и корректный" + } + } + } + }' +``` + +### Сценарий 3: Получение истории сессий + +``` +# Список всех сессий пользователя +curl -X GET /api/v1/users/550e8400-e29b-41d4-a716-446655440000/sessions?limit=10&offset=0 + +# Фильтр только по IFT окружению +curl -X GET /api/v1/users/550e8400-e29b-41d4-a716-446655440000/sessions?environment=ift&limit=20 + +# Получение конкретной сессии +curl -X GET /api/v1/users/550e8400-e29b-41d4-a716-446655440000/sessions/660e8400-e29b-41d4-a716-446655440000 +``` + + +### Особенности реализации + +1. **UUID генерация**: UUID создаются на уровне приложения (Python `uuid.uuid4()`) +2. **Timestamps**: Автоматическое проставление `load_dttm` при INSERT, `updated_dttm` при UPDATE через триггеры +3. **JSONB**: Все JSON-данные хранятся в PostgreSQL JSONB для эффективного поиска +4. **Индексы**: Составные индексы на `(user_id, environment, load_dttm)` для быстрой пагинации +5. **Безопасность**: Base64-кодирование client_ip для защиты от SQL-инъекций + +--- \ No newline at end of file diff --git a/app/api/v1/analysis.py b/app/api/v1/analysis.py index e5704ec..6c7bd57 100644 --- a/app/api/v1/analysis.py +++ b/app/api/v1/analysis.py @@ -6,7 +6,7 @@ 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.models.analysis import SessionCreate, SessionResponse, SessionList, SessionUpdate from app.interfaces.db_api_client import DBApiClient from app.dependencies import get_db_client, get_current_user import httpx @@ -143,6 +143,54 @@ async def get_session( ) +@router.patch("/sessions/{session_id}", response_model=SessionResponse) +async def update_session( + session_id: str, + update_data: SessionUpdate, + current_user: dict = Depends(get_current_user), + db_client: DBApiClient = Depends(get_db_client) +): + """ + Обновить аннотации сессии (например, после ревью). + + Полностью заменяет существующие аннотации новыми. + + Args: + session_id: UUID сессии + update_data: Новые аннотации с ключами в виде числовых строк ('0', '1', ...) + + Returns: + SessionResponse: Обновленная сессия + """ + user_id = current_user["user_id"] + + try: + updated_session = await db_client.update_session(user_id, session_id, update_data) + return updated_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" + ) + elif e.response.status_code == 400: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid annotations format" + ) + logger.error(f"Failed to update session {session_id}: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to update session in DB API" + ) + except Exception as e: + logger.error(f"Unexpected error updating 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, diff --git a/app/api/v1/settings.py b/app/api/v1/settings.py index e2152cb..8a7ab96 100644 --- a/app/api/v1/settings.py +++ b/app/api/v1/settings.py @@ -51,20 +51,22 @@ async def get_settings( ) -@router.put("", response_model=UserSettings) +@router.patch("", 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: Новые настройки для одного или нескольких окружений + settings_update: Частичные настройки для одного или нескольких окружений Returns: - UserSettings: Обновленные настройки + UserSettings: Обновленные настройки со всеми полями """ user_id = current_user["user_id"] diff --git a/app/interfaces/base.py b/app/interfaces/base.py index 5e9c700..5b15f65 100644 --- a/app/interfaces/base.py +++ b/app/interfaces/base.py @@ -252,6 +252,36 @@ class TgBackendInterface: response = await self.client.put(url, json=json_body, **kwargs) return await self._handle_response(response, response_model) + async def patch( + self, + path: str, + body: Optional[BaseModel] = None, + response_model: Optional[Type[T]] = None, + **kwargs + ) -> Any: + """ + HTTP PATCH запрос к {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"PATCH {url} with body={json_body}") + + response = await self.client.patch(url, json=json_body, **kwargs) + return await self._handle_response(response, response_model) + async def delete( self, path: str, diff --git a/app/interfaces/db_api_client.py b/app/interfaces/db_api_client.py index 890e736..b0f89cc 100644 --- a/app/interfaces/db_api_client.py +++ b/app/interfaces/db_api_client.py @@ -3,7 +3,7 @@ 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 +from app.models.analysis import SessionCreate, SessionResponse, SessionList, SessionUpdate class DBApiClient(TgBackendInterface): @@ -11,7 +11,7 @@ class DBApiClient(TgBackendInterface): Клиент для DB API сервиса. Использует Pydantic схемы для type-safety. - Методы self.get(), self.post(), self.put(), self.delete() от TgBackendInterface. + Методы self.get(), self.post(), self.patch(), self.delete() от TgBackendInterface. """ async def login_user(self, request: LoginRequest) -> UserResponse: @@ -36,11 +36,12 @@ class DBApiClient(TgBackendInterface): settings: UserSettingsUpdate ) -> UserSettings: """ - PUT {api_prefix}/users/{user_id}/settings + PATCH {api_prefix}/users/{user_id}/settings - Обновить настройки пользователя. + Частично обновить настройки пользователя. + Обновляются только переданные поля. """ - return await self.put( + return await self.patch( f"/users/{user_id}/settings", body=settings, response_model=UserSettings @@ -94,6 +95,23 @@ class DBApiClient(TgBackendInterface): response_model=SessionResponse ) + async def update_session( + self, + user_id: str, + session_id: str, + update_data: SessionUpdate + ) -> SessionResponse: + """ + PATCH {api_prefix}/users/{user_id}/sessions/{session_id} + + Обновить аннотации сессии (например, после ревью). + """ + return await self.patch( + f"/users/{user_id}/sessions/{session_id}", + body=update_data, + response_model=SessionResponse + ) + async def delete_session(self, user_id: str, session_id: str) -> dict: """ DELETE {api_prefix}/users/{user_id}/sessions/{session_id} diff --git a/app/models/analysis.py b/app/models/analysis.py index 8ef06e0..c976bc1 100644 --- a/app/models/analysis.py +++ b/app/models/analysis.py @@ -1,17 +1,29 @@ """Analysis session Pydantic models.""" -from typing import Any -from pydantic import BaseModel +from typing import Any, Optional +from pydantic import BaseModel, Field class SessionCreate(BaseModel): """Create new analysis session.""" - environment: str - api_mode: str - request: list[Any] - response: dict - annotations: dict + environment: str = Field(..., description="Environment: ift, psi, or prod") + api_mode: str = Field(..., description="API mode: bench or backend") + request: list[Any] = Field(..., description="Array of request objects") + response: dict = Field(..., description="Response object (arbitrary structure)") + annotations: Optional[dict] = Field(default={}, description="Annotations by question index") + + +class SessionUpdate(BaseModel): + """Update session annotations (PATCH). + + According to DB_API_CONTRACT_V2.md: + - PATCH /users/{user_id}/sessions/{session_id} + - Used to update annotations after review + - Completely replaces existing annotations + """ + + annotations: dict = Field(..., description="Annotations with numeric string keys ('0', '1', ...)") class SessionResponse(BaseModel): diff --git a/app/models/query.py b/app/models/query.py index 3cb1858..46a4e26 100644 --- a/app/models/query.py +++ b/app/models/query.py @@ -1,7 +1,7 @@ """Query request/response Pydantic models.""" from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field class QuestionRequest(BaseModel): @@ -26,10 +26,42 @@ class BackendQueryRequest(BaseModel): reset_session: bool = True +class Docs(BaseModel): + """Documents from RAG.""" + + research: list + analytical_hub: list + + +class RagResponse(BaseModel): + """Ответ от RAG на вопрос пользователя.""" + + body_research: str = Field(description="Текст ответа от Research на вопрос") + body_analytical_hub: str = Field(description="Текст ответа от Analytical Hub на вопрос") + docs_from_vectorstore: Docs | None = None + docs_to_llm: Docs | None = None + + +class RagResponseBench(RagResponse): + """Ответ на вопрос + время обработки именно этого вопроса.""" + + processing_time_sec: float = Field( + description="Время обработки запроса в секундах", + ge=0, + ) + question: str = Field(description="Исходный вопрос") + + +class RagResponseBenchList(BaseModel): + """Список ответов RAG в bench режиме.""" + + answers: list[RagResponseBench] + + class QueryResponse(BaseModel): """Query response with metadata.""" request_id: str timestamp: str environment: str - response: dict + response: RagResponseBenchList | dict | list # RagResponseBenchList для bench, dict/list для backend diff --git a/app/models/settings.py b/app/models/settings.py index 103c8b6..b0aaee0 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -1,21 +1,43 @@ """User settings Pydantic models.""" -from pydantic import BaseModel +from typing import Optional +from pydantic import BaseModel, Field class EnvironmentSettings(BaseModel): - """Settings for a specific environment (IFT/PSI/PROD).""" + """Settings for a specific environment (IFT/PSI/PROD). + + According to DB_API_CONTRACT_V2.md: + - apiMode, withClassify, resetSessionMode have defaults + - All other fields are nullable (Optional) + """ apiMode: str = "bench" - bearerToken: str = "" - systemPlatform: str = "" - systemPlatformUser: str = "" - platformUserId: str = "" - platformId: str = "" + bearerToken: Optional[str] = None + systemPlatform: Optional[str] = None + systemPlatformUser: Optional[str] = None + platformUserId: Optional[str] = None + platformId: Optional[str] = None withClassify: bool = False resetSessionMode: bool = True +class EnvironmentSettingsUpdate(BaseModel): + """Partial update for environment settings (for PATCH requests). + + All fields are optional to support partial updates. + """ + + apiMode: Optional[str] = None + bearerToken: Optional[str] = None + systemPlatform: Optional[str] = None + systemPlatformUser: Optional[str] = None + platformUserId: Optional[str] = None + platformId: Optional[str] = None + withClassify: Optional[bool] = None + resetSessionMode: Optional[bool] = None + + class UserSettings(BaseModel): """User settings for all environments.""" @@ -25,6 +47,10 @@ class UserSettings(BaseModel): class UserSettingsUpdate(BaseModel): - """Update user settings request.""" + """Partial update user settings request (PATCH). - settings: dict[str, EnvironmentSettings] + Only the environments/fields provided will be updated. + Unprovided fields remain unchanged. + """ + + settings: dict[str, EnvironmentSettingsUpdate] diff --git a/app/services/rag_service.py b/app/services/rag_service.py index a2f66cf..8415218 100644 --- a/app/services/rag_service.py +++ b/app/services/rag_service.py @@ -12,7 +12,7 @@ import uuid from typing import List, Dict, Optional, Any from datetime import datetime from app.config import settings -from app.models.query import QuestionRequest +from app.models.query import QuestionRequest, RagResponseBenchList logger = logging.getLogger(__name__) @@ -200,7 +200,7 @@ class RagService: questions: List[QuestionRequest], user_settings: Dict, request_id: Optional[str] = None - ) -> Dict[str, Any]: + ) -> RagResponseBenchList: """ Отправить batch запрос к RAG backend (bench mode). @@ -211,7 +211,7 @@ class RagService: request_id: Request ID (опционально) Returns: - Dict с ответом от RAG backend + RagResponseBenchList с ответом от RAG backend Raises: httpx.HTTPStatusError: При HTTP ошибках @@ -220,7 +220,7 @@ class RagService: url = self._get_bench_endpoint(environment) headers = self._build_bench_headers(environment, user_settings, request_id) - + body = [q.model_dump() for q in questions] logger.info(f"Sending bench query to {environment}: {len(questions)} questions") @@ -229,7 +229,10 @@ class RagService: try: response = await client.post(url, json=body, headers=headers) response.raise_for_status() - return response.json() + response_data = response.json() + + # Валидация ответа через Pydantic модель + return RagResponseBenchList(**response_data) except httpx.HTTPStatusError as e: logger.error(f"Bench query failed for {environment}: {e.response.status_code} - {e.response.text}") raise diff --git a/coverage.xml b/coverage.xml index 5cb0a55..5cc2ffb 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,9 +1,9 @@ - + - C:\Users\leonk\Documents\code\brief-bench-fastapi\app + C:\Users\itqop\Documents\code\brief-rags-bench\app @@ -18,33 +18,33 @@ + - + + - + + + - + + - - + + - - - - - @@ -75,25 +75,25 @@ - - + + + + + - - + - - + + - - + + - - - - - - + + + + @@ -106,7 +106,7 @@ - + @@ -115,7 +115,7 @@ - + @@ -173,18 +173,34 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -305,31 +321,31 @@ - - - - - - - - + + + + + + + + - + - + @@ -399,10 +415,16 @@ - - - - + + + + + + + + + + @@ -418,18 +440,20 @@ - - - - - + + + + - - - - + + + + + + + @@ -460,22 +484,24 @@ - - - - - - - - - + + + + + - + + + + + + + @@ -518,29 +544,52 @@ - - + + + + + + + + + + + + + + + - - - - - - - + + + + + + - - - - + + + + + + + + + + + + + + + @@ -649,40 +698,41 @@ - - + - - - + + + - - - + + + - - - + + + - + + - - - - + + + - - - + + + + + diff --git a/tests/conftest.py b/tests/conftest.py index ce12d88..ca32671 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -144,20 +144,36 @@ def unauthenticated_client(): @pytest.fixture def mock_bench_response(): - """Mock RAG backend bench response.""" + """Mock RAG backend bench response (matches format.py structure).""" return { "answers": [ { - "question_id": 1, - "answer": "Test answer 1", - "confidence": 0.95, - "docs": [] + "body_research": "Test research answer 1", + "body_analytical_hub": "Test analytical hub answer 1", + "docs_from_vectorstore": { + "research": [], + "analytical_hub": [] + }, + "docs_to_llm": { + "research": [], + "analytical_hub": [] + }, + "processing_time_sec": 1.5, + "question": "Test question 1" }, { - "question_id": 2, - "answer": "Test answer 2", - "confidence": 0.87, - "docs": [] + "body_research": "Test research answer 2", + "body_analytical_hub": "Test analytical hub answer 2", + "docs_from_vectorstore": { + "research": [], + "analytical_hub": [] + }, + "docs_to_llm": { + "research": [], + "analytical_hub": [] + }, + "processing_time_sec": 2.3, + "question": "Test question 2" } ] } diff --git a/tests/unit/test_db_api_client.py b/tests/unit/test_db_api_client.py index 6d384be..7bb5cef 100644 --- a/tests/unit/test_db_api_client.py +++ b/tests/unit/test_db_api_client.py @@ -4,8 +4,8 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock from app.interfaces.db_api_client import DBApiClient from app.models.auth import LoginRequest, UserResponse -from app.models.settings import UserSettings, UserSettingsUpdate, EnvironmentSettings -from app.models.analysis import SessionCreate, SessionResponse, SessionList, SessionListItem +from app.models.settings import UserSettings, UserSettingsUpdate, EnvironmentSettings, EnvironmentSettingsUpdate +from app.models.analysis import SessionCreate, SessionResponse, SessionList, SessionListItem, SessionUpdate class TestDBApiClient: @@ -70,19 +70,14 @@ class TestDBApiClient: @pytest.mark.asyncio async def test_update_user_settings(self): - """Test update_user_settings calls put correctly.""" + """Test update_user_settings calls patch correctly.""" with patch('app.interfaces.base.httpx.AsyncClient'): client = DBApiClient(api_prefix="http://db-api:8080/api/v1") settings_update = UserSettingsUpdate( settings={ - "ift": EnvironmentSettings( + "ift": EnvironmentSettingsUpdate( apiMode="backend", - bearerToken="", - systemPlatform="", - systemPlatformUser="", - platformUserId="", - platformId="", withClassify=True, resetSessionMode=False ) @@ -90,15 +85,26 @@ class TestDBApiClient: ) mock_updated_settings = UserSettings( user_id="user-123", - settings=settings_update.settings, + settings={ + "ift": EnvironmentSettings( + apiMode="backend", + bearerToken=None, + systemPlatform=None, + systemPlatformUser=None, + platformUserId=None, + platformId=None, + withClassify=True, + resetSessionMode=False + ) + }, updated_at="2024-01-01T01:00:00Z" ) - client.put = AsyncMock(return_value=mock_updated_settings) + client.patch = AsyncMock(return_value=mock_updated_settings) result = await client.update_user_settings("user-123", settings_update) assert result == mock_updated_settings - client.put.assert_called_once_with( + client.patch.assert_called_once_with( "/users/user-123/settings", body=settings_update, response_model=UserSettings @@ -211,6 +217,44 @@ class TestDBApiClient: response_model=SessionResponse ) + @pytest.mark.asyncio + async def test_update_session(self): + """Test update_session calls patch correctly.""" + with patch('app.interfaces.base.httpx.AsyncClient'): + client = DBApiClient(api_prefix="http://db-api:8080/api/v1") + + update_data = SessionUpdate( + annotations={ + "0": { + "overall": { + "rating": "incorrect", + "comment": "Ответ неполный" + } + } + } + ) + mock_updated_session = SessionResponse( + session_id="session-123", + user_id="user-123", + environment="ift", + api_mode="bench", + request=[{"question": "test"}], + response={"answer": "test"}, + annotations=update_data.annotations, + created_at="2024-01-01T00:00:00Z", + updated_at="2024-01-01T01:00:00Z" + ) + client.patch = AsyncMock(return_value=mock_updated_session) + + result = await client.update_session("user-123", "session-123", update_data) + + assert result == mock_updated_session + client.patch.assert_called_once_with( + "/users/user-123/sessions/session-123", + body=update_data, + response_model=SessionResponse + ) + @pytest.mark.asyncio async def test_delete_session(self): """Test delete_session calls delete correctly.""" diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ed5aac0..8b84ecb 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -4,7 +4,8 @@ import pytest from pydantic import ValidationError from app.models.auth import LoginRequest, UserResponse, LoginResponse from app.models.query import QuestionRequest, BenchQueryRequest, BackendQueryRequest, QueryResponse -from app.models.settings import EnvironmentSettings, UserSettingsUpdate +from app.models.settings import EnvironmentSettings, EnvironmentSettingsUpdate, UserSettingsUpdate +from app.models.analysis import SessionCreate, SessionUpdate class TestAuthModels: @@ -120,16 +121,34 @@ class TestQueryModels: def test_query_response(self): """Test QueryResponse model.""" + # Test with RagResponseBenchList (parsed from dict) response = QueryResponse( request_id="req-123", timestamp="2024-01-01T00:00:00Z", environment="ift", - response={"answers": []} + response={"answers": []} # Auto-parsed to RagResponseBenchList ) assert response.request_id == "req-123" assert response.environment == "ift" + # When dict with "answers" is provided, it's parsed as RagResponseBenchList + from app.models.query import RagResponseBenchList + assert isinstance(response.response, RagResponseBenchList) + + def test_query_response_with_dict(self): + """Test QueryResponse model with plain dict (backend mode).""" + # Use dict that doesn't match RagResponseBenchList schema + response = QueryResponse( + request_id="req-456", + timestamp="2024-01-01T00:00:00Z", + environment="psi", + response={"result": "some data", "status": "ok"} + ) + + assert response.request_id == "req-456" + assert response.environment == "psi" assert isinstance(response.response, dict) + assert response.response["status"] == "ok" class TestSettingsModels: @@ -154,30 +173,135 @@ class TestSettingsModels: assert settings.resetSessionMode is False def test_environment_settings_defaults(self): - """Test EnvironmentSettings with default values.""" - settings = EnvironmentSettings(apiMode="backend") + """Test EnvironmentSettings with default values (nullable fields).""" + settings = EnvironmentSettings() - assert settings.apiMode == "backend" - assert settings.bearerToken == "" + assert settings.apiMode == "bench" + assert settings.bearerToken is None + assert settings.systemPlatform is None + assert settings.systemPlatformUser is None + assert settings.platformUserId is None + assert settings.platformId is None assert settings.withClassify is False assert settings.resetSessionMode is True + def test_environment_settings_nullable_fields(self): + """Test EnvironmentSettings with explicit None values.""" + settings = EnvironmentSettings( + apiMode="backend", + bearerToken=None, + systemPlatform=None, + withClassify=True + ) + + assert settings.apiMode == "backend" + assert settings.bearerToken is None + assert settings.systemPlatform is None + assert settings.withClassify is True + + def test_environment_settings_update_partial(self): + """Test EnvironmentSettingsUpdate for partial updates.""" + update = EnvironmentSettingsUpdate( + bearerToken="new-token", + withClassify=True + ) + + assert update.bearerToken == "new-token" + assert update.withClassify is True + assert update.apiMode is None # Not provided, should be None + assert update.systemPlatform is None + + def test_environment_settings_update_all_none(self): + """Test EnvironmentSettingsUpdate with all fields as None.""" + update = EnvironmentSettingsUpdate() + + assert update.apiMode is None + assert update.bearerToken is None + assert update.systemPlatform is None + assert update.withClassify is None + def test_user_settings_update(self): - """Test UserSettingsUpdate model.""" + """Test UserSettingsUpdate model with partial updates.""" update = UserSettingsUpdate( settings={ - "ift": EnvironmentSettings(apiMode="bench"), - "psi": EnvironmentSettings(apiMode="backend") + "ift": EnvironmentSettingsUpdate(apiMode="bench", withClassify=True), + "psi": EnvironmentSettingsUpdate(bearerToken="token123") } ) assert "ift" in update.settings assert "psi" in update.settings assert update.settings["ift"].apiMode == "bench" - assert update.settings["psi"].apiMode == "backend" + assert update.settings["ift"].withClassify is True + assert update.settings["psi"].bearerToken == "token123" + assert update.settings["psi"].apiMode is None # Not provided def test_user_settings_update_empty(self): """Test UserSettingsUpdate with empty settings.""" update = UserSettingsUpdate(settings={}) assert update.settings == {} + + +class TestAnalysisModels: + """Tests for analysis session models.""" + + def test_session_create_valid(self): + """Test valid SessionCreate.""" + session = SessionCreate( + environment="ift", + api_mode="bench", + request=[{"body": "question 1", "with_docs": True}], + response={"answers": ["answer 1"]}, + annotations={"0": {"rating": "correct"}} + ) + + assert session.environment == "ift" + assert session.api_mode == "bench" + assert len(session.request) == 1 + assert session.annotations == {"0": {"rating": "correct"}} + + def test_session_create_default_annotations(self): + """Test SessionCreate with default empty annotations.""" + session = SessionCreate( + environment="psi", + api_mode="backend", + request=[], + response={} + ) + + assert session.annotations == {} + + def test_session_update_valid(self): + """Test valid SessionUpdate.""" + update = SessionUpdate( + annotations={ + "0": { + "overall": { + "rating": "incorrect", + "comment": "Ответ неполный" + }, + "body_research": { + "issues": ["missing_info"], + "comment": "Не указаны процентные ставки" + } + }, + "1": { + "overall": { + "rating": "correct", + "comment": "Ответ корректный" + } + } + } + ) + + assert "0" in update.annotations + assert "1" in update.annotations + assert update.annotations["0"]["overall"]["rating"] == "incorrect" + assert update.annotations["1"]["overall"]["rating"] == "correct" + + def test_session_update_empty_annotations(self): + """Test SessionUpdate with empty annotations.""" + update = SessionUpdate(annotations={}) + + assert update.annotations == {} diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py index 9f192cf..463cc81 100644 --- a/tests/unit/test_query.py +++ b/tests/unit/test_query.py @@ -4,7 +4,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import httpx from app.services.rag_service import RagService -from app.models.query import QuestionRequest +from app.models.query import QuestionRequest, RagResponseBenchList class TestBenchQueryEndpoint: @@ -16,7 +16,9 @@ class TestBenchQueryEndpoint: with patch('app.api.v1.query.RagService') as MockRagService: mock_rag = AsyncMock() - mock_rag.send_bench_query = AsyncMock(return_value=mock_bench_response) + # Mock возвращает RagResponseBenchList + from app.models.query import RagResponseBenchList + mock_rag.send_bench_query = AsyncMock(return_value=RagResponseBenchList(**mock_bench_response)) mock_rag.close = AsyncMock() MockRagService.return_value = mock_rag @@ -37,7 +39,9 @@ class TestBenchQueryEndpoint: assert "timestamp" in data assert data["environment"] == "ift" assert "response" in data - assert data["response"] == mock_bench_response + # FastAPI автоматически сериализует RagResponseBenchList в dict + assert data["response"]["answers"][0]["question"] == "Test question 1" + assert data["response"]["answers"][0]["body_research"] == "Test research answer 1" mock_rag.send_bench_query.assert_called_once() mock_rag.close.assert_called_once() @@ -245,7 +249,7 @@ class TestRagService: @pytest.mark.asyncio async def test_send_bench_query_success(self, mock_httpx_client, mock_bench_response): """Test successful bench query via RagService.""" - + mock_httpx_client.post.return_value.json.return_value = mock_bench_response with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client): @@ -268,10 +272,15 @@ class TestRagService: request_id="test-request-123" ) - assert result == mock_bench_response + # Проверяем, что результат - это RagResponseBenchList + assert isinstance(result, RagResponseBenchList) + assert len(result.answers) == 2 + assert result.answers[0].question == "Test question 1" + assert result.answers[0].body_research == "Test research answer 1" + mock_httpx_client.post.assert_called_once() - + call_kwargs = mock_httpx_client.post.call_args[1] headers = call_kwargs["headers"] assert headers["Request-Id"] == "test-request-123" diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 411b0b5..01609a4 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -46,7 +46,7 @@ class TestSettingsEndpoints: assert response.status_code == 401 def test_update_settings_success(self, client, mock_db_client, test_settings): - """Test updating user settings successfully.""" + """Test updating user settings successfully with PATCH (partial update).""" mock_db_client.update_user_settings = AsyncMock(return_value=test_settings) update_data = { @@ -59,7 +59,7 @@ class TestSettingsEndpoints: } } - response = client.put("/api/v1/settings", json=update_data) + response = client.patch("/api/v1/settings", json=update_data) assert response.status_code == 200 data = response.json() @@ -80,7 +80,7 @@ class TestSettingsEndpoints: } } - response = client.put("/api/v1/settings", json=update_data) + response = client.patch("/api/v1/settings", json=update_data) assert response.status_code == 400 @@ -96,7 +96,7 @@ class TestSettingsEndpoints: } } - response = client.put("/api/v1/settings", json=update_data) + response = client.patch("/api/v1/settings", json=update_data) assert response.status_code == 500 @@ -136,7 +136,7 @@ class TestSettingsEndpoints: } } - response = client.put("/api/v1/settings", json=update_data) + response = client.patch("/api/v1/settings", json=update_data) assert response.status_code == 404 assert "user not found" in response.json()["detail"].lower() @@ -154,7 +154,26 @@ class TestSettingsEndpoints: } } - response = client.put("/api/v1/settings", json=update_data) + response = client.patch("/api/v1/settings", json=update_data) assert response.status_code == 502 assert "failed to update settings" in response.json()["detail"].lower() + + def test_update_settings_partial_update(self, client, mock_db_client, test_settings): + """Test partial update - only updating specific fields.""" + mock_db_client.update_user_settings = AsyncMock(return_value=test_settings) + + # Partial update - only bearerToken and withClassify for IFT + update_data = { + "settings": { + "ift": { + "bearerToken": "updated-token-123", + "withClassify": True + } + } + } + + response = client.patch("/api/v1/settings", json=update_data) + + assert response.status_code == 200 + mock_db_client.update_user_settings.assert_called_once()