"""Versionierte FastAPI-Oberfläche des PV RAG Agent. Die API bleibt ein reiner Wissensdienst: Requests enthalten eine Frage, aber keinen Mandanten- oder Payroll-Datenkontext. Eine spätere Lohndaten-Anbindung benötigt einen getrennten, mandantenautorisierten Vertrag. """ from __future__ import annotations import logging import re import secrets import threading import uuid from contextlib import asynccontextmanager from typing import Literal from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, ConfigDict, Field from .config import Config from .generate import CITE_RE, answer_question from .ingest import build_index from .ollama_client import OllamaClient from .retrieve import Retriever API_VERSION = "v1" DATA_SCOPE = "knowledge_base_only" _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") logger = logging.getLogger(__name__) bearer = HTTPBearer(auto_error=False) bearer_credentials = Depends(bearer) class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid") class AskRequest(StrictModel): question: str = Field(min_length=3, max_length=2000) top_k: int | None = Field(default=None, ge=1, le=20) mode: Literal["knowledge"] = Field( default="knowledge", description="Derzeit ausschließlich KB-Wissen; kein Payroll-Datenkontext.", ) class SourceOut(StrictModel): id: str title: str section: str | None = None stand: str | None = None work: str | None = None class ConflictOut(StrictModel): summary: str source_ids: list[str] class PlannedQueryOut(StrictModel): text: str stand_year: str | None = None scope: str | None = None class GroundingOut(StrictModel): data_scope: Literal["knowledge_base_only"] = DATA_SCOPE citations_verified: bool context_count: int regenerations: int class AskResponse(StrictModel): api_version: Literal["v1"] = API_VERSION request_id: str status: Literal["answered", "refused", "uncertain"] question: str answer: str refused: bool verified: bool citations: list[str] sources: list[SourceOut] conflicts: list[ConflictOut] = Field(default_factory=list) assumptions: list[str] = Field(default_factory=list) clarification_question: str | None = None alternatives: list[str] = Field(default_factory=list) answer_type: Literal["specific", "survey"] = "specific" planned: bool = False planned_queries: list[PlannedQueryOut] = Field(default_factory=list) grounding: GroundingOut n_context: int model: str latency_ms: int regenerations: int = 0 class HealthResponse(StrictModel): api_version: Literal["v1"] = API_VERSION service: Literal["pv-rag-agent"] = "pv-rag-agent" status: Literal["ok", "degraded"] index: dict ollama_up: bool authentication_enabled: bool class AppState: def __init__(self) -> None: self.cfg: Config | None = None self.client: OllamaClient | None = None self.retriever: Retriever | None = None self.maintenance_lock = threading.Lock() self.state_lock = threading.RLock() def ensure(self) -> Config: with self.state_lock: if self.cfg is None: self.cfg = Config.from_env() return self.cfg def get_client(self) -> OllamaClient: with self.state_lock: if self.client is None: cfg = self.ensure() self.client = OllamaClient( cfg.ollama_url, embed_timeout_s=cfg.embed_timeout_s, chat_timeout_s=cfg.chat_timeout_s, ) return self.client def get_retriever(self) -> Retriever: with self.state_lock: if self.retriever is None: self.retriever = Retriever(self.ensure()) return self.retriever def reset_retriever(self) -> None: with self.state_lock: if self.retriever is not None: self.retriever.close() self.retriever = None @asynccontextmanager async def lifespan(app: FastAPI): app.state.rag = AppState() yield rag: AppState = app.state.rag rag.reset_retriever() if rag.client: rag.client.close() app = FastAPI( title="PV RAG Agent", version="1.0.0", description=( "Eigenständiger, KB-gebundener Wissensdienst. Der v1-Vertrag nimmt " "keine Mandanten- oder Mitarbeiterdaten entgegen." ), lifespan=lifespan, ) @app.middleware("http") async def add_request_id(request: Request, call_next): supplied = request.headers.get("X-Request-ID", "") request_id = supplied if _REQUEST_ID_RE.fullmatch(supplied) else uuid.uuid4().hex request.state.request_id = request_id response = await call_next(request) response.headers["X-Request-ID"] = request_id return response def _request_id(request: Request) -> str: return getattr(request.state, "request_id", uuid.uuid4().hex) def _check_bearer( expected: str, credentials: HTTPAuthorizationCredentials | None, ) -> None: # Leere Keys erhalten den bisherigen lokalen Entwicklungsmodus. Für ein # exponiertes Deployment muss PV_API_KEY gesetzt sein. if not expected: return supplied = credentials.credentials if credentials and credentials.scheme.lower() == "bearer" else "" if not supplied or not secrets.compare_digest(supplied, expected): raise HTTPException( status_code=401, detail="Authentisierung erforderlich.", headers={"WWW-Authenticate": "Bearer"}, ) def require_api_access( request: Request, credentials: HTTPAuthorizationCredentials | None = bearer_credentials, ) -> None: rag: AppState = request.app.state.rag _check_bearer(rag.ensure().api_key, credentials) def require_admin_access( request: Request, credentials: HTTPAuthorizationCredentials | None = bearer_credentials, ) -> None: rag: AppState = request.app.state.rag cfg = rag.ensure() _check_bearer(cfg.admin_api_key or cfg.api_key, credentials) def _extract_conflicts(answer: str, citations: list[str]) -> list[ConflictOut]: allowed = set(citations) conflicts: list[ConflictOut] = [] for paragraph in re.split(r"\n\s*\n", answer): text = " ".join(paragraph.split()) if "⚠" not in text: continue ids = sorted(set(CITE_RE.findall(text)) & allowed) if ids: conflicts.append(ConflictOut(summary=text, source_ids=ids)) return conflicts def _extract_clarification(answer: str, refused: bool) -> str | None: if refused: return None match = re.search(r"([^.!?\n]*\?)\s*$", answer.strip()) if not match: return None question = match.group(1).strip().lstrip("-• ") return question or None def _response_from_result(result: dict, request_id: str) -> AskResponse: if not result["verified"]: status = "uncertain" elif result["refused"]: status = "refused" else: status = "answered" citations = list(result["citations"]) return AskResponse( request_id=request_id, status=status, question=result["question"], answer=result["answer"], refused=result["refused"], verified=result["verified"], citations=citations, sources=result["sources"], conflicts=_extract_conflicts(result["answer"], citations), # Keine Annahmen oder Alternativen aus Freitext erraten. Diese Felder # sind Teil des stabilen Vertrags und werden erst befüllt, wenn die # Generierung sie selbst belegbar strukturiert liefert. assumptions=[], clarification_question=_extract_clarification( result["answer"], result["refused"] ), alternatives=[], answer_type=result.get("answer_type", "specific"), planned=result.get("planned", False), planned_queries=result.get("planned_queries", []), grounding=GroundingOut( citations_verified=result["verified"], context_count=result["n_context"], regenerations=result["regenerations"], ), n_context=result["n_context"], model=result["model"], latency_ms=result["latency_ms"], regenerations=result["regenerations"], ) def _ask(req: AskRequest, request: Request) -> AskResponse: rag: AppState = request.app.state.rag cfg = rag.ensure() request_id = _request_id(request) try: result = answer_question( req.question, cfg, client=rag.get_client(), retriever=rag.get_retriever(), top_k=req.top_k, ) except Exception as exc: logger.exception("Antwortgenerierung fehlgeschlagen request_id=%s", request_id) raise HTTPException( status_code=503, detail="Der Wissensdienst ist vorübergehend nicht verfügbar.", ) from exc result.pop("draft", None) return _response_from_result(result, request_id) @app.post("/v1/ask", response_model=AskResponse, dependencies=[Depends(require_api_access)]) def ask_v1(req: AskRequest, request: Request) -> AskResponse: return _ask(req, request) @app.post( "/ask", response_model=AskResponse, dependencies=[Depends(require_api_access)], deprecated=True, ) def ask_compat(req: AskRequest, request: Request) -> AskResponse: return _ask(req, request) def _health(request: Request) -> HealthResponse: rag: AppState = request.app.state.rag cfg = rag.ensure() try: index = rag.get_retriever().stats() index_ok = True except RuntimeError: index = {"available": False} index_ok = False ollama_up = rag.get_client().is_up() return HealthResponse( status="ok" if index_ok and ollama_up else "degraded", index=index, ollama_up=ollama_up, authentication_enabled=bool(cfg.api_key), ) @app.get("/v1/health", response_model=HealthResponse) def health_v1(request: Request) -> HealthResponse: return _health(request) @app.get("/health", response_model=HealthResponse, deprecated=True) def health_compat(request: Request) -> HealthResponse: return _health(request) def _reindex(request: Request) -> dict: rag: AppState = request.app.state.rag cfg = rag.ensure() with rag.maintenance_lock: try: stats = build_index(cfg, client=rag.get_client()) rag.reset_retriever() except Exception as exc: request_id = _request_id(request) logger.exception("Reindex fehlgeschlagen request_id=%s", request_id) raise HTTPException( status_code=503, detail="Der Index konnte nicht neu aufgebaut werden.", ) from exc result = stats.as_dict() result["api_version"] = API_VERSION result["request_id"] = _request_id(request) result["warning"] = ( "Index ohne Dense-Vektoren aufgebaut; Embedding-Dienst prüfen und erneut reindexen." if stats.embed_error else None ) return result @app.post("/v1/reindex", dependencies=[Depends(require_admin_access)]) def reindex_v1(request: Request) -> dict: return _reindex(request) @app.post( "/reindex", dependencies=[Depends(require_admin_access)], deprecated=True, ) def reindex_compat(request: Request) -> dict: return _reindex(request)