Files
pv-agent/agent/api.py
T

412 lines
13 KiB
Python

"""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 .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 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
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) -> 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)