"""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 pathlib import Path from typing import Literal from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, ConfigDict, Field from .audit import AuditStore 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}$") WEB_DIR = Path(__file__).resolve().parent.parent / "web" _WEB_ASSETS = {"app.js", "styles.css"} logger = logging.getLogger(__name__) bearer = HTTPBearer(auto_error=False) bearer_credentials = Depends(bearer) class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid") class FactIn(StrictModel): key: str = Field(pattern=r"^[a-z0-9_.\-]{1,64}$") value: str = Field(min_length=1, max_length=200) note: str | None = Field(default=None, max_length=200) class ComputationIn(StrictModel): label: str = Field(min_length=1, max_length=200) result: str = Field(min_length=1, max_length=200) basis: str | None = Field(default=None, max_length=200) components: list[FactIn] = Field(default_factory=list, max_length=40) class ReviewContextIn(StrictModel): """Schema-gebundener Odoo-Kontext (M4.2). Keine freien Objekte, keine Personendaten-Felder — Odoo kuratiert die facts pro Workflow.""" facts: list[FactIn] = Field(default_factory=list, max_length=40) computation: ComputationIn | None = None note: str | None = Field(default=None, max_length=500) 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", "review"] = Field( default="knowledge", description=( "knowledge = KB-Wissen; review = Plausibilitätsprüfung eines " "übermittelten Odoo-Ergebnisses (erfordert PV_REVIEW_MODE)." ), ) context: ReviewContextIn | None = None 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", "knowledge_base_plus_review_context" ] = DATA_SCOPE citations_verified: bool context_count: int regenerations: int class PlausibilityCheckOut(StrictModel): status: Literal["ok", "warn", "open"] aspect: str detail: str source_ids: list[str] class PlausibilityOut(StrictModel): verdict: Literal["plausible", "implausible", "not_checkable"] checks: list[PlausibilityCheckOut] 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 ratings_enabled: bool = False mode: Literal["knowledge", "review"] = "knowledge" plausibility: PlausibilityOut | None = None 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 ratings_enabled: bool class RatingRequest(StrictModel): request_id: str = Field(pattern=r"^[A-Za-z0-9._:-]{1,128}$") rating: Literal["up", "down"] feedback: str | None = Field(default=None, max_length=1000) class RatingResponse(StrictModel): api_version: Literal["v1"] = API_VERSION request_id: str rating: Literal["up", "down"] accepted: Literal[True] = True class CommentRequest(StrictModel): request_id: str = Field(pattern=r"^[A-Za-z0-9._:-]{1,128}$") comment: str = Field(min_length=1, max_length=2000) class CommentResponse(StrictModel): api_version: Literal["v1"] = API_VERSION request_id: str comment_id: int accepted: Literal[True] = True class AppState: def __init__(self) -> None: self.cfg: Config | None = None self.client: OllamaClient | None = None self.retriever: Retriever | None = None self.audit: AuditStore | 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 get_audit(self) -> AuditStore | None: with self.state_lock: if not self.ensure().audit_enabled: return None if self.audit is None: self.audit = AuditStore(self.ensure()) return self.audit 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.audit: rag.audit.close() 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 response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "no-referrer" if request.url.path == "/" or request.url.path.startswith("/assets/"): response.headers["Content-Security-Policy"] = ( "default-src 'self'; base-uri 'none'; form-action 'self'; " "frame-ancestors 'none'; img-src 'self' data:; " "script-src 'self'; style-src 'self'; connect-src 'self'" ) return response @app.get("/", include_in_schema=False, response_class=FileResponse) def frontend() -> FileResponse: return FileResponse( WEB_DIR / "index.html", media_type="text/html", headers={"Cache-Control": "no-store"}, ) @app.get("/assets/{asset_name}", include_in_schema=False, response_class=FileResponse) def frontend_asset(asset_name: str) -> FileResponse: if asset_name not in _WEB_ASSETS: raise HTTPException(status_code=404, detail="Asset nicht gefunden.") return FileResponse( WEB_DIR / asset_name, headers={"Cache-Control": "public, max-age=3600"}, ) 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, ratings_enabled: bool = False, mode: str = "knowledge", ) -> AskResponse: if not result["verified"]: status = "uncertain" elif result["refused"]: status = "refused" else: status = "answered" citations = list(result["citations"]) plausibility = None if result.get("plausibility") is not None: plausibility = PlausibilityOut( verdict=result["plausibility"]["verdict"], checks=[PlausibilityCheckOut(**c) for c in result["plausibility"]["checks"]], ) 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( data_scope=( "knowledge_base_plus_review_context" if mode == "review" else DATA_SCOPE ), 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"], ratings_enabled=ratings_enabled, mode=mode, # type: ignore[arg-type] plausibility=plausibility, ) def _ask(req: AskRequest, request: Request) -> AskResponse: rag: AppState = request.app.state.rag cfg = rag.ensure() request_id = _request_id(request) context: dict | None = None if req.mode == "review": if not cfg.review_mode: raise HTTPException( status_code=422, detail="Der Review-Modus ist auf diesem Dienst nicht aktiviert.", ) if req.context is None: raise HTTPException( status_code=422, detail="Der Review-Modus erfordert einen schema-gebundenen Kontext.", ) context = req.context.model_dump(mode="json") elif req.context is not None: raise HTTPException( status_code=422, detail="Kontext ist nur im Modus review erlaubt.", ) try: result = answer_question( req.question, cfg, client=rag.get_client(), retriever=rag.get_retriever(), top_k=req.top_k, context=context, ) 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) response = _response_from_result( result, request_id, ratings_enabled=cfg.audit_enabled, mode=req.mode ) audit = rag.get_audit() if audit is not None: try: audit.record_interaction( response.model_dump(mode="json"), context=context ) except Exception: # Die Fachantwort darf bei einem reinen Audit-Fehler nicht verloren gehen. logger.exception("Audit-Protokollierung fehlgeschlagen request_id=%s", request_id) return response @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), ratings_enabled=cfg.audit_enabled, ) @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) @app.post( "/v1/ratings", response_model=RatingResponse, dependencies=[Depends(require_api_access)], ) def rate_answer(req: RatingRequest, request: Request) -> RatingResponse: rag: AppState = request.app.state.rag audit = rag.get_audit() if audit is None: raise HTTPException(status_code=503, detail="Bewertungen sind nicht aktiviert.") try: audit.record_rating(req.request_id, req.rating, req.feedback) except KeyError as exc: raise HTTPException(status_code=404, detail="Antwort nicht gefunden.") from exc except Exception as exc: logger.exception("Bewertung fehlgeschlagen request_id=%s", req.request_id) raise HTTPException( status_code=503, detail="Bewertung konnte nicht gespeichert werden." ) from exc return RatingResponse(request_id=req.request_id, rating=req.rating) @app.post( "/v1/comments", response_model=CommentResponse, dependencies=[Depends(require_api_access)], ) def comment_answer(req: CommentRequest, request: Request) -> CommentResponse: rag: AppState = request.app.state.rag audit = rag.get_audit() if audit is None: raise HTTPException(status_code=503, detail="Kommentare sind nicht aktiviert.") comment = req.comment.strip() if not comment: raise HTTPException(status_code=422, detail="Kommentar darf nicht leer sein.") try: comment_id = audit.record_comment(req.request_id, comment) except KeyError as exc: raise HTTPException(status_code=404, detail="Antwort nicht gefunden.") from exc except Exception as exc: logger.exception("Kommentar fehlgeschlagen request_id=%s", req.request_id) raise HTTPException( status_code=503, detail="Kommentar konnte nicht gespeichert werden." ) from exc return CommentResponse( request_id=req.request_id, comment_id=comment_id, ) 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)