feat(agent): harden API-first service

This commit is contained in:
2026-09-16 21:12:47 +02:00
parent aa637270b7
commit c8b55fbd38
12 changed files with 839 additions and 162 deletions
+301 -78
View File
@@ -1,31 +1,51 @@
"""FastAPI-Oberfläche des PV RAG Agent.
"""Versionierte FastAPI-Oberfläche des PV RAG Agent.
Endpunkte:
POST /ask — Frage -> belegte Antwort (oder Verweigerung)
GET /health — Index- und Ollama-Status
POST /reindex — Index-Neuaufbau (nach neuem Wissensbasis-Batch)
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 FastAPI, HTTPException
from pydantic import BaseModel, Field
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 answer_question
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 AskRequest(BaseModel):
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(BaseModel):
class SourceOut(StrictModel):
id: str
title: str
section: str | None = None
@@ -33,51 +53,93 @@ class SourceOut(BaseModel):
work: str | None = None
class AskResponse(BaseModel):
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.lock = threading.Lock()
self.maintenance_lock = threading.Lock()
self.state_lock = threading.RLock()
def ensure(self) -> Config:
if self.cfg is None:
self.cfg = Config.from_env()
return self.cfg
with self.state_lock:
if self.cfg is None:
self.cfg = Config.from_env()
return self.cfg
def get_client(self) -> OllamaClient:
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
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:
if self.retriever is None:
cfg = self.ensure()
self.retriever = Retriever(cfg)
return self.retriever
with self.state_lock:
if self.retriever is None:
self.retriever = Retriever(self.ensure())
return self.retriever
def reset_retriever(self) -> None:
if self.retriever is not None:
self.retriever.close()
self.retriever = None
with self.state_lock:
if self.retriever is not None:
self.retriever.close()
self.retriever = None
@asynccontextmanager
@@ -85,72 +147,233 @@ async def lifespan(app: FastAPI):
app.state.rag = AppState()
yield
rag: AppState = app.state.rag
if rag.retriever:
rag.retriever.close()
rag.reset_retriever()
if rag.client:
rag.client.close()
app = FastAPI(title="PV RAG Agent", version="0.1.0", lifespan=lifespan)
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.post("/ask", response_model=AskResponse)
def ask(req: AskRequest) -> AskResponse:
rag: AppState = app.state.rag
@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,
req.question,
cfg,
client=rag.get_client(),
retriever=rag.get_retriever(),
top_k=req.top_k,
)
except RuntimeError as e: # Index fehlt
raise HTTPException(status_code=503, detail=str(e)) from e
except Exception as e: # Ollama nicht erreichbar o. Ä.
except Exception as exc:
logger.exception("Antwortgenerierung fehlgeschlagen request_id=%s", request_id)
raise HTTPException(
status_code=503,
detail=f"Antwortgenerierung fehlgeschlagen: {type(e).__name__}: {e}",
) from e
detail="Der Wissensdienst ist vorübergehend nicht verfügbar.",
) from exc
result.pop("draft", None)
return AskResponse(**result)
return _response_from_result(result, request_id)
@app.get("/health")
def health() -> dict:
rag: AppState = app.state.rag
@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()
out: dict = {"service": "pv-rag-agent", "config": {
"ollama_url": cfg.ollama_url,
"answer_model": cfg.answer_model,
"embed_model": cfg.embed_model,
}}
try:
retriever = rag.get_retriever()
out["index"] = retriever.stats()
except RuntimeError as e:
out["index"] = {"error": str(e)}
client = rag.get_client()
out["ollama_up"] = client.is_up()
if out["ollama_up"]:
try:
out["ollama_models"] = client.list_models()
except Exception:
out["ollama_models"] = None
return out
@app.post("/reindex")
def reindex() -> dict:
rag: AppState = app.state.rag
cfg = rag.ensure()
with rag.lock:
stats = build_index(cfg, client=rag.get_client())
rag.reset_retriever()
result = stats.as_dict()
result["warning"] = (
"Index ohne Dense-Vektoren aufgebaut (Ollama-Embedding nicht verfügbar) — "
"BM25-only. 'ollama pull " + cfg.embed_model + "' prüfen und erneut reindexen."
if stats.embed_error else None
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),
)
return result
@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)