diff --git a/app/main.py b/app/main.py index 38e23ae..53bf975 100644 --- a/app/main.py +++ b/app/main.py @@ -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,13 +38,30 @@ OPENWEBUI_ORIGIN = ( async def lifespan( _: FastAPI, ): + # Bez správne nakonfigurovanej security + # aplikáciu nespustíme. validate_security_configuration() - # Predhriatie embedding modelu. - await asyncio.to_thread( - embed_query, - "warmup", - ) + # 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 ) - diff --git a/app/routes.py b/app/routes.py index 989d8e4..b06a944 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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( - 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 + except Exception as error: + raise_retrieval_error( + error + ) @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( - 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 + except Exception as error: + raise_retrieval_error( + 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( - error - ), - ) from error - - except RuntimeError as error: - raise HTTPException( - status_code=500, - detail=str( - error - ), - ) from error + except Exception as error: + raise_reindex_error( + 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( - error - ), - ) from error - - except RuntimeError as error: - raise HTTPException( - status_code=500, - detail=str( - error - ), - ) from error + except Exception as error: + raise_reindex_error( + error + ) return { "status": "ok", @@ -506,4 +818,3 @@ async def gitea_webhook( "counts" ], } - diff --git a/test/test_api.py b/test/test_api.py index bf1a3da..2a7cf6c 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -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 + ) diff --git a/test/test_rag.py b/test/test_rag.py index 4c5597c..9807e35 100644 --- a/test/test_rag.py +++ b/test/test_rag.py @@ -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" + ] + )