mirror of
http://100.103.83.12:3003/fegger/pv-agent.git
synced 2026-09-17 14:46:24 +00:00
594 lines
19 KiB
Python
594 lines
19 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 .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)
|