upravy main/fast api
This commit is contained in:
parent
54c9101077
commit
7beeecef0f
53
app/main.py
53
app/main.py
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -18,38 +19,16 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from app.routes import (
|
from app.routes import router
|
||||||
RagRequest,
|
|
||||||
SearchRequest,
|
|
||||||
SyncRequest,
|
|
||||||
gitea_webhook,
|
|
||||||
health,
|
|
||||||
rag,
|
|
||||||
router,
|
|
||||||
search,
|
|
||||||
sync,
|
|
||||||
)
|
|
||||||
from app.security import (
|
from app.security import (
|
||||||
MIN_SECRET_LENGTH,
|
|
||||||
SEARCH_API_KEY_HEADER,
|
|
||||||
SYNC_API_KEY_HEADER,
|
|
||||||
expected_gitea_repository,
|
|
||||||
repository_name_from_payload,
|
|
||||||
require_search_api_key,
|
|
||||||
require_sync_api_key,
|
|
||||||
required_environment_value,
|
|
||||||
same_repository,
|
|
||||||
search_api_key_scheme,
|
|
||||||
search_bearer_scheme,
|
|
||||||
sync_api_key_scheme,
|
|
||||||
validate_secret,
|
|
||||||
validate_security_configuration,
|
validate_security_configuration,
|
||||||
verify_gitea_signature,
|
|
||||||
webhook_should_pull_git,
|
|
||||||
)
|
)
|
||||||
from scripts.embedding_utils import embed_query
|
from scripts.embedding_utils import embed_query
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
OPENWEBUI_ORIGIN = (
|
OPENWEBUI_ORIGIN = (
|
||||||
"https://ui.tukekemt.xyz"
|
"https://ui.tukekemt.xyz"
|
||||||
)
|
)
|
||||||
@ -59,14 +38,31 @@ OPENWEBUI_ORIGIN = (
|
|||||||
async def lifespan(
|
async def lifespan(
|
||||||
_: FastAPI,
|
_: FastAPI,
|
||||||
):
|
):
|
||||||
|
# Bez správne nakonfigurovanej security
|
||||||
|
# aplikáciu nespustíme.
|
||||||
validate_security_configuration()
|
validate_security_configuration()
|
||||||
|
|
||||||
# Predhriatie embedding modelu.
|
# Predhriatie embedding modelu je
|
||||||
|
# optimalizácia, nie podmienka samotného
|
||||||
|
# spustenia HTTP API.
|
||||||
|
#
|
||||||
|
# Ak warm-up zlyhá, API zostane dostupné.
|
||||||
|
# Retrieval endpoint následne vráti
|
||||||
|
# kontrolovanú chybu 503, ak embeddingový
|
||||||
|
# model skutočne nebude dostupný.
|
||||||
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
embed_query,
|
embed_query,
|
||||||
"warmup",
|
"warmup",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Predhriatie embedding modelu "
|
||||||
|
"zlyhalo. API pokračuje bez "
|
||||||
|
"úspešného warm-upu."
|
||||||
|
)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@ -78,7 +74,7 @@ app = FastAPI(
|
|||||||
"z repozitára záverečných prác "
|
"z repozitára záverečných prác "
|
||||||
"ZP Wiki."
|
"ZP Wiki."
|
||||||
),
|
),
|
||||||
version="0.8.0",
|
version="0.9.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -102,4 +98,3 @@ app.add_middleware(
|
|||||||
app.include_router(
|
app.include_router(
|
||||||
router
|
router
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
529
app/routes.py
529
app/routes.py
@ -2,8 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import logging
|
||||||
from typing import Any
|
import sqlite3
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Literal,
|
||||||
|
NoReturn,
|
||||||
|
)
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
@ -14,7 +19,11 @@ from fastapi import (
|
|||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
)
|
||||||
|
|
||||||
from app.security import (
|
from app.security import (
|
||||||
expected_gitea_repository,
|
expected_gitea_repository,
|
||||||
@ -23,6 +32,7 @@ from app.security import (
|
|||||||
require_sync_api_key,
|
require_sync_api_key,
|
||||||
same_repository,
|
same_repository,
|
||||||
validate_secret,
|
validate_secret,
|
||||||
|
validate_security_configuration,
|
||||||
verify_gitea_signature,
|
verify_gitea_signature,
|
||||||
webhook_should_pull_git,
|
webhook_should_pull_git,
|
||||||
)
|
)
|
||||||
@ -38,21 +48,54 @@ from scripts.rebuild_index import (
|
|||||||
from scripts.search_utils import search_database
|
from scripts.search_utils import search_database
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
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(
|
query: str = Field(
|
||||||
...,
|
...,
|
||||||
min_length=1,
|
min_length=1,
|
||||||
max_length=500,
|
max_length=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
limit: int = Field(
|
limit: int = Field(
|
||||||
default=10,
|
default=10,
|
||||||
ge=1,
|
ge=1,
|
||||||
le=50,
|
le=50,
|
||||||
)
|
)
|
||||||
|
|
||||||
published_only: bool = False
|
published_only: bool = False
|
||||||
|
|
||||||
max_per_document: int = Field(
|
max_per_document: int = Field(
|
||||||
default=1,
|
default=1,
|
||||||
ge=0,
|
ge=0,
|
||||||
@ -60,7 +103,9 @@ class SearchRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RagRequest(BaseModel):
|
class RagRequest(
|
||||||
|
StrictRequestModel
|
||||||
|
):
|
||||||
query: str = Field(
|
query: str = Field(
|
||||||
...,
|
...,
|
||||||
min_length=1,
|
min_length=1,
|
||||||
@ -101,7 +146,9 @@ class RagRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SyncRequest(BaseModel):
|
class SyncRequest(
|
||||||
|
StrictRequestModel
|
||||||
|
):
|
||||||
pull_git: bool = Field(
|
pull_git: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
@ -111,15 +158,300 @@ class SyncRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
@router.get(
|
||||||
"/health",
|
"/health",
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
|
response_model=HealthResponse,
|
||||||
)
|
)
|
||||||
def health() -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
"ready": ready,
|
||||||
"database_exists": (
|
"database_exists": (
|
||||||
DB_FILE.exists()
|
database_exists
|
||||||
),
|
),
|
||||||
"database_path": str(
|
"database_path": str(
|
||||||
DB_FILE
|
DB_FILE
|
||||||
@ -132,21 +464,10 @@ def health() -> dict[str, Any]:
|
|||||||
ZPWIKI_ROOT
|
ZPWIKI_ROOT
|
||||||
),
|
),
|
||||||
"zpwiki_exists": (
|
"zpwiki_exists": (
|
||||||
ZPWIKI_ROOT.exists()
|
zpwiki_exists
|
||||||
),
|
),
|
||||||
"security_configured": all(
|
"security_configured": (
|
||||||
bool(
|
security_configured
|
||||||
os.getenv(
|
|
||||||
name,
|
|
||||||
"",
|
|
||||||
).strip()
|
|
||||||
)
|
|
||||||
for name in (
|
|
||||||
"WEBHOOK_SECRET",
|
|
||||||
"SYNC_API_KEY",
|
|
||||||
"SEARCH_API_KEY",
|
|
||||||
"EXPECTED_GITEA_REPOSITORY",
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,6 +489,40 @@ def health() -> dict[str, Any]:
|
|||||||
"embeddingové vyhľadávanie a pripraví "
|
"embeddingové vyhľadávanie a pripraví "
|
||||||
"zdrojovo podložený RAG kontext."
|
"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=[
|
dependencies=[
|
||||||
Depends(
|
Depends(
|
||||||
require_search_api_key
|
require_search_api_key
|
||||||
@ -178,7 +533,7 @@ def rag(
|
|||||||
request: RagRequest,
|
request: RagRequest,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
response = build_rag_context(
|
return build_rag_context(
|
||||||
DB_FILE,
|
DB_FILE,
|
||||||
request.query,
|
request.query,
|
||||||
limit=request.limit,
|
limit=request.limit,
|
||||||
@ -190,31 +545,10 @@ def rag(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
except FileNotFoundError as error:
|
except Exception as error:
|
||||||
raise HTTPException(
|
raise_retrieval_error(
|
||||||
status_code=500,
|
|
||||||
detail=str(
|
|
||||||
error
|
error
|
||||||
),
|
)
|
||||||
) from error
|
|
||||||
|
|
||||||
except ValueError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=str(
|
|
||||||
error
|
|
||||||
),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
except RuntimeError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=str(
|
|
||||||
error
|
|
||||||
),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@ -225,6 +559,7 @@ def rag(
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
|
response_model=SearchResponse,
|
||||||
)
|
)
|
||||||
def search(
|
def search(
|
||||||
request: SearchRequest,
|
request: SearchRequest,
|
||||||
@ -242,29 +577,10 @@ def search(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
except FileNotFoundError as error:
|
except Exception as error:
|
||||||
raise HTTPException(
|
raise_retrieval_error(
|
||||||
status_code=500,
|
|
||||||
detail=str(
|
|
||||||
error
|
error
|
||||||
),
|
)
|
||||||
) from error
|
|
||||||
|
|
||||||
except ValueError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=str(
|
|
||||||
error
|
|
||||||
),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
except RuntimeError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=str(
|
|
||||||
error
|
|
||||||
),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
results = response[
|
results = response[
|
||||||
"results"
|
"results"
|
||||||
@ -293,6 +609,7 @@ def search(
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
|
response_model=SyncResponse,
|
||||||
)
|
)
|
||||||
def sync(
|
def sync(
|
||||||
request: SyncRequest,
|
request: SyncRequest,
|
||||||
@ -304,21 +621,10 @@ def sync(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
except ReindexInProgressError as error:
|
except Exception as error:
|
||||||
raise HTTPException(
|
raise_reindex_error(
|
||||||
status_code=409,
|
|
||||||
detail=str(
|
|
||||||
error
|
error
|
||||||
),
|
)
|
||||||
) from error
|
|
||||||
|
|
||||||
except RuntimeError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=str(
|
|
||||||
error
|
|
||||||
),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@ -338,7 +644,11 @@ def sync(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/webhook/gitea",
|
"/webhook/gitea",
|
||||||
response_model=None,
|
response_model=(
|
||||||
|
WebhookSuccessResponse
|
||||||
|
| WebhookIgnoredResponse
|
||||||
|
),
|
||||||
|
response_model_exclude_unset=True,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def gitea_webhook(
|
async def gitea_webhook(
|
||||||
@ -351,12 +661,13 @@ async def gitea_webhook(
|
|||||||
default=None,
|
default=None,
|
||||||
alias="X-Gitea-Signature",
|
alias="X-Gitea-Signature",
|
||||||
),
|
),
|
||||||
) -> dict[str, Any] | JSONResponse:
|
) -> (
|
||||||
|
dict[str, Any]
|
||||||
|
| JSONResponse
|
||||||
|
):
|
||||||
raw_body = await request.body()
|
raw_body = await request.body()
|
||||||
|
|
||||||
secret = validate_secret(
|
secret = webhook_secret()
|
||||||
"WEBHOOK_SECRET"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not verify_gitea_signature(
|
if not verify_gitea_signature(
|
||||||
raw_body,
|
raw_body,
|
||||||
@ -384,7 +695,9 @@ async def gitea_webhook(
|
|||||||
json.JSONDecodeError,
|
json.JSONDecodeError,
|
||||||
) as error:
|
) as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=(
|
||||||
|
status.HTTP_400_BAD_REQUEST
|
||||||
|
),
|
||||||
detail=(
|
detail=(
|
||||||
"Webhook payload nie je "
|
"Webhook payload nie je "
|
||||||
"platný JSON"
|
"platný JSON"
|
||||||
@ -396,7 +709,9 @@ async def gitea_webhook(
|
|||||||
dict,
|
dict,
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=(
|
||||||
|
status.HTTP_400_BAD_REQUEST
|
||||||
|
),
|
||||||
detail=(
|
detail=(
|
||||||
"Webhook payload musí byť "
|
"Webhook payload musí byť "
|
||||||
"JSON objekt"
|
"JSON objekt"
|
||||||
@ -405,7 +720,9 @@ async def gitea_webhook(
|
|||||||
|
|
||||||
if not x_gitea_event:
|
if not x_gitea_event:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=(
|
||||||
|
status.HTTP_400_BAD_REQUEST
|
||||||
|
),
|
||||||
detail=(
|
detail=(
|
||||||
"Chýba hlavička "
|
"Chýba hlavička "
|
||||||
"X-Gitea-Event"
|
"X-Gitea-Event"
|
||||||
@ -421,7 +738,9 @@ async def gitea_webhook(
|
|||||||
status.HTTP_202_ACCEPTED
|
status.HTTP_202_ACCEPTED
|
||||||
),
|
),
|
||||||
content={
|
content={
|
||||||
"status": "ignored",
|
"status": (
|
||||||
|
"ignored"
|
||||||
|
),
|
||||||
"reason": (
|
"reason": (
|
||||||
"unsupported_event"
|
"unsupported_event"
|
||||||
),
|
),
|
||||||
@ -439,7 +758,9 @@ async def gitea_webhook(
|
|||||||
|
|
||||||
if repository_name is None:
|
if repository_name is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=(
|
||||||
|
status.HTTP_400_BAD_REQUEST
|
||||||
|
),
|
||||||
detail=(
|
detail=(
|
||||||
"Webhook payload neobsahuje "
|
"Webhook payload neobsahuje "
|
||||||
"repository.full_name"
|
"repository.full_name"
|
||||||
@ -447,7 +768,7 @@ async def gitea_webhook(
|
|||||||
)
|
)
|
||||||
|
|
||||||
expected_repository = (
|
expected_repository = (
|
||||||
expected_gitea_repository()
|
configured_repository()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not same_repository(
|
if not same_repository(
|
||||||
@ -455,7 +776,9 @@ async def gitea_webhook(
|
|||||||
expected_repository,
|
expected_repository,
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=(
|
||||||
|
status.HTTP_403_FORBIDDEN
|
||||||
|
),
|
||||||
detail=(
|
detail=(
|
||||||
"Webhook patrí neočakávanému "
|
"Webhook patrí neočakávanému "
|
||||||
"repozitáru"
|
"repozitáru"
|
||||||
@ -470,21 +793,10 @@ async def gitea_webhook(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
except ReindexInProgressError as error:
|
except Exception as error:
|
||||||
raise HTTPException(
|
raise_reindex_error(
|
||||||
status_code=409,
|
|
||||||
detail=str(
|
|
||||||
error
|
error
|
||||||
),
|
)
|
||||||
) from error
|
|
||||||
|
|
||||||
except RuntimeError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=str(
|
|
||||||
error
|
|
||||||
),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@ -506,4 +818,3 @@ async def gitea_webhook(
|
|||||||
"counts"
|
"counts"
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
336
test/test_api.py
336
test/test_api.py
@ -87,6 +87,36 @@ def test_startup_rejects_short_secret(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_survives_embedding_warmup_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def broken_embedding(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"embedding warmup failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"embed_query",
|
||||||
|
broken_embedding,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(
|
||||||
|
main.app
|
||||||
|
) as test_client:
|
||||||
|
response = test_client.get(
|
||||||
|
"/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_health_endpoint(
|
def test_health_endpoint(
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -106,6 +136,11 @@ def test_health_endpoint(
|
|||||||
== "ok"
|
== "ok"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(
|
||||||
|
payload["ready"],
|
||||||
|
bool,
|
||||||
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
payload["search_engine"]
|
payload["search_engine"]
|
||||||
== "hybrid_fts5_embeddings"
|
== "hybrid_fts5_embeddings"
|
||||||
@ -162,17 +197,15 @@ def test_search_endpoint_uses_shared_search_logic(
|
|||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
response.json()[
|
payload["count"]
|
||||||
"count"
|
|
||||||
]
|
|
||||||
== 1
|
== 1
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
response.json()[
|
payload["results"][0][
|
||||||
"results"
|
|
||||||
][0][
|
|
||||||
"chunk_id"
|
"chunk_id"
|
||||||
]
|
]
|
||||||
== "test::0"
|
== "test::0"
|
||||||
@ -200,6 +233,49 @@ def test_search_rejects_empty_query(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_rejects_whitespace_query(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/search",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": " ",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 422
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_rejects_unknown_field(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/search",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "Ján Holp",
|
||||||
|
"unknown_field": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 422
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_search_rejects_missing_api_key(
|
def test_search_rejects_missing_api_key(
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -239,6 +315,136 @@ def test_search_rejects_wrong_api_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_400_for_invalid_search(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def invalid_search(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Neplatný vyhľadávací dotaz"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"search_database",
|
||||||
|
invalid_search,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/search",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_503_when_index_is_missing(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def missing_index(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"/private/path/zp_index.sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"search_database",
|
||||||
|
missing_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/search",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 503
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()[
|
||||||
|
"detail"
|
||||||
|
] == (
|
||||||
|
routes.RETRIEVAL_UNAVAILABLE_DETAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"/private/path"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_generic_500_for_unexpected_error(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def broken_search(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"private internal error"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"search_database",
|
||||||
|
broken_search,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/search",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 500
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()[
|
||||||
|
"detail"
|
||||||
|
] == (
|
||||||
|
routes.INTERNAL_ERROR_DETAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"private internal error"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_sync_rejects_missing_api_key(
|
def test_sync_rejects_missing_api_key(
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -351,6 +557,53 @@ def test_sync_returns_conflict_when_reindex_is_running(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_returns_generic_500_when_rebuild_fails(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def failed_rebuild(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"private rebuild error"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"rebuild_index",
|
||||||
|
failed_rebuild,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/sync",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SYNC_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"pull_git": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 500
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()[
|
||||||
|
"detail"
|
||||||
|
] == (
|
||||||
|
routes.REINDEX_FAILED_DETAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"private rebuild error"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_webhook_rejects_invalid_signature(
|
def test_webhook_rejects_invalid_signature(
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -608,15 +861,17 @@ def test_webhook_accepts_signed_push(
|
|||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
response.json()[
|
payload[
|
||||||
"verified_by"
|
"verified_by"
|
||||||
]
|
]
|
||||||
== "hmac_sha256"
|
== "hmac_sha256"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
response.json()[
|
payload[
|
||||||
"repository"
|
"repository"
|
||||||
]
|
]
|
||||||
== "KEMT/zpwiki"
|
== "KEMT/zpwiki"
|
||||||
@ -677,3 +932,68 @@ def test_webhook_returns_conflict_when_reindex_is_running(
|
|||||||
response.status_code
|
response.status_code
|
||||||
== 409
|
== 409
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_returns_generic_500_when_rebuild_fails(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def failed_rebuild(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"private webhook error"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"rebuild_index",
|
||||||
|
failed_rebuild,
|
||||||
|
)
|
||||||
|
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"repository": {
|
||||||
|
"full_name": (
|
||||||
|
"KEMT/zpwiki"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/webhook/gitea",
|
||||||
|
content=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": (
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
"X-Gitea-Event": (
|
||||||
|
"push"
|
||||||
|
),
|
||||||
|
"X-Gitea-Signature": (
|
||||||
|
sign(
|
||||||
|
body
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 500
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()[
|
||||||
|
"detail"
|
||||||
|
] == (
|
||||||
|
routes.REINDEX_FAILED_DETAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"private webhook error"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|||||||
425
test/test_rag.py
425
test/test_rag.py
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -120,9 +121,6 @@ def test_build_source() -> None:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Interná identifikácia zdroja
|
|
||||||
# nemá byť používateľská
|
|
||||||
# citation hodnota.
|
|
||||||
assert (
|
assert (
|
||||||
"citation"
|
"citation"
|
||||||
not in source
|
not in source
|
||||||
@ -522,9 +520,31 @@ def test_rag_endpoint(
|
|||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
response.json()
|
payload["query"]
|
||||||
== expected
|
== "Ján Holp"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
payload["engine"]
|
||||||
|
== "hybrid_fts5_embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
payload["source_count"]
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
payload["sources"][0][
|
||||||
|
"source_url"
|
||||||
|
]
|
||||||
|
== (
|
||||||
|
"https://example.test/"
|
||||||
|
"jan_holp"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -602,6 +622,29 @@ def test_rag_endpoint_without_api_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_with_wrong_api_key(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
"x" * 64
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": (
|
||||||
|
"Ján Holp"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rag_endpoint_empty_query(
|
def test_rag_endpoint_empty_query(
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -623,6 +666,248 @@ def test_rag_endpoint_empty_query(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_whitespace_query(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": " ",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 422
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_rejects_invalid_limit(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
"limit": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 422
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_rejects_unknown_field(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
"unknown_field": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 422
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_returns_400_for_value_error(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def invalid_rag(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Neplatný dotaz"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"build_rag_context",
|
||||||
|
invalid_rag,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_returns_503_when_database_is_missing(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def missing_database(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"/private/path/zp_index.sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"build_rag_context",
|
||||||
|
missing_database,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 503
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()[
|
||||||
|
"detail"
|
||||||
|
] == (
|
||||||
|
routes.RETRIEVAL_UNAVAILABLE_DETAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"/private/path"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_returns_503_for_runtime_error(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def unavailable_rag(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"embedding model failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"build_rag_context",
|
||||||
|
unavailable_rag,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 503
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()[
|
||||||
|
"detail"
|
||||||
|
] == (
|
||||||
|
routes.RETRIEVAL_UNAVAILABLE_DETAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"embedding model failed"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_returns_generic_500_for_unexpected_error(
|
||||||
|
client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def broken_rag(
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"private internal error"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"build_rag_context",
|
||||||
|
broken_rag,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 500
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()[
|
||||||
|
"detail"
|
||||||
|
] == (
|
||||||
|
routes.INTERNAL_ERROR_DETAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"private internal error"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_exposes_rag_only(
|
def test_openapi_exposes_rag_only(
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -635,7 +920,9 @@ def test_openapi_exposes_rag_only(
|
|||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
|
|
||||||
paths = response.json()[
|
schema = response.json()
|
||||||
|
|
||||||
|
paths = schema[
|
||||||
"paths"
|
"paths"
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -663,3 +950,129 @@ def test_openapi_exposes_rag_only(
|
|||||||
"/webhook/gitea"
|
"/webhook/gitea"
|
||||||
not in paths
|
not in paths
|
||||||
)
|
)
|
||||||
|
|
||||||
|
operation = paths[
|
||||||
|
"/rag"
|
||||||
|
][
|
||||||
|
"post"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert operation[
|
||||||
|
"operationId"
|
||||||
|
] == (
|
||||||
|
"retrieve_zpwiki_context"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"requestBody"
|
||||||
|
in operation
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"responses"
|
||||||
|
in operation
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"200"
|
||||||
|
in operation[
|
||||||
|
"responses"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"401"
|
||||||
|
in operation[
|
||||||
|
"responses"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"422"
|
||||||
|
in operation[
|
||||||
|
"responses"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"503"
|
||||||
|
in operation[
|
||||||
|
"responses"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_endpoint_live_end_to_end(
|
||||||
|
security_environment,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
os.getenv(
|
||||||
|
"RUN_LIVE_RAG_E2E",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
!= "1"
|
||||||
|
):
|
||||||
|
pytest.skip(
|
||||||
|
"Live RAG E2E test je vypnutý. "
|
||||||
|
"Spusti s RUN_LIVE_RAG_E2E=1."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not routes.DB_FILE.exists():
|
||||||
|
pytest.fail(
|
||||||
|
"Live RAG E2E vyžaduje "
|
||||||
|
"existujúci SQLite index."
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(
|
||||||
|
main_module.app
|
||||||
|
) as live_client:
|
||||||
|
response = live_client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": (
|
||||||
|
"V akom roku robil "
|
||||||
|
"Ján Holp diplomovú prácu?"
|
||||||
|
),
|
||||||
|
"limit": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
payload["engine"]
|
||||||
|
== "hybrid_fts5_embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
payload["source_count"]
|
||||||
|
>= 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"2021"
|
||||||
|
in payload[
|
||||||
|
"context"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
source[
|
||||||
|
"source_url"
|
||||||
|
].endswith(
|
||||||
|
"/students/2016/jan_holp"
|
||||||
|
)
|
||||||
|
for source in payload[
|
||||||
|
"sources"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user