Files
pv-agent/agent/api.py
T
fegger 2cba72aeb0 M1+M2: RAG-Pipeline mit verbindlichem Grounding
agent/-Paket: Ingest (601 Layer-2-Eintraege -> 3005 Chunks, FTS5-BM25 +
Vektoren-Cache), Hybrid-Retrieval (RRF, Stand-Boost, cross_ref-Erweiterung),
Ollama-Client (embed/chat, think-Flag-Fallback, kurzes Connect-Budget),
Systemprompt mit Zitierpflicht, Post-Validierung (zitierte IDs gemaess
Retrieved-Set, 1x Regenerierung, dann Verweigerung), FastAPI (/ask, /health,
/reindex), CLI, Goldset (31 Fragen, IDs gegen kb.json verifiziert, inkl.
ATZ-Konfliktfall + 4 Verweigerungsfaelle), Eval-Suite, Test-Chat.

41 Offline-Tests gruen. Baseline BM25-only: Hit-Rate 0,871 / Recall@8 0,855 /
MRR 0,476. Hybrid-Messung, Antwortmodus-Eval und Modell-Bake-off (M3) auf
dem Host ausstaendig (Ollama aus der Zed-Sandbox nicht erreichbar).

MEMORY.md und planung.md Umsetzungsstand aktualisiert.
2026-09-14 16:53:04 +02:00

156 lines
4.2 KiB
Python

"""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)
"""
from __future__ import annotations
import threading
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from .config import Config
from .generate import answer_question
from .ingest import build_index
from .ollama_client import OllamaClient
from .retrieve import Retriever
class AskRequest(BaseModel):
question: str = Field(min_length=3, max_length=2000)
top_k: int | None = Field(default=None, ge=1, le=20)
class SourceOut(BaseModel):
id: str
title: str
section: str | None = None
stand: str | None = None
work: str | None = None
class AskResponse(BaseModel):
question: str
answer: str
refused: bool
verified: bool
citations: list[str]
sources: list[SourceOut]
n_context: int
model: str
latency_ms: int
regenerations: int = 0
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()
def ensure(self) -> Config:
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
def get_retriever(self) -> Retriever:
if self.retriever is None:
cfg = self.ensure()
self.retriever = Retriever(cfg)
return self.retriever
def reset_retriever(self) -> None:
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
if rag.retriever:
rag.retriever.close()
if rag.client:
rag.client.close()
app = FastAPI(title="PV RAG Agent", version="0.1.0", lifespan=lifespan)
@app.post("/ask", response_model=AskResponse)
def ask(req: AskRequest) -> AskResponse:
rag: AppState = app.state.rag
cfg = rag.ensure()
try:
result = answer_question(
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. Ä.
raise HTTPException(
status_code=503,
detail=f"Antwortgenerierung fehlgeschlagen: {type(e).__name__}: {e}",
) from e
result.pop("draft", None)
return AskResponse(**result)
@app.get("/health")
def health() -> dict:
rag: AppState = 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
)
return result