"""Antwort-Generierung mit verbindlichen Grounding-Regeln.
Kern der Pipeline: Systemprompt (nur Kontext, Zitierpflicht, Verweigerung),
kontrollierte cross_ref-Erweiterung und Post-Validierung — jede zitierte ID
muss im Retrieved-Set stehen, sonst eine Regenerierung, dann Verweigerung.
Keine Antwort verlässt die Pipeline mit ungültigen Zitaten.
"""
from __future__ import annotations
import re
import time
from .config import Config
from .normalize import normalize_text
from .ollama_client import OllamaClient
from .retrieve import ChunkResult, Retriever
REFUSAL_MESSAGE = "Dazu enthält die Wissensbasis keine Aussage."
UNCERTAIN_MESSAGE = (
"⚠ Zu dieser Frage kann ich keine verlässlich belegte Antwort "
"aus der Wissensbasis geben."
)
SYSTEM_PROMPT = """Du bist ein präziser Assistent für österreichische Personalverrechnung.
Du beantwortest Fragen AUSSCHLIESSLICH auf Basis der nummerierten Kontextblöcke
aus der internen Wissensbasis.
Verbindliche Regeln:
1. Jede fachliche Aussage muss durch die Kontextblöcke gedeckt sein. Verwende
KEIN Wissen aus deinem Training und ergänze nichts aus eigenem Wissen.
2. Belege jede fachliche Aussage mit der KB-ID in eckigen Klammern, z. B.
[lb-atz-07]. Zitiere NUR die IDs aus den Block-Köpfen („Block N — [id] …“).
IDs, die nur im Fließtext als Verweis genannt werden, sind Querverweise
und KEINE Belege.
3. Gib jeden Wert mit seinem Stand an, z. B. „28,5 % (Stand 2026-01)“.
4. Beantworten die Kontextblöcke die Frage nicht, antworte exakt:
„Dazu enthält die Wissensbasis keine Aussage.“ — und schlage nichts vor.
5. Widersprechen sich Kontextblöcke, nenne beide Werte mit ihren IDs und
kennzeichne den Widerspruch mit ⚠. Löse Widersprüche niemals stillschweigend auf.
6. Nenne Paragraphen und Gesetze nur, wenn ein Kontextblock sie nennt.
7. Antworte auf Deutsch, prägnant (Stichpunkte, wo sinnvoll), und füge am
Ende eine Zeile „Quellen:“ mit den verwendeten IDs (ID — Titel, Stand) an.
Verletze Regel 2 oder Regel 4 niemals — im Zweifel verweigere die Antwort."""
CITE_RE = re.compile(r"\b(?:lb|wk)-[a-z0-9]+-\d+\b")
_THINK_RE = re.compile(r".*?", re.DOTALL)
def strip_think(text: str) -> str:
"""Entfernt -Blöcke defensiv (falls Thinking nicht abschaltbar war)."""
return _THINK_RE.sub("", text).strip()
def looks_like_refusal(answer: str) -> bool:
folded = normalize_text(answer)
return (
"keine aussage" in folded
or "keine verlasslich belegte" in folded
or "nicht in der wissensbasis" in folded
)
def build_user_content(question: str, results: list[ChunkResult]) -> str:
blocks = []
for i, r in enumerate(results, 1):
header = (
f"Block {i} — [{r.entry_id}] {r.title} · Abschnitt: {r.section} "
f"· Stand: {r.stand} · Werk: {r.work}"
)
blocks.append(f"{header}\n{r.text}")
context = "\n\n---\n\n".join(blocks)
return f"Kontextblöcke aus der Wissensbasis:\n\n{context}\n\nFrage: {question}"
def validate_answer(answer: str, allowed_ids: list[str]) -> list[str]:
"""Regel-2/4-Prüfung: zitierte IDs ⊆ Kontext; keine unbelegte Fachantwort."""
cited = set(CITE_RE.findall(answer))
violations: list[str] = []
unknown = sorted(cited - set(allowed_ids))
if unknown:
violations.append(f"zitierte IDs außerhalb des Kontexts: {', '.join(unknown)}")
if not cited and not looks_like_refusal(answer):
violations.append("keine KB-ID zitiert")
return violations
def _source_rows(results: list[ChunkResult]) -> list[dict]:
rows = []
seen: set[str] = set()
for r in results:
if r.entry_id in seen:
continue
seen.add(r.entry_id)
rows.append(
{
"id": r.entry_id,
"title": r.title,
"section": r.section,
"stand": r.stand,
"work": r.work,
"source": r.source,
}
)
return rows
def answer_question(
question: str,
cfg: Config,
client: OllamaClient | None = None,
retriever: Retriever | None = None,
top_k: int | None = None,
) -> dict:
"""Vollständiger Ask-Zyklus: Retrieval -> Prompt -> LLM -> Post-Validierung."""
t0 = time.perf_counter()
own_retriever = retriever is None
if retriever is None:
retriever = Retriever(cfg)
try:
results = retriever.search(question, n_entries=top_k)
finally:
if own_retriever:
retriever.close()
def finish(answer, refused, verified, citations, regenerations=0, draft=None):
return {
"question": question,
"answer": answer,
"refused": refused,
"verified": verified,
"citations": citations,
"sources": _source_rows(results),
"n_context": len(results),
"model": cfg.answer_model,
"regenerations": regenerations,
"latency_ms": round((time.perf_counter() - t0) * 1000),
"draft": draft,
}
if not results:
# Verweigerungspflicht: leeres Retrieval -> deterministische Antwort
return finish(REFUSAL_MESSAGE, refused=True, verified=True, citations=[])
allowed = [r.entry_id for r in results]
by_id = {r.entry_id: r for r in results}
if client is None:
client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_content(question, results)},
]
def chat(msgs):
return strip_think(
client.chat(
cfg.answer_model,
msgs,
temperature=cfg.temperature,
num_ctx=cfg.num_ctx,
num_predict=cfg.num_predict,
think=cfg.think,
)
)
final = chat(messages)
violations = validate_answer(final, allowed)
regenerations = 0
if violations:
regenerations = 1
warn = (
"Deine letzte Antwort verstieß gegen die Regeln: "
+ "; ".join(violations)
+ f". Erlaubte KB-IDs sind ausschließlich: {', '.join(sorted(set(allowed)))}. "
"Beantworte die Frage erneut und zitiere nur diese IDs — oder verweigere "
f"mit dem vorgesehenen Satz („{REFUSAL_MESSAGE}“)."
)
retry = chat(
messages
+ [{"role": "assistant", "content": final},
{"role": "user", "content": warn}]
)
retry_violations = validate_answer(retry, allowed)
if not retry_violations:
final = retry
violations = []
else:
return finish(
UNCERTAIN_MESSAGE,
refused=True,
verified=False,
citations=[],
regenerations=regenerations,
draft=retry,
)
citations = sorted(set(CITE_RE.findall(final)))
refused = looks_like_refusal(final)
sources = [
{
"id": cid,
"title": by_id[cid].title,
"section": by_id[cid].section,
"stand": by_id[cid].stand,
"work": by_id[cid].work,
}
for cid in citations
if cid in by_id
]
return finish(
final, refused=refused, verified=not violations,
citations=citations, regenerations=regenerations,
)