dp-zp-agent/app/routes.py

843 lines
19 KiB
Python

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.rag.search_utils import search_database
from tools.wiki_git import expected_branch
from tools.publication import DEFAULT_PUBLISHED_ONLY
from tools.tool_health import tools_readiness
from tools.retrieval_contract import RetrievalResponse, RetrievalSource, RetrievalErrorResponse
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 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]
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
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 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 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']
return {
"status": "ok",
"ready": ready,
"index_version": version,
"tools": tools,
"database_exists": (
database_exists
),
"search_engine": (
"hybrid_fts5_embeddings"
),
"rag_enabled": True,
"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: {
"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(
"/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',
}