from __future__ import annotations import asyncio import json import logging import sqlite3 from typing import ( Any, Literal, NoReturn, ) from fastapi import ( APIRouter, Depends, Header, HTTPException, Request, status, ) from fastapi.responses import JSONResponse from pydantic import ( BaseModel, ConfigDict, Field, ) from app.security import ( expected_gitea_repository, repository_name_from_payload, require_search_api_key, require_sync_api_key, same_repository, validate_secret, validate_security_configuration, verify_gitea_signature, webhook_should_pull_git, ) from scripts.common import ( DB_FILE, ZPWIKI_ROOT, ) from scripts.rag_utils import build_rag_context from scripts.rebuild_index import ( ReindexInProgressError, rebuild_index, ) from scripts.search_utils import search_database logger = logging.getLogger(__name__) router = APIRouter() RETRIEVAL_UNAVAILABLE_DETAIL = ( "Vyhľadávací index alebo embeddingový " "model momentálne nie je dostupný." ) INTERNAL_ERROR_DETAIL = ( "Nastala interná chyba servera." ) REINDEX_FAILED_DETAIL = ( "Reindexovanie zlyhalo." ) WEBHOOK_CONFIGURATION_DETAIL = ( "Webhook nie je správne nakonfigurovaný." ) class StrictRequestModel(BaseModel): model_config = ConfigDict( extra="forbid", str_strip_whitespace=True, ) class SearchRequest( StrictRequestModel ): query: str = Field( ..., min_length=1, max_length=500, ) limit: int = Field( default=10, ge=1, le=50, ) published_only: bool = False max_per_document: int = Field( default=1, ge=0, le=10, ) class RagRequest( StrictRequestModel ): query: str = Field( ..., min_length=1, max_length=500, description=( "Otázka alebo vyhľadávací dotaz " "používateľa nad ZP Wiki." ), ) limit: int = Field( default=5, ge=1, le=20, description=( "Maximálny počet relevantných " "zdrojov pre RAG kontext." ), ) published_only: bool = Field( default=False, description=( "Ak je true, použijú sa iba " "publikované dokumenty." ), ) max_per_document: int = Field( default=1, ge=0, le=10, description=( "Maximálny počet chunkov z jedného " "dokumentu. Hodnota 1 preferuje " "rôzne dokumenty." ), ) class SyncRequest( StrictRequestModel ): pull_git: bool = Field( default=False, description=( "Pred reindexovaním vykoná " "git pull --ff-only." ), ) class HealthResponse(BaseModel): status: Literal[ "ok" ] ready: bool database_exists: bool database_path: str search_engine: str rag_enabled: bool zpwiki_root: str zpwiki_exists: bool security_configured: bool class RagSource(BaseModel): # RAG source sa môže v budúcnosti # rozšíriť o ďalšie retrieval metadata # bez rozbitia response modelu. model_config = ConfigDict( extra="allow", ) source_id: str title: str source_url: str author: str | None = None published: bool | None = None heading_paths: list[Any] | None = None text: str | None = None retrieval: dict[ str, Any, ] | None = None class RagResponse(BaseModel): model_config = ConfigDict( extra="forbid", ) query: str engine: str strategies: list[str] source_count: int = Field( ge=0, ) instructions: list[str] answer_format: dict[ str, Any, ] context: str sources: list[ RagSource ] class SearchResponse(BaseModel): query: str engine: str strategies: list[str] count: int = Field( ge=0, ) results: list[ dict[str, Any] ] class SyncResponse(BaseModel): status: Literal[ "ok" ] pull_git: bool duration_seconds: float counts: dict[ str, int, ] class WebhookSuccessResponse( BaseModel ): status: Literal[ "ok" ] event: str repository: str verified_by: Literal[ "hmac_sha256" ] duration_seconds: float counts: dict[ str, int, ] class WebhookIgnoredResponse( BaseModel ): status: Literal[ "ignored" ] reason: Literal[ "unsupported_event" ] event: str def raise_retrieval_error( error: Exception, ) -> NoReturn: if isinstance( error, ValueError, ): raise HTTPException( status_code=( status.HTTP_400_BAD_REQUEST ), detail=str( error ), ) from error if isinstance( error, ( FileNotFoundError, sqlite3.Error, RuntimeError, ), ): logger.exception( "Retrieval nie je dostupný." ) raise HTTPException( status_code=( status.HTTP_503_SERVICE_UNAVAILABLE ), detail=( RETRIEVAL_UNAVAILABLE_DETAIL ), ) from error logger.exception( "Neočakávaná chyba retrieval API." ) raise HTTPException( status_code=( status.HTTP_500_INTERNAL_SERVER_ERROR ), detail=( INTERNAL_ERROR_DETAIL ), ) from error def raise_reindex_error( error: Exception, ) -> NoReturn: if isinstance( error, ReindexInProgressError, ): raise HTTPException( status_code=( status.HTTP_409_CONFLICT ), detail=str( error ), ) from error logger.exception( "Reindexovanie zlyhalo." ) raise HTTPException( status_code=( status.HTTP_500_INTERNAL_SERVER_ERROR ), detail=( REINDEX_FAILED_DETAIL ), ) from error def webhook_secret() -> str: try: return validate_secret( "WEBHOOK_SECRET" ) except RuntimeError as error: logger.exception( "WEBHOOK_SECRET nie je " "správne nakonfigurovaný." ) raise HTTPException( status_code=( status.HTTP_503_SERVICE_UNAVAILABLE ), detail=( WEBHOOK_CONFIGURATION_DETAIL ), ) from error def configured_repository() -> str: try: return ( expected_gitea_repository() ) except RuntimeError as error: logger.exception( "EXPECTED_GITEA_REPOSITORY " "nie je správne nakonfigurovaný." ) raise HTTPException( status_code=( status.HTTP_503_SERVICE_UNAVAILABLE ), detail=( WEBHOOK_CONFIGURATION_DETAIL ), ) from error @router.get( "/health", include_in_schema=False, response_model=HealthResponse, ) def health() -> dict[str, Any]: database_exists = ( DB_FILE.exists() ) zpwiki_exists = ( ZPWIKI_ROOT.exists() ) try: validate_security_configuration() security_configured = True except RuntimeError: security_configured = False ready = ( database_exists and zpwiki_exists and security_configured ) return { "status": "ok", "ready": ready, "database_exists": ( database_exists ), "database_path": str( DB_FILE ), "search_engine": ( "hybrid_fts5_embeddings" ), "rag_enabled": True, "zpwiki_root": str( ZPWIKI_ROOT ), "zpwiki_exists": ( zpwiki_exists ), "security_configured": ( security_configured ), } @router.post( "/rag", operation_id=( "retrieve_zpwiki_context" ), summary=( "Vyhľadaj informácie v ZP Wiki" ), description=( "Použi tento nástroj pri otázkach " "o ZP Wiki, študentoch, autoroch, " "záverečných prácach, témach, rokoch, " "projektoch alebo dokumentoch. " "Nástroj vykoná hybridné FTS5 a " "embeddingové vyhľadávanie a pripraví " "zdrojovo podložený RAG kontext." ), response_model=RagResponse, response_model_exclude_unset=True, status_code=( status.HTTP_200_OK ), responses={ 400: { "description": ( "Neplatný dotaz." ), }, 401: { "description": ( "Chýbajúci alebo neplatný " "API kľúč." ), }, 422: { "description": ( "Neplatná štruktúra requestu." ), }, 500: { "description": ( "Interná chyba servera." ), }, 503: { "description": ( "Retrieval nie je momentálne " "dostupný." ), }, }, dependencies=[ Depends( require_search_api_key ) ], ) def rag( request: RagRequest, ) -> dict[str, Any]: try: return build_rag_context( DB_FILE, request.query, limit=request.limit, published_only=( request.published_only ), max_per_document=( request.max_per_document ), ) except Exception as error: raise_retrieval_error( error ) @router.post( "/search", dependencies=[ Depends( require_search_api_key ) ], include_in_schema=False, response_model=SearchResponse, ) def search( request: SearchRequest, ) -> dict[str, Any]: try: response = search_database( DB_FILE, request.query, request.limit, published_only=( request.published_only ), max_per_document=( request.max_per_document ), ) except Exception as error: raise_retrieval_error( error ) results = response[ "results" ] return { "query": request.query, "engine": response[ "engine" ], "strategies": response[ "strategies" ], "count": len( results ), "results": results, } @router.post( "/sync", dependencies=[ Depends( require_sync_api_key ) ], include_in_schema=False, response_model=SyncResponse, ) def sync( request: SyncRequest, ) -> dict[str, Any]: try: result = rebuild_index( pull_git=( request.pull_git ) ) except Exception as error: raise_reindex_error( error ) return { "status": "ok", "pull_git": ( request.pull_git ), "duration_seconds": ( result[ "duration_seconds" ] ), "counts": result[ "counts" ], } @router.post( "/webhook/gitea", response_model=( WebhookSuccessResponse | WebhookIgnoredResponse ), response_model_exclude_unset=True, include_in_schema=False, ) async def gitea_webhook( request: Request, x_gitea_event: str | None = Header( default=None, alias="X-Gitea-Event", ), x_gitea_signature: str | None = Header( default=None, alias="X-Gitea-Signature", ), ) -> ( dict[str, Any] | JSONResponse ): raw_body = await request.body() secret = webhook_secret() if not verify_gitea_signature( raw_body, x_gitea_signature, secret, ): raise HTTPException( status_code=( status.HTTP_401_UNAUTHORIZED ), detail=( "Neplatný webhook podpis" ), ) try: payload = json.loads( raw_body.decode( "utf-8" ) ) except ( UnicodeDecodeError, json.JSONDecodeError, ) as error: raise HTTPException( status_code=( status.HTTP_400_BAD_REQUEST ), detail=( "Webhook payload nie je " "platný JSON" ), ) from error if not isinstance( payload, dict, ): raise HTTPException( status_code=( status.HTTP_400_BAD_REQUEST ), detail=( "Webhook payload musí byť " "JSON objekt" ), ) if not x_gitea_event: raise HTTPException( status_code=( status.HTTP_400_BAD_REQUEST ), detail=( "Chýba hlavička " "X-Gitea-Event" ), ) if ( x_gitea_event.casefold() != "push" ): return JSONResponse( status_code=( status.HTTP_202_ACCEPTED ), content={ "status": ( "ignored" ), "reason": ( "unsupported_event" ), "event": ( x_gitea_event ), }, ) repository_name = ( repository_name_from_payload( payload ) ) if repository_name is None: raise HTTPException( status_code=( status.HTTP_400_BAD_REQUEST ), detail=( "Webhook payload neobsahuje " "repository.full_name" ), ) expected_repository = ( configured_repository() ) if not same_repository( repository_name, expected_repository, ): raise HTTPException( status_code=( status.HTTP_403_FORBIDDEN ), detail=( "Webhook patrí neočakávanému " "repozitáru" ), ) try: result = await asyncio.to_thread( rebuild_index, pull_git=( webhook_should_pull_git() ), ) except Exception as error: raise_reindex_error( error ) return { "status": "ok", "event": ( x_gitea_event ), "repository": ( repository_name ), "verified_by": ( "hmac_sha256" ), "duration_seconds": ( result[ "duration_seconds" ] ), "counts": result[ "counts" ], }