fix: db api

This commit is contained in:
itqop 2025-12-25 09:44:52 +03:00
parent ea94f98e74
commit 74cdc196c6
16 changed files with 1332 additions and 182 deletions

View File

@ -15,7 +15,14 @@
"Bash(.venv/Scripts/python.exe -m pytest tests/ -v --tb=line)", "Bash(.venv/Scripts/python.exe -m pytest tests/ -v --tb=line)",
"Bash(.venv/Scripts/python.exe -m pytest:*)", "Bash(.venv/Scripts/python.exe -m pytest:*)",
"Bash(.venvScriptspython.exe -m pytest tests/unit/ -v --cov=app --cov-report=term-missing)", "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": [], "deny": [],
"ask": [] "ask": []

710
DB_API_CONTRACT_V2.md Normal file
View File

@ -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-инъекций
---

View File

@ -6,7 +6,7 @@ Analysis Sessions API endpoints.
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Optional 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.interfaces.db_api_client import DBApiClient
from app.dependencies import get_db_client, get_current_user from app.dependencies import get_db_client, get_current_user
import httpx 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) @router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_session( async def delete_session(
session_id: str, session_id: str,

View File

@ -51,20 +51,22 @@ async def get_settings(
) )
@router.put("", response_model=UserSettings) @router.patch("", response_model=UserSettings)
async def update_settings( async def update_settings(
settings_update: UserSettingsUpdate, settings_update: UserSettingsUpdate,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_client: DBApiClient = Depends(get_db_client) db_client: DBApiClient = Depends(get_db_client)
): ):
""" """
Обновить настройки пользователя. Частично обновить настройки пользователя.
Обновляются только переданные поля. Непереданные поля остаются без изменений.
Args: Args:
settings_update: Новые настройки для одного или нескольких окружений settings_update: Частичные настройки для одного или нескольких окружений
Returns: Returns:
UserSettings: Обновленные настройки UserSettings: Обновленные настройки со всеми полями
""" """
user_id = current_user["user_id"] user_id = current_user["user_id"]

View File

@ -252,6 +252,36 @@ class TgBackendInterface:
response = await self.client.put(url, json=json_body, **kwargs) response = await self.client.put(url, json=json_body, **kwargs)
return await self._handle_response(response, response_model) 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( async def delete(
self, self,
path: str, path: str,

View File

@ -3,7 +3,7 @@
from app.interfaces.base import TgBackendInterface from app.interfaces.base import TgBackendInterface
from app.models.auth import LoginRequest, UserResponse from app.models.auth import LoginRequest, UserResponse
from app.models.settings import UserSettings, UserSettingsUpdate 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): class DBApiClient(TgBackendInterface):
@ -11,7 +11,7 @@ class DBApiClient(TgBackendInterface):
Клиент для DB API сервиса. Клиент для DB API сервиса.
Использует Pydantic схемы для type-safety. Использует 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: async def login_user(self, request: LoginRequest) -> UserResponse:
@ -36,11 +36,12 @@ class DBApiClient(TgBackendInterface):
settings: UserSettingsUpdate settings: UserSettingsUpdate
) -> UserSettings: ) -> 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", f"/users/{user_id}/settings",
body=settings, body=settings,
response_model=UserSettings response_model=UserSettings
@ -94,6 +95,23 @@ class DBApiClient(TgBackendInterface):
response_model=SessionResponse 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: async def delete_session(self, user_id: str, session_id: str) -> dict:
""" """
DELETE {api_prefix}/users/{user_id}/sessions/{session_id} DELETE {api_prefix}/users/{user_id}/sessions/{session_id}

View File

@ -1,17 +1,29 @@
"""Analysis session Pydantic models.""" """Analysis session Pydantic models."""
from typing import Any from typing import Any, Optional
from pydantic import BaseModel from pydantic import BaseModel, Field
class SessionCreate(BaseModel): class SessionCreate(BaseModel):
"""Create new analysis session.""" """Create new analysis session."""
environment: str environment: str = Field(..., description="Environment: ift, psi, or prod")
api_mode: str api_mode: str = Field(..., description="API mode: bench or backend")
request: list[Any] request: list[Any] = Field(..., description="Array of request objects")
response: dict response: dict = Field(..., description="Response object (arbitrary structure)")
annotations: dict 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): class SessionResponse(BaseModel):

