upravy main/fast api

This commit is contained in:
Ján Pták 2026-08-14 23:27:11 +02:00
parent 54c9101077
commit 7beeecef0f
4 changed files with 1199 additions and 160 deletions

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@ -18,38 +19,16 @@ if str(PROJECT_ROOT) not in sys.path:
)
from app.routes import (
RagRequest,
SearchRequest,
SyncRequest,
gitea_webhook,
health,
rag,
router,
search,
sync,
)
from app.routes import router
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,
verify_gitea_signature,
webhook_should_pull_git,
)
from scripts.embedding_utils import embed_query
logger = logging.getLogger(__name__)
OPENWEBUI_ORIGIN = (
"https://ui.tukekemt.xyz"
)
@ -59,14 +38,31 @@ OPENWEBUI_ORIGIN = (
async def lifespan(
_: FastAPI,
):
# Bez správne nakonfigurovanej security
# aplikáciu nespustíme.
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(
embed_query,
"warmup",
)
except Exception:
logger.exception(
"Predhriatie embedding modelu "
"zlyhalo. API pokračuje bez "
"úspešného warm-upu."
)
yield
@ -78,7 +74,7 @@ app = FastAPI(
"z repozitára záverečných prác "
"ZP Wiki."
),
version="0.8.0",
version="0.9.0",
lifespan=lifespan,
)
@ -102,4 +98,3 @@ app.add_middleware(
app.include_router(
router
)

View File

@ -2,8 +2,13 @@ from __future__ import annotations
import asyncio
import json
import os
from typing import Any
import logging
import sqlite3
from typing import (
Any,
Literal,
NoReturn,
)
from fastapi import (
APIRouter,
@ -14,7 +19,11 @@ from fastapi import (
status,
)
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from pydantic import (
BaseModel,
ConfigDict,
Field,
)
from app.security import (
expected_gitea_repository,
@ -23,6 +32,7 @@ from app.security import (
require_sync_api_key,
same_repository,
validate_secret,
validate_security_configuration,
verify_gitea_signature,
webhook_should_pull_git,
)
@ -38,21 +48,54 @@ from scripts.rebuild_index import (
from scripts.search_utils import search_database
logger = logging.getLogger(__name__)
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(
...,
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,
@ -60,7 +103,9 @@ class SearchRequest(BaseModel):
)
class RagRequest(BaseModel):
class RagRequest(
StrictRequestModel
):
query: str = Field(
...,
min_length=1,
@ -101,7 +146,9 @@ class RagRequest(BaseModel):
)
class SyncRequest(BaseModel):
class SyncRequest(
StrictRequestModel
):
pull_git: bool = Field(
default=False,
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(
"/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": (
DB_FILE.exists()
database_exists
),
"database_path": str(
DB_FILE
@ -132,21 +464,10 @@ def health() -> dict[str, Any]:
ZPWIKI_ROOT
),
"zpwiki_exists": (
ZPWIKI_ROOT.exists()
zpwiki_exists
),
"security_configured": all(
bool(
os.getenv(
name,
"",
).strip()
)
for name in (
"WEBHOOK_SECRET",
"SYNC_API_KEY",
"SEARCH_API_KEY",
"EXPECTED_GITEA_REPOSITORY",
)
"security_configured": (
security_configured
),
}
@ -168,6 +489,40 @@ def health() -> dict[str, Any]:
"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
@ -178,7 +533,7 @@ def rag(
request: RagRequest,
) -> dict[str, Any]:
try:
response = build_rag_context(
return build_rag_context(
DB_FILE,
request.query,
limit=request.limit,
@ -190,31 +545,10 @@ def rag(
),
)
except FileNotFoundError as error:
raise HTTPException(
status_code=500,
detail=str(
except Exception as error:
raise_retrieval_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(
@ -225,6 +559,7 @@ def rag(
)
],
include_in_schema=False,
response_model=SearchResponse,
)
def search(
request: SearchRequest,
@ -242,29 +577,10 @@ def search(
),
)
except FileNotFoundError as error:
raise HTTPException(
status_code=500,
detail=str(
except Exception as error:
raise_retrieval_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"
@ -293,6 +609,7 @@ def search(
)
],
include_in_schema=False,
response_model=SyncResponse,
)
def sync(
request: SyncRequest,
@ -304,21 +621,10 @@ def sync(
)
)
except ReindexInProgressError as error:
raise HTTPException(
status_code=409,
detail=str(
except Exception as error:
raise_reindex_error(
error
),
) from error
except RuntimeError as error:
raise HTTPException(
status_code=500,
detail=str(
error
),
) from error
)
return {
"status": "ok",
@ -338,7 +644,11 @@ def sync(
@router.post(
"/webhook/gitea",
response_model=None,
response_model=(
WebhookSuccessResponse
| WebhookIgnoredResponse
),
response_model_exclude_unset=True,
include_in_schema=False,
)
async def gitea_webhook(
@ -351,12 +661,13 @@ async def gitea_webhook(
default=None,
alias="X-Gitea-Signature",
),
) -> dict[str, Any] | JSONResponse:
) -> (
dict[str, Any]
| JSONResponse
):
raw_body = await request.body()
secret = validate_secret(
"WEBHOOK_SECRET"
)
secret = webhook_secret()
if not verify_gitea_signature(
raw_body,
@ -384,7 +695,9 @@ async def gitea_webhook(
json.JSONDecodeError,
) as error:
raise HTTPException(
status_code=400,
status_code=(
status.HTTP_400_BAD_REQUEST
),
detail=(
"Webhook payload nie je "
"platný JSON"
@ -396,7 +709,9 @@ async def gitea_webhook(
dict,
):
raise HTTPException(
status_code=400,
status_code=(
status.HTTP_400_BAD_REQUEST
),
detail=(
"Webhook payload musí byť "
"JSON objekt"
@ -405,7 +720,9 @@ async def gitea_webhook(
if not x_gitea_event:
raise HTTPException(
status_code=400,
status_code=(
status.HTTP_400_BAD_REQUEST
),
detail=(
"Chýba hlavička "
"X-Gitea-Event"
@ -421,7 +738,9 @@ async def gitea_webhook(
status.HTTP_202_ACCEPTED
),
content={
"status": "ignored",
"status": (
"ignored"
),
"reason": (
"unsupported_event"
),
@ -439,7 +758,9 @@ async def gitea_webhook(
if repository_name is None:
raise HTTPException(
status_code=400,
status_code=(
status.HTTP_400_BAD_REQUEST
),
detail=(
"Webhook payload neobsahuje "
"repository.full_name"
@ -447,7 +768,7 @@ async def gitea_webhook(
)
expected_repository = (
expected_gitea_repository()
configured_repository()
)
if not same_repository(
@ -455,7 +776,9 @@ async def gitea_webhook(
expected_repository,
):
raise HTTPException(
status_code=403,
status_code=(
status.HTTP_403_FORBIDDEN
),
detail=(
"Webhook patrí neočakávanému "
"repozitáru"
@ -470,21 +793,10 @@ async def gitea_webhook(
),
)
except ReindexInProgressError as error:
raise HTTPException(
status_code=409,
detail=str(
except Exception as error:
raise_reindex_error(
error
),
) from error
except RuntimeError as error:
raise HTTPException(
status_code=500,
detail=str(
error
),
) from error
)
return {
"status": "ok",
@ -506,4 +818,3 @@ async def gitea_webhook(
"counts"
],
}

View File

@ -87,6 +87,36 @@ def test_startup_rejects_short_secret(
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(
client: TestClient,
) -> None:
@ -106,6 +136,11 @@ def test_health_endpoint(
== "ok"
)
assert isinstance(
payload["ready"],
bool,
)
assert (
payload["search_engine"]
== "hybrid_fts5_embeddings"
@ -162,17 +197,15 @@ def test_search_endpoint_uses_shared_search_logic(
== 200
)
payload = response.json()
assert (
response.json()[
"count"
]
payload["count"]
== 1
)
assert (
response.json()[
"results"
][0][
payload["results"][0][
"chunk_id"
]
== "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(
client: TestClient,
) -> 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(
client: TestClient,
) -> 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(
client: TestClient,
) -> None:
@ -608,15 +861,17 @@ def test_webhook_accepts_signed_push(
== 200
)
payload = response.json()
assert (
response.json()[
payload[
"verified_by"
]
== "hmac_sha256"
)
assert (
response.json()[
payload[
"repository"
]
== "KEMT/zpwiki"
@ -677,3 +932,68 @@ def test_webhook_returns_conflict_when_reindex_is_running(
response.status_code
== 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
)

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import os
from pathlib import Path
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 (
"citation"
not in source
@ -522,9 +520,31 @@ def test_rag_endpoint(
== 200
)
payload = response.json()
assert (
response.json()
== expected
payload["query"]
== "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(
client: TestClient,
) -> 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(
client: TestClient,
) -> None:
@ -635,7 +920,9 @@ def test_openapi_exposes_rag_only(
== 200
)
paths = response.json()[
schema = response.json()
paths = schema[
"paths"
]
@ -663,3 +950,129 @@ def test_openapi_exposes_rag_only(
"/webhook/gitea"
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"
]
)