Files
rag-service/app/api/routes.py

430 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
FastAPI endpoints для RAG сервиса.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from app import __version__
from app.api.auth import AuthDep
from app.exceptions import (
InsufficientExamplesError,
ModelNotLoadedError,
ScoringError,
TextTooShortError,
)
from app.schemas import (
ErrorResponse,
ExampleRequest,
ExampleResponse,
HealthResponse,
ScoreMetadata,
ScoreRequest,
ScoreResponse,
ScoringParamsResponse,
StatsResponse,
UpdateScoringParamsRequest,
VectorStoreStats,
WarmupResponse,
)
from app.services.rag_service import RAGService, get_rag_service
logger = logging.getLogger(__name__)
router = APIRouter()
# Dependency для получения RAG сервиса
def get_service() -> RAGService:
"""Возвращает экземпляр RAG сервиса."""
return get_rag_service()
RAGServiceDep = Annotated[RAGService, Depends(get_service)]
# =============================================================================
# Health Check
# =============================================================================
@router.get(
"/health",
response_model=HealthResponse,
summary="Проверка здоровья сервиса",
tags=["health"],
)
async def health_check(service: RAGServiceDep, _auth: AuthDep) -> HealthResponse:
"""
Проверяет состояние сервиса.
Returns:
HealthResponse: Статус сервиса
"""
return HealthResponse(
status="healthy",
model_loaded=service.is_model_loaded,
version=__version__,
)
# =============================================================================
# Scoring
# =============================================================================
@router.post(
"/score",
response_model=ScoreResponse,
responses={
400: {"model": ErrorResponse, "description": "Ошибка в запросе"},
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
503: {"model": ErrorResponse, "description": "Сервис недоступен"},
},
summary="Расчет скора для текста",
tags=["scoring"],
)
async def calculate_score(
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(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"detail": str(e), "error_type": "ScoringError"},
)
# =============================================================================
# Examples
# =============================================================================
@router.post(
"/examples/positive",
response_model=ExampleResponse,
responses={
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
503: {"model": ErrorResponse, "description": "Сервис недоступен"},
},
summary="Добавить положительный пример",
tags=["examples"],
)
async def add_positive_example(
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(
success=True,
message="Тестовый режим: пример не сохранён",
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(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"detail": str(e), "error_type": "ModelNotLoadedError"},
)
@router.post(
"/examples/negative",
response_model=ExampleResponse,
responses={
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
503: {"model": ErrorResponse, "description": "Сервис недоступен"},
},
summary="Добавить отрицательный пример",
tags=["examples"],
)
async def add_negative_example(
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(
success=True,
message="Тестовый режим: пример не сохранён",
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"},
)
# =============================================================================
# Stats & Warmup
# =============================================================================
@router.get(
"/stats",
response_model=StatsResponse,
responses={
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
},
summary="Статистика сервиса",
tags=["monitoring"],
)
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"],
device=stats["device"],
vector_store=VectorStoreStats(**stats["vector_store"]),
)
@router.post(
"/warmup",
response_model=WarmupResponse,
responses={
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
503: {"model": ErrorResponse, "description": "Не удалось загрузить модель"},
},
summary="Прогрев модели",
tags=["management"],
)
async def warmup(service: RAGServiceDep, _auth: AuthDep) -> WarmupResponse:
"""
Прогревает модель (загружает если не загружена).
Args:
service: RAG сервис
Returns:
WarmupResponse: Результат прогрева
"""
success = await service.warmup()
if success:
message = "Модель успешно загружена"
else:
message = "Не удалось загрузить модель"
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"detail": message, "error_type": "ModelNotLoadedError"},
)
return WarmupResponse(
success=success,
model_loaded=service.is_model_loaded,
message=message,
)
# =============================================================================
# Scoring Parameters
# =============================================================================
@router.get(
"/scoring/params",
response_model=ScoringParamsResponse,
responses={
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
},
summary="Получить параметры формулы расчета score",
tags=["scoring"],
)
async def get_scoring_params(
service: RAGServiceDep,
_auth: AuthDep,
) -> ScoringParamsResponse:
"""
Возвращает текущие параметры формулы расчета score.
Args:
service: RAG сервис
Returns:
ScoringParamsResponse: Текущие параметры формулы
"""
params = service.vector_store.get_scoring_params()
return ScoringParamsResponse(**params)
@router.put(
"/scoring/params",
response_model=ScoringParamsResponse,
responses={
400: {"model": ErrorResponse, "description": "Ошибка в запросе"},
401: {"model": ErrorResponse, "description": "Не авторизован"},
403: {"model": ErrorResponse, "description": "Доступ запрещён"},
},
summary="Обновить параметры формулы расчета score",
tags=["scoring"],
)
async def update_scoring_params(
request: UpdateScoringParamsRequest,
service: RAGServiceDep,
_auth: AuthDep,
) -> ScoringParamsResponse:
"""
Обновляет параметры формулы расчета score.
Можно обновить один или несколько параметров одновременно.
Параметры, которые не указаны, остаются без изменений.
Args:
request: Запрос с новыми параметрами
service: RAG сервис
Returns:
ScoringParamsResponse: Обновленные параметры формулы
Raises:
HTTPException: При невалидных значениях параметров
"""
try:
params = service.vector_store.update_scoring_params(
score_multiplier=request.score_multiplier,
k_min=request.k_min,
k_max=request.k_max,
base_multiplier_factor=request.base_multiplier_factor,
)
return ScoringParamsResponse(**params)
except ValueError as e:
logger.warning(f"Невалидные параметры формулы: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": str(e), "error_type": "ValueError"},
)