View File

@ -1,7 +1,7 @@
"""Query request/response Pydantic models.""" """Query request/response Pydantic models."""
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel, Field
class QuestionRequest(BaseModel): class QuestionRequest(BaseModel):
@ -26,10 +26,42 @@ class BackendQueryRequest(BaseModel):
reset_session: bool = True 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): class QueryResponse(BaseModel):
"""Query response with metadata.""" """Query response with metadata."""
request_id: str request_id: str
timestamp: str timestamp: str
environment: str environment: str
response: dict response: RagResponseBenchList | dict | list # RagResponseBenchList для bench, dict/list для backend

View File

@ -1,21 +1,43 @@
"""User settings Pydantic models.""" """User settings Pydantic models."""
from pydantic import BaseModel from typing import Optional
from pydantic import BaseModel, Field
class EnvironmentSettings(BaseModel): 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" apiMode: str = "bench"
bearerToken: str = "" bearerToken: Optional[str] = None
systemPlatform: str = "" systemPlatform: Optional[str] = None
systemPlatformUser: str = "" systemPlatformUser: Optional[str] = None
platformUserId: str = "" platformUserId: Optional[str] = None
platformId: str = "" platformId: Optional[str] = None
withClassify: bool = False withClassify: bool = False
resetSessionMode: bool = True 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): class UserSettings(BaseModel):
"""User settings for all environments.""" """User settings for all environments."""
@ -25,6 +47,10 @@ class UserSettings(BaseModel):
class UserSettingsUpdate(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]

View File

