""" 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"}, )