2cba72aeb0
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.
156 lines
4.2 KiB
Python
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 |