Stufe 2: Antworttyp-Routing — Survey Map-Reduce + Rueckfrage-Regel (D13, M6)

- Planer liefert jetzt type: survey|specific. Survey-Fragen (Uebersicht
  ueber viele Dokumente) laufen ueber Map-Reduce: Retrieval auf
  survey_blocks=16 erweitert, ein Map-Call destilliert JE Block als
  Stichpunkte mit seiner KB-ID (MAP_SYSTEM_PROMPT), ein Reduce-Call
  synthetisiert die Endantwort. Grounding unveraendert: Zitier-Validierung
  strikt ueber die Retrieved-Union; leerer Map-Output -> Fallback auf
  Einzelantwort.
- Systemprompt-Regel 9: haengt die Antwort wesentlich von nicht genanntem
  Kontext ab (Branche, Bundesland, Zeitraum), belegte allgemeine Aussage
  plus EINE Rueckfrage statt Verweigerung (API-first; Odoo-Chat kann die
  Rueckfrage als Follow-up nutzen).
- Ergebnis: q-029 (WIKU-Survey, bisher hartnaeckigste Fehlverweigerung)
  geheilt - Teilantwort mit 5 belegten Heften; q-015 antwortet mit
  expliziter KV-Abhaengigkeit + Rueckfrage statt Branchen-Noise.
- Eval (46 Fragen): Zitier-Praezision 97,8 %, Verweigerung 97,8 % (Gate
  erfuellt), erwartete Quelle 90,2 %, Latenz mean 34,2 s (Map-Reduce nur
  bei Survey-Fragen, ~95 s). Report lokal data/eval-qwen38-stage2.json.
