feat: add submitted collection, /similar and /submitted endpoints (Stage 4)

Made-with: Cursor
This commit is contained in:
2026-02-28 19:00:22 +03:00
parent 955f518429
commit a1d6d2d860
15 changed files with 1308 additions and 400 deletions

View File

@@ -24,14 +24,14 @@ async def verify_api_key(
) -> bool:
"""
Проверяет API ключ из заголовка запроса.
Args:
api_key: Ключ из заголовка X-API-Key
settings: Настройки приложения
Returns:
True если авторизация успешна
Raises:
HTTPException: Если ключ неверный или отсутствует
"""
@@ -47,7 +47,7 @@ async def verify_api_key(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="API ключ не настроен на сервере",
)
# Проверяем ключ
if api_key is None:
logger.warning("Запрос без API ключа")
@@ -56,14 +56,14 @@ async def verify_api_key(
detail="API ключ не предоставлен. Используйте заголовок X-API-Key",
headers={"WWW-Authenticate": "ApiKey"},
)
if api_key != settings.api_key:
logger.warning("Неверный API ключ")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Неверный API ключ",
)
return True

View File

@@ -24,7 +24,12 @@ from app.schemas import (
ScoreRequest,
ScoreResponse,
ScoringParamsResponse,
SimilarPostItem,
SimilarRequest,
SimilarResponse,
StatsResponse,
SubmittedRequest,
SubmittedResponse,
UpdateScoringParamsRequest,
VectorStoreStats,
WarmupResponse,
@@ -49,6 +54,7 @@ RAGServiceDep = Annotated[RAGService, Depends(get_service)]
# Health Check
# =============================================================================
@router.get(
"/health",
response_model=HealthResponse,
@@ -58,7 +64,7 @@ RAGServiceDep = Annotated[RAGService, Depends(get_service)]
async def health_check(service: RAGServiceDep, _auth: AuthDep) -> HealthResponse:
"""
Проверяет состояние сервиса.
Returns:
HealthResponse: Статус сервиса
"""
@@ -73,6 +79,7 @@ async def health_check(service: RAGServiceDep, _auth: AuthDep) -> HealthResponse
# Scoring
# =============================================================================
@router.post(
"/score",
response_model=ScoreResponse,
@@ -86,55 +93,55 @@ async def health_check(service: RAGServiceDep, _auth: AuthDep) -> HealthResponse
tags=["scoring"],
)
async def calculate_score(
request: ScoreRequest,
request: ScoreRequest,
service: RAGServiceDep,
_auth: AuthDep,
) -> ScoreResponse:
"""
Рассчитывает скор для текста поста.
Args:
request: Запрос с текстом
service: RAG сервис
Returns:
ScoreResponse: Результат скоринга
Raises:
HTTPException: При ошибке расчета
"""
try:
result = await service.calculate_score(request.text)
response_dict = result.to_dict()
return ScoreResponse(
rag_score=response_dict["rag_score"],
rag_confidence=response_dict["rag_confidence"],
rag_score_pos_only=response_dict["rag_score_pos_only"],
meta=ScoreMetadata(**response_dict["meta"]),
)
except TextTooShortError as e:
logger.warning(f"Текст слишком короткий: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": str(e), "error_type": "TextTooShortError"},
)
except InsufficientExamplesError as e:
logger.warning(f"Недостаточно примеров: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": str(e), "error_type": "InsufficientExamplesError"},
)
except ModelNotLoadedError as e:
logger.error(f"Модель не загружена: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"detail": str(e), "error_type": "ModelNotLoadedError"},
)
except ScoringError as e:
logger.error(f"Ошибка скоринга: {e}")
raise HTTPException(
@@ -147,6 +154,7 @@ async def calculate_score(
# Examples
# =============================================================================
@router.post(
"/examples/positive",
response_model=ExampleResponse,
@@ -159,27 +167,27 @@ async def calculate_score(
tags=["examples"],
)
async def add_positive_example(
request: ExampleRequest,
request: ExampleRequest,
service: RAGServiceDep,
_auth: AuthDep,
x_test_mode: str | None = Header(default=None, alias="X-Test-Mode"),
) -> ExampleResponse:
"""
Добавляет текст как положительный пример (опубликованный пост).
При наличии заголовка X-Test-Mode: true пример НЕ сохраняется (тестовый режим).
Args:
request: Запрос с текстом
service: RAG сервис
x_test_mode: Заголовок тестового режима
Returns:
ExampleResponse: Результат добавления
"""
# Тестовый режим — не сохраняем примеры
is_test = x_test_mode and x_test_mode.lower() == "true"
if is_test:
logger.info("Тестовый режим: положительный пример НЕ сохранён")
return ExampleResponse(
@@ -188,22 +196,22 @@ async def add_positive_example(
positive_count=service.vector_store.positive_count,
negative_count=service.vector_store.negative_count,
)
try:
added = await service.add_positive_example(request.text)
if added:
message = "Положительный пример добавлен"
else:
message = "Пример не добавлен (дубликат или слишком короткий текст)"
return ExampleResponse(
success=added,
message=message,
positive_count=service.vector_store.positive_count,
negative_count=service.vector_store.negative_count,
)
except ModelNotLoadedError as e:
logger.error(f"Модель не загружена: {e}")
raise HTTPException(
@@ -224,27 +232,27 @@ async def add_positive_example(
tags=["examples"],
)
async def add_negative_example(
request: ExampleRequest,
request: ExampleRequest,
service: RAGServiceDep,
_auth: AuthDep,
x_test_mode: str | None = Header(default=None, alias="X-Test-Mode"),
) -> ExampleResponse:
"""
Добавляет текст как отрицательный пример (отклоненный пост).
При наличии заголовка X-Test-Mode: true пример НЕ сохраняется (тестовый режим).
Args:
request: Запрос с текстом
service: RAG сервис
x_test_mode: Заголовок тестового режима
Returns:
ExampleResponse: Результат добавления
"""
# Тестовый режим — не сохраняем примеры
is_test = x_test_mode and x_test_mode.lower() == "true"
if is_test:
logger.info("Тестовый режим: отрицательный пример НЕ сохранён")
return ExampleResponse(
@@ -253,22 +261,128 @@ async def add_negative_example(
positive_count=service.vector_store.positive_count,
negative_count=service.vector_store.negative_count,
)
try:
added = await service.add_negative_example(request.text)
if added:
message = "Отрицательный пример добавлен"
else:
message = "Пример не добавлен (дубликат или слишком короткий текст)"
return ExampleResponse(
success=added,
message=message,
positive_count=service.vector_store.positive_count,
negative_count=service.vector_store.negative_count,
)
except ModelNotLoadedError as e:
logger.error(f"Модель не загружена: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"detail": str(e), "error_type": "ModelNotLoadedError"},
)
# =============================================================================
# Similar & Submitted
# =============================================================================
@router.post(
"/similar",
response_model=SimilarResponse,
responses={
400: {"model": ErrorResponse, "description": "Ошибка в запросе"},
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
503: {"model": ErrorResponse, "description": "Сервис недоступен"},
},
summary="Поиск похожих постов",
tags=["similar"],
)
async def find_similar_posts(
request: SimilarRequest,
service: RAGServiceDep,
_auth: AuthDep,
) -> SimilarResponse:
"""
Ищет похожие submitted-посты за последние N часов.
Args:
request: Запрос с текстом, threshold и hours
service: RAG сервис
Returns:
SimilarResponse: Список похожих постов
"""
try:
similar = await service.find_similar_posts(
text=request.text,
threshold=request.threshold,
hours=request.hours,
)
return SimilarResponse(
similar_count=len(similar),
similar_posts=[SimilarPostItem(**item) for item in similar],
)
except TextTooShortError as e:
logger.warning(f"Текст слишком короткий: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": str(e), "error_type": "TextTooShortError"},
)
except ModelNotLoadedError as e:
logger.error(f"Модель не загружена: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"detail": str(e), "error_type": "ModelNotLoadedError"},
)
@router.post(
"/submitted",
response_model=SubmittedResponse,
responses={
400: {"model": ErrorResponse, "description": "Ошибка в запросе"},
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
503: {"model": ErrorResponse, "description": "Сервис недоступен"},
},
summary="Добавить submitted-пост",
tags=["submitted"],
)
async def add_submitted_post(
request: SubmittedRequest,
service: RAGServiceDep,
_auth: AuthDep,
) -> SubmittedResponse:
"""
Добавляет submitted-пост в коллекцию для индексации ботом.
Args:
request: Запрос с текстом, post_id и rag_score
service: RAG сервис
Returns:
SubmittedResponse: Результат добавления
"""
try:
added = await service.add_submitted_post(
text=request.text,
post_id=request.post_id,
rag_score=request.rag_score,
)
if added:
message = "Submitted-пост добавлен"
else:
message = "Пост не добавлен (дубликат или слишком короткий текст)"
return SubmittedResponse(
success=added,
message=message,
submitted_count=service.vector_store.submitted_count,
)
except ModelNotLoadedError as e:
logger.error(f"Модель не загружена: {e}")
raise HTTPException(
@@ -281,6 +395,7 @@ async def add_negative_example(
# Stats & Warmup
# =============================================================================
@router.get(
"/stats",
response_model=StatsResponse,
@@ -294,15 +409,15 @@ async def add_negative_example(
async def get_stats(service: RAGServiceDep, _auth: AuthDep) -> StatsResponse:
"""
Возвращает статистику сервиса.
Args:
service: RAG сервис
Returns:
StatsResponse: Статистика
"""
stats = service.get_stats()
return StatsResponse(
model_name=stats["model_name"],
model_loaded=stats["model_loaded"],
@@ -325,15 +440,15 @@ async def get_stats(service: RAGServiceDep, _auth: AuthDep) -> StatsResponse:
async def warmup(service: RAGServiceDep, _auth: AuthDep) -> WarmupResponse:
"""
Прогревает модель (загружает если не загружена).
Args:
service: RAG сервис
Returns:
WarmupResponse: Результат прогрева
"""
success = await service.warmup()
if success:
message = "Модель успешно загружена"
else:
@@ -342,7 +457,7 @@ async def warmup(service: RAGServiceDep, _auth: AuthDep) -> WarmupResponse:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"detail": message, "error_type": "ModelNotLoadedError"},
)
return WarmupResponse(
success=success,
model_loaded=service.is_model_loaded,
@@ -354,6 +469,7 @@ async def warmup(service: RAGServiceDep, _auth: AuthDep) -> WarmupResponse:
# Scoring Parameters
# =============================================================================
@router.get(
"/scoring/params",
response_model=ScoringParamsResponse,
@@ -370,10 +486,10 @@ async def get_scoring_params(
) -> ScoringParamsResponse:
"""
Возвращает текущие параметры формулы расчета score.
Args:
service: RAG сервис
Returns:
ScoringParamsResponse: Текущие параметры формулы
"""
@@ -399,17 +515,17 @@ async def update_scoring_params(
) -> ScoringParamsResponse:
"""
Обновляет параметры формулы расчета score.
Можно обновить один или несколько параметров одновременно.
Параметры, которые не указаны, остаются без изменений.
Args:
request: Запрос с новыми параметрами
service: RAG сервис
Returns:
ScoringParamsResponse: Обновленные параметры формулы
Raises:
HTTPException: При невалидных значениях параметров
"""