dp-zp-agent/app/routes.py
2026-08-14 23:27:11 +02:00

821 lines
15 KiB
Python

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