From 23a5ef74dcbd6a4d16ab422f8d957b5c11c816c9 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 15 Sep 2026 11:16:53 +0200 Subject: [PATCH] =?UTF-8?q?Stufe=202:=20Antworttyp-Routing=20=E2=80=94=20S?= =?UTF-8?q?urvey=20Map-Reduce=20+=20Rueckfrage-Regel=20(D13,=20M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- pv-agent/.agents/MEMORY.md | 19 ++++++++ pv-agent/agent/README.md | 13 +++++ pv-agent/agent/config.py | 3 ++ pv-agent/agent/generate.py | 48 ++++++++++++++++-- pv-agent/agent/query_planner.py | 30 +++++++----- pv-agent/planung.md | 9 ++++ pv-agent/tests/test_query_planner.py | 73 ++++++++++++++++++++++------ 7 files changed, 163 insertions(+), 32 deletions(-) diff --git a/pv-agent/.agents/MEMORY.md b/pv-agent/.agents/MEMORY.md index f6905d6..1633cf4 100644 --- a/pv-agent/.agents/MEMORY.md +++ b/pv-agent/.agents/MEMORY.md @@ -222,6 +222,25 @@ zu planen). q-113 (Gesetz+KV Multi-Source). Tests 50 -> 57. Offen: q-029 Survey (Stufe-2-Hebel: Map-Reduce), q-113 Planer- Scope flaky (1/2 Laeufe ohne gesetz-Marker). +- **D13 (Antworttyp-Routing / Stufe 2, 2026-09-15):** (a) Planer liefert + jetzt `type: survey|specific`; Survey-Fragen („Welche Neuerungen …“) + laufen ueber Map-Reduce: breiteres Retrieval (`survey_blocks`=16, + PV_SURVEY_BLOCKS), ein Map-Call destilliert JE Block als Stichpunkte + mit seiner KB-ID (MAP_SYSTEM_PROMPT), ein Reduce-Call synthetisiert + daraus die Antwort mit dem normalen Grounding-Prompt; Zitier- + Validierung weiterhin strikt ueber die Retrieved-Union, leerer + Map-Output -> Fallback Einzelantwort. (b) Systemprompt-Regel 9: bei + wesentlicher Kontextabhaengigkeit (Branche, Bundesland, Zeitraum) + belegte allgemeine Aussage + EINE Rueckfrage statt Verweigerung + (API-first; Odoo-Chat kann die Rueckfrage als Follow-up nutzen). + Ergebnis: **q-029 geheilt** (vorher jahrelange Fehlverweigerung; + jetzt Teilantwort mit 5 belegten Heften, 95 s — Map-Reduce-Latenz + nur bei Survey-Fragen), **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. Tests 57 -> 59. + Report `data/eval-qwen38-stage2.json`. Verbleibend: q-024 transiente + Flakiness (Think-Ghost-Verdacht, 2/3 Laeufe sauber). - **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs- korrektheit > Latenz. diff --git a/pv-agent/agent/README.md b/pv-agent/agent/README.md index a75bbbd..18987a0 100644 --- a/pv-agent/agent/README.md +++ b/pv-agent/agent/README.md @@ -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 ``` diff --git a/pv-agent/agent/config.py b/pv-agent/agent/config.py index b4c47de..bfba83d 100644 --- a/pv-agent/agent/config.py +++ b/pv-agent/agent/config.py @@ -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), ) \ No newline at end of file diff --git a/pv-agent/agent/generate.py b/pv-agent/agent/generate.py index 1825685..f9c5f56 100644 --- a/pv-agent/agent/generate.py +++ b/pv-agent/agent/generate.py @@ -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 "[] :" — 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".*?", 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 diff --git a/pv-agent/agent/query_planner.py b/pv-agent/agent/query_planner.py index 62a88bb..2024131 100644 --- a/pv-agent/agent/query_planner.py +++ b/pv-agent/agent/query_planner.py @@ -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 \ No newline at end of file + return [SubQuery(text=question)], False, "specific" + subs, qtype = parse_plan(raw, question) + return subs, True, qtype \ No newline at end of file diff --git a/pv-agent/planung.md b/pv-agent/planung.md index 96c665c..cddd998 100644 --- a/pv-agent/planung.md +++ b/pv-agent/planung.md @@ -306,4 +306,13 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests 46 Fragen (q-110–113). Offen für Stufe 2: Aggregation → Map-Reduce (q-029), Rückfragen statt Verweigerung (API-first), danach Rechtsprechung-Intake (`rj-*`, Lexis-md/json). +- **Antworttyp-Routing Stufe 2 (2026-09-15, M6/D13):** Planer-Typ + `survey` → Map-Reduce (survey_blocks=16; Map destilliert je Block + mit KB-ID, Reduce synthetisiert; Validierung unverändert über die + Union). Systemprompt-Regel 9: Kontext-Abhängigkeit → belegte allgemeine + Aussage + eine Rückfrage (API-first). q-029 geheilt (vorher hart- + näckigste Fehlverweigerung), q-015 mit KV-Abhängigkeits-Hinweis. + Eval: 97,8 % / 97,8 % / 90,2 %, Latenz mean 34,2 s (Map-Reduce nur + bei Survey-Fragen, ~95 s). Nächster Schritt: Rechtsprechung-Intake + (`rj-*`), dann M4 (Odoo; Privacy-Neubewertung für Lohndaten-Zugriff). - Betrieb: `agent/README.md`. diff --git a/pv-agent/tests/test_query_planner.py b/pv-agent/tests/test_query_planner.py index 83cbd09..86a435a 100644 --- a/pv-agent/tests/test_query_planner.py +++ b/pv-agent/tests/test_query_planner.py @@ -27,40 +27,51 @@ def test_should_plan_gate(): def test_parse_plan_valid_and_fallback(): - subs = parse_plan( - 'Vorab: {"queries": [{"text": "mindestlohn friseur", "stand_year": "2024"}, ' + subs, qtype = parse_plan( + 'Vorab: {"type": "specific", "queries": [{"text": "mindestlohn friseur", "stand_year": "2024"}, ' '{"text": "lohnberechnung friseur", "stand_year": null}]}', original="Originalfrage?", ) assert [s.text for s in subs] == ["mindestlohn friseur", "lohnberechnung friseur"] assert subs[0].stand_year == "2024" assert subs[1].stand_year is None + assert qtype == "specific" # Fallbacks: kaputtes JSON, leeres Array, leere Texte for raw in ("kein json", '{"queries": []}', '{"queries": [{"text": ""}]}'): - subs = parse_plan(raw, original="Originalfrage?") + subs, qtype = parse_plan(raw, original="Originalfrage?") assert len(subs) == 1 and subs[0].text == "Originalfrage?" + assert qtype == "specific" - # Ungueltiges Jahr -> None erzwingen, Text bleibt - subs = parse_plan( - '{"queries": [{"text": "x", "stand_year": "98"}]}', original="orig" + # Ungueltiges Jahr -> None erzwingen, Text bleibt; unbekannter scope -> None + subs, _ = parse_plan( + '{"queries": [{"text": "x", "stand_year": "98", "scope": "xyz"}]}', original="orig" ) - assert subs == [SubQuery(text="x", stand_year=None)] + assert subs == [SubQuery(text="x", stand_year=None, scope=None)] + + +def test_parse_plan_type_survey(): + subs, qtype = parse_plan( + '{"type": "survey", "queries": [{"text": "wiku personal aktuell 2026 neuerungen", "scope": null}]}', + original="orig", + ) + assert qtype == "survey" + assert subs[0].scope is None def test_parse_plan_caps_at_three_queries(): raw = json.dumps( {"queries": [{"text": f"q{i}"} for i in range(5)]} ) - subs = parse_plan(raw, original="orig") + subs, _ = parse_plan(raw, original="orig") assert len(subs) == 3 def test_plan_queries_simple_question_no_llm_call(): client = FakeOllama(answers=[]) # darf nicht aufgerufen werden cfg = Config(planner_enabled=True) - subs, planned = plan_queries("Was ist Altersteilzeit?", client, cfg) - assert planned is False + subs, planned, qtype = plan_queries("Was ist Altersteilzeit?", client, cfg) + assert planned is False and qtype == "specific" assert subs == [SubQuery(text="Was ist Altersteilzeit?")] assert client.calls == 0 @@ -68,19 +79,19 @@ def test_plan_queries_simple_question_no_llm_call(): def test_plan_queries_uses_planner_and_falls_back_on_error(): cfg = Config(planner_enabled=True) client = FakeOllama( - answers=['{"queries": [{"text": "atz lohnausgleich"}, {"text": "atz altersteilzeitgeld", "stand_year": null}]}'] + answers=['{"type": "specific", "queries": [{"text": "atz lohnausgleich"}, {"text": "atz altersteilzeitgeld", "stand_year": null}]}'] ) - subs, planned = plan_queries( + subs, planned, qtype = plan_queries( "Wie funktioniert der Lohnausgleich bei Altersteilzeit und was ersetzt das AMS?", client, cfg ) - assert planned is True and len(subs) == 2 + assert planned is True and qtype == "specific" and len(subs) == 2 assert subs[0].text == "atz lohnausgleich" # Fehler -> Originalfrage, geplant False failing = FakeOllama(answers=[]) failing.chat = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline")) - subs, planned = plan_queries("Wie funktioniert der Lohnausgleich 2026?", failing, cfg) - assert planned is False + subs, planned, qtype = plan_queries("Wie funktioniert der Lohnausgleich 2026?", failing, cfg) + assert planned is False and qtype == "specific" assert subs[0].text.startswith("Wie funktioniert") @@ -95,7 +106,7 @@ def test_answer_question_planner_integration(mini_index): planner_max_queries=2, ) client = FakeOllama(answers=[ - json.dumps({"queries": [ + json.dumps({"type": "specific", "queries": [ {"text": "altersteilzeit lohnausgleich", "stand_year": None}, {"text": "urlaubsanspruch", "stand_year": None}, ]}), @@ -112,6 +123,36 @@ def test_answer_question_planner_integration(mini_index): assert len(result["planned_queries"]) == 2 +def test_answer_question_survey_map_reduce(mini_index): + """Survey-Frage: 1. Planer (type=survey), 2. Map-Destillat, + 3. Reduce-Antwort. Zitier-Validierung weiterhin gegen die Union.""" + cfg = Config( + kb_dir=mini_index.kb_dir, + db_path=mini_index.db_path, + embed_off=True, + planner_enabled=True, + survey_blocks=8, + ) + client = FakeOllama(answers=[ + json.dumps({"type": "survey", "queries": [ + {"text": "wiku personal aktuell 2026", "stand_year": None}, + ]}), + # Map-Ausgabe: Destillat je Block mit KB-ID + "[lb-min-01] Altersteilzeit: Lohnausgleich + ATZ-Geld.\n" + "[lb-min-02] Urlaub: 5 Wochen je Dienstjahr.", + # Reduce-Antwort + "Neuerungen: ATZ-Lohnausgleich [lb-min-01]; Urlaub 5 Wochen [lb-min-02].", + ]) + result = answer_question( + "Welche Neuerungen behandeln die Wissensbasis 2026?", + cfg, client=client, + ) + assert client.calls == 3 # Planer + Map + Reduce + assert result["verified"] is True + assert result["refused"] is False + assert set(result["citations"]) == {"lb-min-01", "lb-min-02"} + + def test_search_multi_fuses_across_queries(mini_index): """Multi-Query-Retrieval: Sub-Queries summieren Beitraege; Treffer aus beiden Themen erscheinen im Kontext."""