821 lines
15 KiB
Python
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"
|
|
],
|
|
}
|