- Tests 57 -> 59 (Survey-Integration, Typ-Parsing).
This commit is contained in:
2026-09-15 11:16:53 +02:00
parent 062e010d7b
commit 4a9e06f66e
7 changed files with 163 additions and 32 deletions
+13
View File
@@ -64,6 +64,7 @@ python -m agent.cli serve # http://127.0.0.1:8080 (/ask, /health, /r
| `PV_QUERY_PLANNER` | `true` | Query-Planer an (Heuristik-Gate entscheidet je Frage) |
| `PV_PLANNER_MODEL` | leer = Antwortmodell | Modell des Planer-Calls |
| `PV_TEMPORAL_BOOST` | `0.0` | Bonus für kv-Einträge im gefragten Geltungsjahr |
| `PV_SURVEY_BLOCKS` | `16` | Map-Reduce: breiteres Retrieval für Survey-Fragen (Typ `survey` vom Planer) |
| `PV_NUM_CTX` | `32768` | Modell-Kontextfenster (KV-Chunks überschreiten 16k — Overflow trunciert den Systemprompt) |
| `PV_MAX_CONTEXT_CHARS` | `90000` | User-Content-Budget; niedrig gerankte Blöcke werden ganz weggelassen (`trim_results`) |
| `PV_CONTEXT_BLOCKS` | `8` | Kontextblöcke im Prompt |
@@ -155,6 +156,18 @@ Report: `data/eval-qwen38-stage1-final.json`. Offen: q-029 Survey
(Stufe-2-Hebel: Map-Reduce), q-015 Branchen-Noise → API-first-Rückfrage
(Stufe 2).
**Antworttyp-Routing Stufe 2 (2026-09-15, M6/D13):** Planer liefert
`type: survey|specific`. Survey-Fragen („Welche Neuerungen …“) →
**Map-Reduce**: Retrieval auf `survey_blocks` (16) erweitert, ein Map-Call
destilliert jeden Block als Stichpunkte mit seiner KB-ID, ein Reduce-Call
synthetisiert die Endantwort — Zitier-Validierung weiterhin strikt über
die Retrieved-Union. **Regel 9** (Kontext-Abhängigkeit): belegte allgemeine
Aussage + eine Rückfrage statt Verweigerung (Branche/Bundesland/Zeitraum).
Ergebnisse: q-029 geheilt (5 belegte Hefte, ~95 s Map-Reduce-Latenz),
q-015 antwortet mit KV-Abhängigkeit + Rückfrage. Eval (46 Fragen):
Zitier-Präzision 97,8 % · Verweigerung 97,8 % (Gate ✓) · erwartete
Quelle 90,2 % · Latenz mean 34,2 s. Report: `data/eval-qwen38-stage2.json`.
## Dateien
```
+3
View File
@@ -80,6 +80,8 @@ class Config:
planner_model: str = "" # leer = Antwortmodell
per_query_slots: int = 2 # Multi-Query: garantierte Kontext-Slots
# je Sub-Query (Multi-Hop-Abdeckung)
survey_blocks: int = 16 # Map-Reduce (Stufe 2): breiteres
# Retrieval fuer Survey-Fragen
temporal_boost: float = 0.0 # Bonus fuer kv-Eintraege im gefragten
# Geltungsjahr (0 = nur FTS-Tag-Signal)
@@ -115,6 +117,7 @@ class Config:
planner_num_predict=_env_int("PV_PLANNER_NUM_PREDICT", d.planner_num_predict),
planner_model=_env_str("PV_PLANNER_MODEL", d.planner_model),
per_query_slots=_env_int("PV_PER_QUERY_SLOTS", d.per_query_slots),
survey_blocks=_env_int("PV_SURVEY_BLOCKS", d.survey_blocks),
temporal_boost=_env_float("PV_TEMPORAL_BOOST", d.temporal_boost),
port=_env_int("PV_PORT", d.port),
)
+44 -4
View File
@@ -47,11 +47,22 @@ Verbindliche Regeln:
existierendes Gesetz), korrigiere die Annahme anhand der Blöcke und
gib die zutreffende, belegte Aussage. Muster: Auf „Was regelt das
Mindestlohngesetz?“ antworte sinngemäß „Ein Mindestlohngesetz existiert
laut Kontext nicht; stattdätzlich gilt …“ — mit Beleg [ID].
laut Kontext nicht; stattdessen gilt …“ — mit Beleg [ID].
Verweigere in diesem Fall nicht.
9. Hängt die Antwort wesentlich von nicht genanntem Kontext ab (z. B.
Branche, Bundesland, Zeitraum), sage dies explizit: gib die belegte
allgemeine Aussage für die im Kontext vertretenen Fälle und frage am
Ende in EINEM kurzen Satz nach dem fehlenden Kontext. Verweigere in
diesem Fall nicht.
Verletze Regel 2 oder Regel 4 niemals — im Zweifel verweigere die Antwort."""
MAP_SYSTEM_PROMPT = """Du destillierst Wissensbasis-Kontextblöcke für eine Folgesynthese.
Erstelle für JEDEN Kontextblock 1-3 prägnante Stichpunkte. Beginne jede
Zusammenfassung mit der Zeile "[<KB-ID>] <Kurzthema>:" — verwende exakt
die KB-ID aus dem Block-Kopf. Behalte konkrete Werte mit ihrem Stand.
Lasse keinen Block aus; keine Einleitung, keine Schlussbemerkung."""
CITE_RE = re.compile(r"\b(?:lb|wk|kv|ris)-[a-z0-9]+-\d+\b")
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
@@ -157,15 +168,19 @@ def answer_question(
sub_queries = [SubQuery(text=question)]
planned = False
qtype = "specific"
if cfg.planner_enabled:
try:
sub_queries, planned = plan_queries(question, client, cfg)
sub_queries, planned, qtype = plan_queries(question, client, cfg)
sub_queries = sub_queries[: cfg.planner_max_queries] or sub_queries[:1]
except Exception:
sub_queries, planned = [SubQuery(text=question)], False
sub_queries, planned, qtype = [SubQuery(text=question)], False, "specific"
n_entries = top_k
if n_entries is None and qtype == "survey":
n_entries = cfg.survey_blocks
try:
results = retriever.search_multi(sub_queries, n_entries=top_k)
results = retriever.search_multi(sub_queries, n_entries=n_entries)
finally:
if own_retriever:
retriever.close()
@@ -202,6 +217,15 @@ def answer_question(
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_content(question, results)},
]
map_messages = None
if qtype == "survey":
# Map-Reduce (Stufe 2): alle Bloecke destillieren (Map), dann
# synthetisieren (Reduce). Zitiert werden duerfen weiterhin nur IDs
# aus der Retrieved-Menge — die Post-Validierung bleibt unveraendert.
map_messages = [
{"role": "system", "content": MAP_SYSTEM_PROMPT},
{"role": "user", "content": build_user_content(question, results)},
]
def chat(msgs):
return strip_think(
@@ -215,6 +239,22 @@ def answer_question(
)
)
if map_messages is not None:
summary = chat(map_messages)
if summary:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": (
"Kontextblöcke aus der Wissensbasis "
f"(Block-Zusammenfassungen):\n\n{summary}\n\n"
f"Frage: {question}"
),
},
]
# leerer Map-Output -> Fallback: messages bleibt die Einzelantwort
final = chat(messages)
violations = validate_answer(final, allowed)
regenerations = 0
+18 -12
View File
@@ -37,9 +37,11 @@ beantworten. Regeln:
- Setze "scope" je Suchanfrage: "gesetz", wenn nach der gesetzlichen/
allgemeinen Grundlage gefragt ist (nur Gesetze und Fachbriefings, ohne
Branchen-Kollektivverträge); "kv", wenn ausdrücklich nach kollektivvertrag-
lichen Branchenregelungen gefragt ist; null für alles andere.
lichen Branchenregelungen gefragt ist; null für alles anderes.
- Setze "type" auf "survey", wenn die Frage eine Übersicht über viele
Dokumente verlangt (Neuerungen, Entwicklungen, alle …); sonst "specific".
- Antworte ausschließlich mit JSON, ohne Erklärung:
{{"queries": [{{"text": "...", "stand_year": null, "scope": null}}]}}"""
{{"type": "specific", "queries": [{{"text": "...", "stand_year": null, "scope": null}}]}}"""
@dataclass
@@ -69,8 +71,9 @@ def should_plan(question: str) -> bool:
return False
def parse_plan(raw: str, original: str) -> list[SubQuery]:
"""Robustes JSON-Parsing; jeder Fehler → [Originalfrage]."""
def parse_plan(raw: str, original: str) -> tuple[list[SubQuery], str]:
"""Robustes JSON-Parsing; jeder Fehler → [Originalfrage]. Liefert
(Sub-Queries, Fragetyp 'survey' | 'specific')."""
try:
match = JSON_RE.search(raw)
if not match:
@@ -89,8 +92,10 @@ def parse_plan(raw: str, original: str) -> list[SubQuery]:
scope = item.get("scope")
scope = scope if scope in ("gesetz", "kv") else None
subs.append(SubQuery(text=text, stand_year=year, scope=scope))
return subs
qtype = data.get("type") if data.get("type") in ("survey", "specific") else "specific"
return subs, qtype
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return [SubQuery(text=original)], "specific"
return [SubQuery(text=original)]
@@ -98,12 +103,12 @@ def plan_queries(
question: str,
client,
cfg,
) -> tuple[list[SubQuery], bool]:
"""Liefert (Sub-Queries, geplant?) — Call-/Parse-Fehler → Original als
Einzel-Query. Der Planer-Call ist klein (Frage ohne Kontext, kurzes
num_predict); Temperature 0."""
) -> tuple[list[SubQuery], bool, str]:
"""Liefert (Sub-Queries, geplant?, Fragetyp) — Call-/Parse-Fehler →
Original als Einzel-Query, Typ 'specific'. Der Planer-Call ist klein
(Frage ohne Kontext, kurzes num_predict); Temperature 0."""
if not should_plan(question):
return [SubQuery(text=question)], False
return [SubQuery(text=question)], False, "specific"
prompt = PLANNER_PROMPT.format(question=question.strip())
try:
raw = client.chat(
@@ -118,5 +123,6 @@ def plan_queries(
think=False,
)
except Exception:
return [SubQuery(text=question)], False
return parse_plan(raw, question), True
return [SubQuery(text=question)], False, "specific"
subs, qtype = parse_plan(raw, question)
return subs, True, qtype