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.
This commit is contained in:
+156
@@ -0,0 +1,156 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user