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:*)",
"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": []

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 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,

View File

@ -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"]

View File

@ -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,

View File

@ -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}

View File

@ -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):

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -1,9 +1,9 @@
<?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 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources>
<source>C:\Users\leonk\Documents\code\brief-bench-fastapi\app</source>
<source>C:\Users\itqop\Documents\code\brief-rags-bench\app</source>
</sources>
<packages>
<package name="." line-rate="0.971" branch-rate="0" complexity="0">
@ -18,33 +18,33 @@
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="15" 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="21" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="27" hits="1"/>
<line number="28" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="32" 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="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="43" hits="1"/>
<line number="44" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="49" hits="1"/>
<line number="50" hits="1"/>
<line number="53" hits="1"/>
<line number="57" hits="1"/>
</lines>
</class>
<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="8" hits="1"/>
<line number="9" hits="1"/>
<line number="12" hits="1"/>
<line number="19" hits="1"/>
<line number="11" 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="29" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="36" hits="1"/>
<line number="39" hits="1"/>
<line number="40" hits="1"/>
<line number="43" hits="1"/>
<line number="44" hits="1"/>
<line number="42" hits="1"/>
<line number="45" hits="1"/>
<line number="46" hits="1"/>
<line number="49" hits="1"/>
<line number="50" hits="1"/>
<line number="52" hits="1"/>
<line number="55" hits="1"/>
<line number="56" hits="0"/>
<line number="57" hits="0"/>
<line number="48" hits="1"/>
<line number="51" hits="1"/>
<line number="52" hits="0"/>
<line number="53" hits="0"/>
</lines>
</class>
</classes>
@ -106,7 +106,7 @@
</class>
</classes>
</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>
<class name="__init__.py" filename="api/v1/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
@ -115,7 +115,7 @@
<line number="7" hits="1"/>
</lines>
</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/>
<lines>
<line number="7" hits="1"/>
@ -173,18 +173,34 @@
<line number="140" hits="1"/>
<line number="146" hits="1"/>
<line number="147" hits="1"/>
<line number="161" hits="1"/>
<line number="163" hits="1"/>
<line number="164" hits="1"/>
<line number="165" hits="1"/>
<line number="166" hits="1"/>
<line number="167" hits="1"/>
<line number="168" hits="1"/>
<line number="172" hits="1"/>
<line number="173" hits="1"/>
<line number="177" hits="1"/>
<line number="178" hits="1"/>
<line number="179" hits="1"/>
<line number="165" hits="0"/>
<line number="167" hits="0"/>
<line number="168" hits="0"/>
<line number="169" hits="0"/>
<line number="170" hits="0"/>
<line number="171" hits="0"/>
<line number="172" hits="0"/>
<line number="176" hits="0"/>
<line number="177" hits="0"/>
<line number="181" hits="0"/>
<line number="182" hits="0"/>
<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>
</class>
<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="54" hits="1"/>
<line number="55" hits="1"/>
<line number="69" hits="1"/>
<line number="71" hits="1"/>
<line number="72" hits="1"/>
<line number="73" hits="1"/>
<line number="74" hits="1"/>
<line number="75" hits="1"/>
<line number="76" hits="1"/>
<line number="80" hits="1"/>
<line number="81" hits="1"/>
<line number="85" hits="1"/>
<line number="86" hits="1"/>
<line number="90" hits="1"/>
<line number="91" hits="1"/>
<line number="77" hits="1"/>
<line number="78" hits="1"/>
<line number="82" hits="1"/>
<line number="83" hits="1"/>
<line number="87" hits="1"/>
<line number="88" hits="1"/>
<line number="92" hits="1"/>
<line number="93" hits="1"/>
<line number="94" hits="1"/>
</lines>
</class>
</classes>
</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>
<class name="__init__.py" filename="interfaces/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</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/>
<lines>
<line number="9" hits="1"/>
@ -399,10 +415,16 @@
<line number="252" hits="1"/>
<line number="253" hits="1"/>
<line number="255" hits="1"/>
<line number="273" hits="1"/>
<line number="274" hits="1"/>
<line number="276" hits="1"/>
<line number="277" hits="1"/>
<line number="278" hits="0"/>
<line number="279" hits="0"/>
<line number="280" hits="0"/>
<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>
</class>
<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="31" hits="1"/>
<line number="33" hits="1"/>
<line number="43" hits="1"/>
<line number="49" hits="1"/>
<line number="59" hits="1"/>
<line number="65" hits="1"/>
<line number="77" hits="1"/>
<line number="44" hits="1"/>
<line number="50" hits="1"/>
<line number="60" hits="1"/>
<line number="66" hits="1"/>
<line number="78" hits="1"/>
<line number="79" hits="1"/>
<line number="80" hits="1"/>
<line number="86" hits="1"/>
<line number="92" hits="1"/>
<line number="97" hits="1"/>
<line number="103" hits="1"/>
<line number="81" hits="1"/>
<line number="87" hits="1"/>
<line number="93" hits="1"/>
<line number="98" hits="1"/>
<line number="109" hits="1"/>
<line number="115" hits="1"/>
<line number="121" hits="1"/>
</lines>
</class>
</classes>
@ -460,22 +484,24 @@
<line number="13" hits="1"/>
<line number="14" 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="27" hits="1"/>
<line number="28" hits="1"/>
<line number="31" hits="1"/>
<line number="29" hits="1"/>
<line number="32" 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="39" hits="1"/>
<line number="42" hits="1"/>
<line number="40" 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>
</class>
<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="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" 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>
</class>
<class name="settings.py" filename="models/settings.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="9" 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="4" hits="1"/>
<line number="7" hits="1"/>
<line number="15" 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="21" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="27" hits="1"/>
<line number="30" hits="1"/>
<line number="25" hits="1"/>
<line number="31" hits="1"/>
<line number="32" 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>
</class>
</classes>
@ -649,40 +698,41 @@
<line number="230" hits="1"/>
<line number="231" hits="1"/>
<line number="232" hits="1"/>
<line number="233" hits="1"/>
<line number="234" hits="1"/>
<line number="235" hits="1"/>
<line number="236" hits="1"/>
<line number="237" hits="1"/>
<line number="238" hits="1"/>
<line number="239" hits="1"/>
<line number="240" hits="1"/>
<line number="264" hits="1"/>
<line number="265" hits="1"/>
<line number="266" hits="1"/>
<line number="241" hits="1"/>
<line number="243" hits="1"/>
<line number="267" hits="1"/>
<line number="268" hits="1"/>
<line number="273" hits="1"/>
<line number="275" hits="1"/>
<line number="277" hits="1"/>
<line number="269" hits="1"/>
<line number="271" hits="1"/>
<line number="276" hits="1"/>
<line number="278" hits="1"/>
<line number="285" hits="1"/>
<line number="287" hits="1"/>
<line number="289" hits="1"/>
<line number="280" hits="1"/>
<line number="281" hits="1"/>
<line number="288" hits="1"/>
<line number="290" hits="1"/>
<line number="291" hits="1"/>
<line number="292" hits="1"/>
<line number="293" hits="1"/>
<line number="294" hits="1"/>
<line number="295" hits="1"/>
<line number="296" hits="1"/>
<line number="297" hits="1"/>
<line number="298" hits="1"/>
<line number="303" hits="1"/>
<line number="305" hits="1"/>
<line number="299" hits="1"/>
<line number="300" hits="1"/>
<line number="301" hits="1"/>
<line number="306" hits="1"/>
<line number="310" hits="1"/>
<line number="311" hits="1"/>
<line number="312" hits="1"/>
<line number="308" hits="1"/>
<line number="309" hits="1"/>
<line number="313" hits="1"/>
<line number="314" hits="1"/>
<line number="315" hits="1"/>
<line number="316" hits="1"/>
<line number="318" hits="1"/>
<line number="319" hits="1"/>
</lines>
</class>
</classes>

View File

@ -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"
}
]
}

View File

@ -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."""

View File

@ -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 == {}

View File

@ -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"

View File

@ -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()