from __future__ import annotations import json import logging import re 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 tools.rag.common import ( DB_FILE, ZPWIKI_ROOT, ) from tools.rag.rag_utils import build_rag_context from tools.rag.llm_context import compact_rag_payload from tools.graphrag.graph_search import search_graph, DEFAULT_LIMIT, DEFAULT_MAX_ENTITIES from tools.rag.search_utils import search_database from tools.sync.wiki_git import expected_branch from tools.retrieval.publication import DEFAULT_PUBLISHED_ONLY from tools.runtime.tool_health import tools_readiness from tools.retrieval.retrieval_contract import ( RetrievalResponse, RetrievalSource, RetrievalErrorResponse, error_reason, ) from tools.answers.answer_delivery import deliver_answer from tools.answers.answer_errors import public_failure logger = logging.getLogger(__name__) router = APIRouter() MAX_WEBHOOK_BODY_BYTES = 2 * 1024 * 1024 RETRIEVAL_UNAVAILABLE_DETAIL = ( "Vyhľadávací index alebo embeddingový " "model momentálne nie je dostupný." ) INVALID_QUERY_DETAIL = "Neplatný vyhľadávací dotaz." INTERNAL_ERROR_DETAIL = "Nastala interná chyba servera." 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 = DEFAULT_PUBLISHED_ONLY max_per_document: int = Field( default=1, ge=0, le=10, ) class RagRequest(StrictRequestModel): include_diagnostics: bool = False 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=DEFAULT_PUBLISHED_ONLY, description=( "Ak je true, použijú sa iba " "dokumenty okrem explicitného published=false; chýbajúca hodnota sa zahrnie." ), ) 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 stiahne očakávanú vetvu " "a aktualizuje ju iba fast-forward." ), ) class GraphRequest(StrictRequestModel): query: str = Field(min_length=1, max_length=500) limit: int = Field(default=DEFAULT_LIMIT, ge=1, le=100) max_entities: int = Field(default=DEFAULT_MAX_ENTITIES, ge=1, le=20) published_only: bool = DEFAULT_PUBLISHED_ONLY class AnswerRequest(StrictRequestModel): query: str = Field(min_length=1, max_length=500) engine: Literal["rag", "graphrag"] = "rag" limit: int = Field(default=5, ge=1, le=20) include_diagnostics: bool = False context_token: str | None = Field(default=None,min_length=1,max_length=16000) @router.post("/answer", dependencies=[Depends(require_search_api_key)], include_in_schema=False) def verified_answer(request: AnswerRequest) -> dict: try: result = deliver_answer( request.query, engine=request.engine, limit=request.limit, include_diagnostics=request.include_diagnostics, **({'context_token':request.context_token} if request.context_token else {}), ) except Exception: logger.exception("Príprava overenej odpovede zlyhala.") return { "status": "error", "reason": "answer_unavailable", "accepted_for_delivery": False, "answer": "Overenú odpoveď sa nepodarilo pripraviť. Skúste to neskôr.", } if result.get("accepted_for_delivery") is not True or result.get("status") not in { "ok", "no_answer", "ambiguous", }: result = public_failure(result) result = {**result, "query": request.query.strip(), "engine": request.engine} if not request.include_diagnostics: result.pop("attempts", None) result.pop("retrieval_context", None) return result class HealthResponse(BaseModel): status: Literal["ok"] ready: bool database_exists: bool search_engine: str rag_enabled: bool zpwiki_exists: bool security_configured: bool index_version: dict[str, Any] | None = None tools: dict[str, Any] capabilities: dict[str, Any] answer_ready: dict[str, bool] class RagSource(RetrievalSource): 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(RetrievalResponse): 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] index_version: dict[str, Any] | None = None context_budget: dict[str, Any] | None = None diagnostics: dict[str, Any] | None = None resolved_query: str | None = None name_resolution: dict[str, Any] | None = None clarification: str | None = None class SearchResponse(RetrievalResponse): query: str engine: str strategies: list[str] count: int = Field( ge=0, ) results: list[dict[str, Any]] index_version: dict[str, Any] | None = None class SyncAcceptedResponse(BaseModel): status: Literal["accepted"] job_id: str status_url: str pull_git: bool coalesced: bool duplicate: bool event: str | None = None repository: str | None = None verified_by: Literal["hmac_sha256"] | None = None class WebhookIgnoredResponse(BaseModel): status: Literal["ignored"] reason: Literal["unsupported_event", "unexpected_branch", "deleted_branch"] event: str def enqueue_sync(request: Request, **kwargs) -> dict: try: accepted = request.app.state.sync_jobs.enqueue(**kwargs) except (sqlite3.Error, OSError): logger.exception("Prijatie synchronizácie do fronty zlyhalo.") raise HTTPException(status_code=503, detail="Synchronizačná fronta nie je dostupná") job = accepted["job"] return { "status": "accepted", "job_id": job["id"], "status_url": f"/sync/jobs/{job['id']}", "pull_git": job["pull_git"], "coalesced": accepted["coalesced"], "duplicate": accepted["duplicate"], } def raise_retrieval_error( error: Exception, ) -> NoReturn: if isinstance( error, ValueError, ): logger.warning( "Neplatný retrieval dotaz: %s", error, ) raise HTTPException( status_code=(status.HTTP_400_BAD_REQUEST), detail=(INVALID_QUERY_DETAIL), ) from error if error_reason(error) == "retrieval_unavailable": 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 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 async def read_webhook_body( request: Request, ) -> bytes: content_length = request.headers.get("content-length") if content_length: try: declared_length = int(content_length) except ValueError as error: raise HTTPException( status_code=400, detail=("Neplatná Content-Length " "hlavička"), ) from error if declared_length < 0: raise HTTPException( status_code=400, detail=("Neplatná Content-Length " "hlavička"), ) if declared_length > MAX_WEBHOOK_BODY_BYTES: raise HTTPException( status_code=413, detail=("Webhook payload je " "príliš veľký"), ) chunks: list[bytes] = [] total_size = 0 async for chunk in request.stream(): total_size += len(chunk) if total_size > MAX_WEBHOOK_BODY_BYTES: raise HTTPException( status_code=413, detail=("Webhook payload je " "príliš veľký"), ) chunks.append(chunk) return b"".join(chunks) @router.get( "/health", include_in_schema=False, response_model=HealthResponse, ) def health(request: Request) -> 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 tools, version = tools_readiness( DB_FILE, security_configured=security_configured, embedding_ready=getattr(request.app.state, "embedding_ready", False), ) ready = tools["rag"]["ready"] and tools["graphrag"]["ready"] from tools.runtime.answer_health import answer_capabilities capabilities = answer_capabilities(DB_FILE, tools, version) content = capabilities["knowledge"] generation = capabilities["generation"]["ready"] answer_ready = { "rag": tools["rag"]["ready"] and content["ready"] and generation, "graphrag": tools["graphrag"]["ready"] and content["ready"] and content.get("graph", {}).get("ready", False) and generation, } return { "status": "ok", "ready": ready, "index_version": version, "tools": tools, "capabilities": capabilities, "answer_ready": answer_ready, "database_exists": (database_exists), "search_engine": ("hybrid_fts5_embeddings"), "rag_enabled": True, "zpwiki_exists": (zpwiki_exists), "security_configured": (security_configured), } @router.post("/health/generation", include_in_schema=False, dependencies=[Depends(require_search_api_key)]) def probe_answer_generation(): """Explicit authenticated model probe; GET /health never incurs model calls.""" from tools.answers.model_client import probe_generation return probe_generation() @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: { "model": RetrievalErrorResponse, "description": ("Neplatný dotaz."), }, 401: { "model": RetrievalErrorResponse, "description": ("Chýbajúci alebo neplatný " "API kľúč."), }, 422: { "model": RetrievalErrorResponse, "description": ("Neplatná štruktúra requestu."), }, 500: { "model": RetrievalErrorResponse, "description": ("Interná chyba servera."), }, 503: { "model": RetrievalErrorResponse, "description": ("Retrieval nie je momentálne " "dostupný."), }, }, dependencies=[Depends(require_search_api_key)], ) def rag( request: RagRequest, http_request: Request, ) -> dict[str, Any]: try: result = build_rag_context( DB_FILE, request.query, limit=request.limit, published_only=(request.published_only), max_per_document=(request.max_per_document), ) http_request.app.state.embedding_ready = True return compact_rag_payload(result, include_diagnostics=request.include_diagnostics) except Exception as error: raise_retrieval_error(error) @router.post("/graphrag", dependencies=[Depends(require_search_api_key)], include_in_schema=False) def graph_retrieval(request: GraphRequest) -> dict[str, Any]: """Native GraphRAG Tool transport; the legacy OpenAPI schema stays stable.""" try: return search_graph( request.query, limit=request.limit, max_entities=request.max_entities, published_only=request.published_only, ) 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, http_request: Request, ) -> 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), ) http_request.app.state.embedding_ready = True except Exception as error: raise_retrieval_error(error) results = response["results"] return { **{key: response[key] for key in RetrievalResponse.model_fields if key in response}, "query": request.query, "engine": response["engine"], "strategies": response["strategies"], "count": len(results), "results": results, **({"index_version": response["index_version"]} if response.get("index_version") else {}), } @router.post( "/sync", dependencies=[Depends(require_sync_api_key)], include_in_schema=False, response_model=SyncAcceptedResponse, status_code=202, ) def sync( request: SyncRequest, http_request: Request, ) -> dict[str, Any]: return enqueue_sync(http_request, pull_git=request.pull_git) @router.get("/sync/status", dependencies=[Depends(require_sync_api_key)], include_in_schema=False) def sync_status(request: Request) -> dict: return request.app.state.sync_jobs.overview() @router.get( "/sync/jobs/{job_id}", dependencies=[Depends(require_sync_api_key)], include_in_schema=False ) def sync_job(job_id: str, request: Request) -> dict: job = request.app.state.sync_jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail="Synchronizačná úloha neexistuje") return job @router.post( "/webhook/gitea", status_code=202, response_model=(SyncAcceptedResponse | 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: secret = webhook_secret() raw_body = await read_webhook_body(request) # Gitea podpis overujeme nad presným raw # request body ešte pred JSON parsingom. 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"), ) ref = payload.get("ref") if not isinstance(ref, str) or not ref.startswith("refs/heads/"): raise HTTPException(status_code=400, detail="Push payload musí obsahovať ref vetvy") if ref != f"refs/heads/{expected_branch()}": return {"status": "ignored", "reason": "unexpected_branch", "event": x_gitea_event} revision = payload.get("after") if payload.get("deleted") is True or ( isinstance(revision, str) and revision and set(revision) == {"0"} ): return {"status": "ignored", "reason": "deleted_branch", "event": x_gitea_event} if revision is not None and ( not isinstance(revision, str) or not re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", revision) ): raise HTTPException(status_code=400, detail="Neplatná Git revízia v push payload") delivery = request.headers.get("X-Gitea-Delivery") if delivery is not None and ( not delivery.strip() or len(delivery) > 200 or any(ord(c) < 32 for c in delivery) ): raise HTTPException(status_code=400, detail="Neplatné ID webhook doručenia") return { **enqueue_sync( request, pull_git=webhook_should_pull_git(), requested_revision=revision, delivery_key=f"{repository_name.casefold()}:{delivery}" if delivery else None, ), "event": x_gitea_event, "repository": repository_name, "verified_by": "hmac_sha256", }