@ -12,7 +12,7 @@ import uuid
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any
from datetime import datetime from datetime import datetime
from app.config import settings from app.config import settings
from app.models.query import QuestionRequest from app.models.query import QuestionRequest, RagResponseBenchList
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -200,7 +200,7 @@ class RagService:
questions: List[QuestionRequest], questions: List[QuestionRequest],
user_settings: Dict, user_settings: Dict,
request_id: Optional[str] = None request_id: Optional[str] = None
) -> Dict[str, Any]: ) -> RagResponseBenchList:
""" """
Отправить batch запрос к RAG backend (bench mode). Отправить batch запрос к RAG backend (bench mode).
@ -211,7 +211,7 @@ class RagService:
request_id: Request ID (опционально) request_id: Request ID (опционально)
Returns: Returns:
Dict с ответом от RAG backend RagResponseBenchList с ответом от RAG backend
Raises: Raises:
httpx.HTTPStatusError: При HTTP ошибках httpx.HTTPStatusError: При HTTP ошибках
@ -220,7 +220,7 @@ class RagService:
url = self._get_bench_endpoint(environment) url = self._get_bench_endpoint(environment)
headers = self._build_bench_headers(environment, user_settings, request_id) headers = self._build_bench_headers(environment, user_settings, request_id)
body = [q.model_dump() for q in questions] body = [q.model_dump() for q in questions]
logger.info(f"Sending bench query to {environment}: {len(questions)} questions") logger.info(f"Sending bench query to {environment}: {len(questions)} questions")
@ -229,7 +229,10 @@ class RagService:
try: try:
response = await client.post(url, json=body, headers=headers) response = await client.post(url, json=body, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() response_data = response.json()
# Валидация ответа через Pydantic модель
return RagResponseBenchList(**response_data)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error(f"Bench query failed for {environment}: {e.response.status_code} - {e.response.text}") logger.error(f"Bench query failed for {environment}: {e.response.status_code} - {e.response.text}")
raise raise

View File

@ -1,9 +1,9 @@
<?xml version="1.0" ?> <?xml version="1.0" ?>
<coverage version="7.13.0" timestamp="1766039646938" lines-valid="567" lines-covered="563" line-rate="0.9929" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0"> <coverage version="7.13.0" timestamp="1766645017585" lines-valid="617" lines-covered="594" line-rate="0.9627" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.13.0 --> <!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.13.0 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd --> <!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources> <sources>
<source>C:\Users\leonk\Documents\code\brief-bench-fastapi\app</source> <source>C:\Users\itqop\Documents\code\brief-rags-bench\app</source>
</sources> </sources>
<packages> <packages>
<package name="." line-rate="0.971" branch-rate="0" complexity="0"> <package name="." line-rate="0.971" branch-rate="0" complexity="0">
@ -18,33 +18,33 @@
<line number="3" hits="1"/> <line number="3" hits="1"/>
<line number="6" hits="1"/> <line number="6" hits="1"/>
<line number="9" hits="1"/> <line number="9" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/> <line number="16" hits="1"/>
<line number="17" hits="1"/> <line number="18" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/> <line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/> <line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="25" hits="1"/> <line number="25" hits="1"/>
<line number="26" hits="1"/> <line number="26" hits="1"/>
<line number="27" hits="1"/>
<line number="28" hits="1"/>
<line number="29" hits="1"/> <line number="29" hits="1"/>
<line number="30" hits="1"/> <line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="32" hits="1"/> <line number="32" hits="1"/>
<line number="33" hits="1"/> <line number="33" hits="1"/>
<line number="34" hits="1"/> <line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="37" hits="1"/> <line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/> <line number="39" hits="1"/>
<line number="40" hits="1"/> <line number="40" hits="1"/>
<line number="41" hits="1"/> <line number="41" hits="1"/>
<line number="42" hits="1"/> <line number="42" hits="1"/>
<line number="45" hits="1"/> <line number="43" hits="1"/>
<line number="44" hits="1"/>
<line number="46" hits="1"/> <line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="49" hits="1"/> <line number="49" hits="1"/>
<line number="50" hits="1"/>
<line number="53" hits="1"/>
<line number="57" hits="1"/>
</lines> </lines>
</class> </class>
<class name="dependencies.py" filename="dependencies.py" complexity="0" line-rate="1" branch-rate="0"> <class name="dependencies.py" filename="dependencies.py" complexity="0" line-rate="1" branch-rate="0">
@ -75,25 +75,25 @@
<line number="6" hits="1"/> <line number="6" hits="1"/>
<line number="8" hits="1"/> <line number="8" hits="1"/>
<line number="9" hits="1"/> <line number="9" hits="1"/>
<line number="12" hits="1"/> <line number="11" hits="1"/>
<line number="19" hits="1"/> <line number="17" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="27" hits="1"/>
<line number="28" hits="1"/> <line number="28" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/> <line number="30" hits="1"/>
<line number="31" hits="1"/> <line number="33" hits="1"/>
<line number="34" hits="1"/> <line number="34" hits="1"/>
<line number="37" hits="1"/> <line number="36" hits="1"/>
<line number="38" hits="1"/> <line number="39" hits="1"/>
<line number="40" hits="1"/> <line number="40" hits="1"/>
<line number="43" hits="1"/> <line number="42" hits="1"/>
<line number="44" hits="1"/> <line number="45" hits="1"/>
<line number="46" hits="1"/> <line number="46" hits="1"/>
<line number="49" hits="1"/> <line number="48" hits="1"/>
<line number="50" hits="1"/> <line number="51" hits="1"/>
<line number="52" hits="1"/> <line number="52" hits="0"/>
<line number="55" hits="1"/> <line number="53" hits="0"/>
<line number="56" hits="0"/>
<line number="57" hits="0"/>
</lines> </lines>
</class> </class>
</classes> </classes>
@ -106,7 +106,7 @@
</class> </class>
</classes> </classes>
</package> </package>
<package name="api.v1" line-rate="0.9894" branch-rate="0" complexity="0"> <package name="api.v1" line-rate="0.9216" branch-rate="0" complexity="0">
<classes> <classes>
<class name="__init__.py" filename="api/v1/__init__.py" complexity="0" line-rate="1" branch-rate="0"> <class name="__init__.py" filename="api/v1/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/> <methods/>
@ -115,7 +115,7 @@
<line number="7" hits="1"/> <line number="7" hits="1"/>
</lines> </lines>
</class> </class>
<class name="analysis.py" filename="api/v1/analysis.py" complexity="0" line-rate="1" branch-rate="0"> <class name="analysis.py" filename="api/v1/analysis.py" complexity="0" line-rate="0.8313" branch-rate="0">
<methods/> <methods/>
<lines> <lines>
<line number="7" hits="1"/> <line number="7" hits="1"/>
@ -173,18 +173,34 @@
<line number="140" hits="1"/> <line number="140" hits="1"/>
<line number="146" hits="1"/> <line number="146" hits="1"/>
<line number="147" hits="1"/> <line number="147" hits="1"/>
<line number="161" hits="1"/> <line number="165" hits="0"/>
<line number="163" hits="1"/> <line number="167" hits="0"/>
<line number="164" hits="1"/> <line number="168" hits="0"/>
<line number="165" hits="1"/> <line number="169" hits="0"/>
<line number="166" hits="1"/> <line number="170" hits="0"/>
<line number="167" hits="1"/> <line number="171" hits="0"/>
<line number="168" hits="1"/> <line number="172" hits="0"/>
<line number="172" hits="1"/> <line number="176" hits="0"/>
<line number="173" hits="1"/> <line number="177" hits="0"/>
<line number="177" hits="1"/> <line number="181" hits="0"/>
<line number="178" hits="1"/> <line number="182" hits="0"/>
<line number="179" hits="1"/> <line number="186" hits="0"/>
<line number="187" hits="0"/>
<line number="188" hits="0"/>
<line number="194" hits="1"/>
<line number="195" hits="1"/>
<line number="209" hits="1"/>
<line number="211" hits="1"/>
<line number="212" hits="1"/>
<line number="213" hits="1"/>
<line number="214" hits="1"/>
<line number="215" hits="1"/>
<line number="216" hits="1"/>
<line number="220" hits="1"/>
<line number="221" hits="1"/>
<line number="225" hits="1"/>
<line number="226" hits="1"/>
<line number="227" hits="1"/>
</lines> </lines>
</class> </class>
<class name="auth.py" filename="api/v1/auth.py" complexity="0" line-rate="1" branch-rate="0"> <class name="auth.py" filename="api/v1/auth.py" complexity="0" line-rate="1" branch-rate="0">
@ -305,31 +321,31 @@
<line number="48" hits="1"/> <line number="48" hits="1"/>
<line number="54" hits="1"/> <line number="54" hits="1"/>
<line number="55" hits="1"/> <line number="55" hits="1"/>
<line number="69" hits="1"/>
<line number="71" hits="1"/> <line number="71" hits="1"/>
<line number="72" hits="1"/>
<line number="73" hits="1"/> <line number="73" hits="1"/>
<line number="74" hits="1"/> <line number="74" hits="1"/>
<line number="75" hits="1"/> <line number="75" hits="1"/>
<line number="76" hits="1"/> <line number="76" hits="1"/>
<line number="80" hits="1"/> <line number="77" hits="1"/>
<line number="81" hits="1"/> <line number="78" hits="1"/>
<line number="85" hits="1"/> <line number="82" hits="1"/>
<line number="86" hits="1"/> <line number="83" hits="1"/>
<line number="90" hits="1"/> <line number="87" hits="1"/>
<line number="91" hits="1"/> <line number="88" hits="1"/>
<line number="92" hits="1"/> <line number="92" hits="1"/>
<line number="93" hits="1"/>
<line number="94" hits="1"/>
</lines> </lines>
</class> </class>
</classes> </classes>
</package> </package>
<package name="interfaces" line-rate="1" branch-rate="0" complexity="0"> <package name="interfaces" line-rate="0.9505" branch-rate="0" complexity="0">
<classes> <classes>
<class name="__init__.py" filename="interfaces/__init__.py" complexity="0" line-rate="1" branch-rate="0"> <class name="__init__.py" filename="interfaces/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/> <methods/>
<lines/> <lines/>
</class> </class>
<class name="base.py" filename="interfaces/base.py" complexity="0" line-rate="1" branch-rate="0"> <class name="base.py" filename="interfaces/base.py" complexity="0" line-rate="0.9351" branch-rate="0">
<methods/> <methods/>
<lines> <lines>
<line number="9" hits="1"/> <line number="9" hits="1"/>
@ -399,10 +415,16 @@
<line number="252" hits="1"/> <line number="252" hits="1"/>
<line number="253" hits="1"/> <line number="253" hits="1"/>
<line number="255" hits="1"/> <line number="255" hits="1"/>
<line number="273" hits="1"/> <line number="278" hits="0"/>
<line number="274" hits="1"/> <line number="279" hits="0"/>
<line number="276" hits="1"/> <line number="280" hits="0"/>
<line number="277" hits="1"/> <line number="282" hits="0"/>
<line number="283" hits="0"/>
<line number="285" hits="1"/>
<line number="303" hits="1"/>
<line number="304" hits="1"/>
<line number="306" hits="1"/>
<line number="307" hits="1"/>
</lines> </lines>
</class> </class>
<class name="db_api_client.py" filename="interfaces/db_api_client.py" complexity="0" line-rate="1" branch-rate="0"> <class name="db_api_client.py" filename="interfaces/db_api_client.py" complexity="0" line-rate="1" branch-rate="0">
@ -418,18 +440,20 @@
<line number="25" hits="1"/> <line number="25" hits="1"/>
<line number="31" hits="1"/> <line number="31" hits="1"/>
<line number="33" hits="1"/> <line number="33" hits="1"/>
<line number="43" hits="1"/> <line number="44" hits="1"/>
<line number="49" hits="1"/> <line number="50" hits="1"/>
<line number="59" hits="1"/> <line number="60" hits="1"/>
<line number="65" hits="1"/> <line number="66" hits="1"/>
<line number="77" hits="1"/>
<line number="78" hits="1"/> <line number="78" hits="1"/>
<line number="79" hits="1"/> <line number="79" hits="1"/>
<line number="80" hits="1"/> <line number="80" hits="1"/>
<line number="86" hits="1"/> <line number="81" hits="1"/>
<line number="92" hits="1"/> <line number="87" hits="1"/>
<line number="97" hits="1"/> <line number="93" hits="1"/>
<line number="103" hits="1"/> <line number="98" hits="1"/>
<line number="109" hits="1"/>
<line number="115" hits="1"/>
<line number="121" hits="1"/>
</lines> </lines>
</class> </class>
</classes> </classes>
@ -460,22 +484,24 @@
<line number="13" hits="1"/> <line number="13" hits="1"/>
<line number="14" hits="1"/> <line number="14" hits="1"/>
<line number="17" hits="1"/> <line number="17" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/> <line number="26" hits="1"/>
<line number="27" hits="1"/> <line number="29" hits="1"/>
<line number="28" hits="1"/> <line number="32" hits="1"/>
<line number="31" hits="1"/> <line number="33" hits="1"/>
<line number="34" hits="1"/> <line number="34" hits="1"/>
<line number="35" hits="1"/> <line number="35" hits="1"/>
<line number="36" hits="1"/> <line number="36" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/> <line number="39" hits="1"/>
<line number="42" hits="1"/> <line number="40" hits="1"/>
<line number="43" hits="1"/> <line number="43" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="51" hits="1"/>
<line number="54" hits="1"/>
<line number="55" hits="1"/>
</lines> </lines>
</class> </class>
<class name="auth.py" filename="models/auth.py" complexity="0" line-rate="1" branch-rate="0"> <class name="auth.py" filename="models/auth.py" complexity="0" line-rate="1" branch-rate="0">
@ -518,29 +544,52 @@
<line number="29" hits="1"/> <line number="29" hits="1"/>
<line number="32" hits="1"/> <line number="32" hits="1"/>
<line number="33" hits="1"/> <line number="33" hits="1"/>
<line number="34" hits="1"/> <line number="36" hits="1"/>
<line number="35" hits="1"/> <line number="39" hits="1"/>
<line number="40" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="45" hits="1"/>
<line number="48" hits="1"/>
<line number="52" hits="1"/>
<line number="55" hits="1"/>
<line number="58" hits="1"/>
<line number="61" hits="1"/>
<line number="64" hits="1"/>
<line number="65" hits="1"/>
<line number="66" hits="1"/>
<line number="67" hits="1"/>
</lines> </lines>
</class> </class>
<class name="settings.py" filename="models/settings.py" complexity="0" line-rate="1" branch-rate="0"> <class name="settings.py" filename="models/settings.py" complexity="0" line-rate="1" branch-rate="0">
<methods/> <methods/>
<lines> <lines>
<line number="3" hits="1"/> <line number="3" hits="1"/>
<line number="6" hits="1"/> <line number="4" hits="1"/>
<line number="9" hits="1"/> <line number="7" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="15" hits="1"/> <line number="15" hits="1"/>
<line number="16" hits="1"/> <line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="1"/>
<line number="19" hits="1"/> <line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/> <line number="22" hits="1"/>
<line number="23" hits="1"/> <line number="25" hits="1"/>
<line number="24" hits="1"/> <line number="31" hits="1"/>
<line number="27" hits="1"/> <line number="32" hits="1"/>
<line number="30" hits="1"/> <line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="41" hits="1"/>
<line number="44" hits="1"/>
<line number="45" hits="1"/>
<line number="46" hits="1"/>
<line number="49" hits="1"/>
<line number="56" hits="1"/>
</lines> </lines>
</class> </class>
</classes> </classes>
@ -649,40 +698,41 @@
<line number="230" hits="1"/> <line number="230" hits="1"/>
<line number="231" hits="1"/> <line number="231" hits="1"/>
<line number="232" hits="1"/> <line number="232" hits="1"/>
<line number="233" hits="1"/>
<line number="234" hits="1"/>
<line number="235" hits="1"/> <line number="235" hits="1"/>
<line number="236" hits="1"/> <line number="236" hits="1"/>
<line number="237" hits="1"/> <line number="237" hits="1"/>
<line number="238" hits="1"/> <line number="238" hits="1"/>
<line number="239" hits="1"/>
<line number="240" hits="1"/> <line number="240" hits="1"/>
<line number="264" hits="1"/> <line number="241" hits="1"/>
<line number="265" hits="1"/> <line number="243" hits="1"/>
<line number="266" hits="1"/> <line number="267" hits="1"/>
<line number="268" hits="1"/> <line number="268" hits="1"/>
<line number="273" hits="1"/> <line number="269" hits="1"/>
<line number="275" hits="1"/> <line number="271" hits="1"/>
<line number="277" hits="1"/> <line number="276" hits="1"/>
<line number="278" hits="1"/> <line number="278" hits="1"/>
<line number="285" hits="1"/> <line number="280" hits="1"/>
<line number="287" hits="1"/> <line number="281" hits="1"/>
<line number="289" hits="1"/> <line number="288" hits="1"/>
<line number="290" hits="1"/> <line number="290" hits="1"/>
<line number="291" hits="1"/>
<line number="292" hits="1"/> <line number="292" hits="1"/>
<line number="293" hits="1"/>
<line number="294" hits="1"/>
<line number="295" hits="1"/> <line number="295" hits="1"/>
<line number="296" hits="1"/>
<line number="297" hits="1"/>
<line number="298" hits="1"/> <line number="298" hits="1"/>
<line number="303" hits="1"/> <line number="299" hits="1"/>
<line number="305" hits="1"/> <line number="300" hits="1"/>
<line number="301" hits="1"/>
<line number="306" hits="1"/> <line number="306" hits="1"/>
<line number="310" hits="1"/> <line number="308" hits="1"/>
<line number="311" hits="1"/> <line number="309" hits="1"/>
<line number="312" hits="1"/>
<line number="313" hits="1"/> <line number="313" hits="1"/>
<line number="314" hits="1"/>
<line number="315" hits="1"/> <line number="315" hits="1"/>
<line number="316" hits="1"/> <line number="316" hits="1"/>
<line number="318" hits="1"/>
<line number="319" hits="1"/>
</lines> </lines>
</class> </class>
</classes> </classes>

View File

@ -144,20 +144,36 @@ def unauthenticated_client():
@pytest.fixture @pytest.fixture
def mock_bench_response(): def mock_bench_response():
"""Mock RAG backend bench response.""" """Mock RAG backend bench response (matches format.py structure)."""
return { return {
"answers": [ "answers": [
{ {
"question_id": 1, "body_research": "Test research answer 1",
"answer": "Test answer 1", "body_analytical_hub": "Test analytical hub answer 1",
"confidence": 0.95, "docs_from_vectorstore": {
"docs": [] "research": [],
"analytical_hub": []
},
"docs_to_llm": {
"research": [],
"analytical_hub": []
},
"processing_time_sec": 1.5,
"question": "Test question 1"
}, },
{ {
"question_id": 2, "body_research": "Test research answer 2",
"answer": "Test answer 2", "body_analytical_hub": "Test analytical hub answer 2",
"confidence": 0.87, "docs_from_vectorstore": {
"docs": [] "research": [],
"analytical_hub": []
},
"docs_to_llm": {
"research": [],
"analytical_hub": []
},
"processing_time_sec": 2.3,
"question": "Test question 2"
} }
] ]
} }

View File

@ -4,8 +4,8 @@ import pytest
from unittest.mock import AsyncMock, patch, MagicMock from unittest.mock import AsyncMock, patch, MagicMock
from app.interfaces.db_api_client import DBApiClient from app.interfaces.db_api_client import DBApiClient
from app.models.auth import LoginRequest, UserResponse from app.models.auth import LoginRequest, UserResponse
from app.models.settings import UserSettings, UserSettingsUpdate, EnvironmentSettings from app.models.settings import UserSettings, UserSettingsUpdate, EnvironmentSettings, EnvironmentSettingsUpdate
from app.models.analysis import SessionCreate, SessionResponse, SessionList, SessionListItem from app.models.analysis import SessionCreate, SessionResponse, SessionList, SessionListItem, SessionUpdate
class TestDBApiClient: class TestDBApiClient:
@ -70,19 +70,14 @@ class TestDBApiClient:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_settings(self): 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'): with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1") client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
settings_update = UserSettingsUpdate( settings_update = UserSettingsUpdate(
settings={ settings={
"ift": EnvironmentSettings( "ift": EnvironmentSettingsUpdate(
apiMode="backend", apiMode="backend",
bearerToken="",
systemPlatform="",
systemPlatformUser="",
platformUserId="",
platformId="",
withClassify=True, withClassify=True,
resetSessionMode=False resetSessionMode=False
) )
@ -90,15 +85,26 @@ class TestDBApiClient:
) )
mock_updated_settings = UserSettings( mock_updated_settings = UserSettings(
user_id="user-123", 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" 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) result = await client.update_user_settings("user-123", settings_update)
assert result == mock_updated_settings assert result == mock_updated_settings
client.put.assert_called_once_with( client.patch.assert_called_once_with(
"/users/user-123/settings", "/users/user-123/settings",
body=settings_update, body=settings_update,
response_model=UserSettings response_model=UserSettings
@ -211,6 +217,44 @@ class TestDBApiClient:
response_model=SessionResponse 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 @pytest.mark.asyncio
async def test_delete_session(self): async def test_delete_session(self):
"""Test delete_session calls delete correctly.""" """Test delete_session calls delete correctly."""

View File

@ -4,7 +4,8 @@ import pytest
from pydantic import ValidationError from pydantic import ValidationError
from app.models.auth import LoginRequest, UserResponse, LoginResponse from app.models.auth import LoginRequest, UserResponse, LoginResponse
from app.models.query import QuestionRequest, BenchQueryRequest, BackendQueryRequest, QueryResponse 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: class TestAuthModels:
@ -120,16 +121,34 @@ class TestQueryModels:
def test_query_response(self): def test_query_response(self):
"""Test QueryResponse model.""" """Test QueryResponse model."""
# Test with RagResponseBenchList (parsed from dict)
response = QueryResponse( response = QueryResponse(
request_id="req-123", request_id="req-123",
timestamp="2024-01-01T00:00:00Z", timestamp="2024-01-01T00:00:00Z",
environment="ift", environment="ift",
response={"answers": []} response={"answers": []} # Auto-parsed to RagResponseBenchList
) )
assert response.request_id == "req-123" assert response.request_id == "req-123"
assert response.environment == "ift" 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 isinstance(response.response, dict)
assert response.response["status"] == "ok"
class TestSettingsModels: class TestSettingsModels:
@ -154,30 +173,135 @@ class TestSettingsModels:
assert settings.resetSessionMode is False assert settings.resetSessionMode is False
def test_environment_settings_defaults(self): def test_environment_settings_defaults(self):
"""Test EnvironmentSettings with default values.""" """Test EnvironmentSettings with default values (nullable fields)."""
settings = EnvironmentSettings(apiMode="backend") settings = EnvironmentSettings()
assert settings.apiMode == "backend" assert settings.apiMode == "bench"
assert settings.bearerToken == "" 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.withClassify is False
assert settings.resetSessionMode is True 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): def test_user_settings_update(self):
"""Test UserSettingsUpdate model.""" """Test UserSettingsUpdate model with partial updates."""
update = UserSettingsUpdate( update = UserSettingsUpdate(
settings={ settings={
"ift": EnvironmentSettings(apiMode="bench"), "ift": EnvironmentSettingsUpdate(apiMode="bench", withClassify=True),
"psi": EnvironmentSettings(apiMode="backend") "psi": EnvironmentSettingsUpdate(bearerToken="token123")
} }
) )
assert "ift" in update.settings assert "ift" in update.settings
assert "psi" in update.settings assert "psi" in update.settings
assert update.settings["ift"].apiMode == "bench" 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): def test_user_settings_update_empty(self):
"""Test UserSettingsUpdate with empty settings.""" """Test UserSettingsUpdate with empty settings."""
update = UserSettingsUpdate(settings={}) update = UserSettingsUpdate(settings={})
assert update.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 == {}

View File

@ -4,7 +4,7 @@ import pytest
from unittest.mock import AsyncMock, patch, MagicMock from unittest.mock import AsyncMock, patch, MagicMock
import httpx import httpx
from app.services.rag_service import RagService from app.services.rag_service import RagService
from app.models.query import QuestionRequest from app.models.query import QuestionRequest, RagResponseBenchList
class TestBenchQueryEndpoint: class TestBenchQueryEndpoint:
@ -16,7 +16,9 @@ class TestBenchQueryEndpoint:
with patch('app.api.v1.query.RagService') as MockRagService: with patch('app.api.v1.query.RagService') as MockRagService:
mock_rag = AsyncMock() 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() mock_rag.close = AsyncMock()
MockRagService.return_value = mock_rag MockRagService.return_value = mock_rag
@ -37,7 +39,9 @@ class TestBenchQueryEndpoint:
assert "timestamp" in data assert "timestamp" in data
assert data["environment"] == "ift" assert data["environment"] == "ift"
assert "response" in data 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.send_bench_query.assert_called_once()
mock_rag.close.assert_called_once() mock_rag.close.assert_called_once()
@ -245,7 +249,7 @@ class TestRagService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_bench_query_success(self, mock_httpx_client, mock_bench_response): async def test_send_bench_query_success(self, mock_httpx_client, mock_bench_response):
"""Test successful bench query via RagService.""" """Test successful bench query via RagService."""
mock_httpx_client.post.return_value.json.return_value = mock_bench_response 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): with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
@ -268,10 +272,15 @@ class TestRagService:
request_id="test-request-123" 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() mock_httpx_client.post.assert_called_once()
call_kwargs = mock_httpx_client.post.call_args[1] call_kwargs = mock_httpx_client.post.call_args[1]
headers = call_kwargs["headers"] headers = call_kwargs["headers"]
assert headers["Request-Id"] == "test-request-123" assert headers["Request-Id"] == "test-request-123"

View File

@ -46,7 +46,7 @@ class TestSettingsEndpoints:
assert response.status_code == 401 assert response.status_code == 401
def test_update_settings_success(self, client, mock_db_client, test_settings): 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) mock_db_client.update_user_settings = AsyncMock(return_value=test_settings)
update_data = { 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 assert response.status_code == 200
data = response.json() 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 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 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 response.status_code == 404
assert "user not found" in response.json()["detail"].lower() 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 response.status_code == 502
assert "failed to update settings" in response.json()["detail"].lower() 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()