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:
@@ -222,6 +222,25 @@ zu planen).
|
|||||||
q-113 (Gesetz+KV Multi-Source). Tests 50 -> 57.
|
q-113 (Gesetz+KV Multi-Source). Tests 50 -> 57.
|
||||||
Offen: q-029 Survey (Stufe-2-Hebel: Map-Reduce), q-113 Planer-
|
Offen: q-029 Survey (Stufe-2-Hebel: Map-Reduce), q-113 Planer-
|
||||||
Scope flaky (1/2 Laeufe ohne gesetz-Marker).
|
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
|
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
|
||||||
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
||||||
korrektheit > Latenz.
|
korrektheit > Latenz.
|
||||||
|
|||||||
@@ -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_QUERY_PLANNER` | `true` | Query-Planer an (Heuristik-Gate entscheidet je Frage) |
|
||||||
| `PV_PLANNER_MODEL` | leer = Antwortmodell | Modell des Planer-Calls |
|
| `PV_PLANNER_MODEL` | leer = Antwortmodell | Modell des Planer-Calls |
|
||||||
| `PV_TEMPORAL_BOOST` | `0.0` | Bonus für kv-Einträge im gefragten Geltungsjahr |
|
| `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_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_MAX_CONTEXT_CHARS` | `90000` | User-Content-Budget; niedrig gerankte Blöcke werden ganz weggelassen (`trim_results`) |
|
||||||
| `PV_CONTEXT_BLOCKS` | `8` | Kontextblöcke im Prompt |
|
| `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-Hebel: Map-Reduce), q-015 Branchen-Noise → API-first-Rückfrage
|
||||||
(Stufe 2).
|
(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
|
## Dateien
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class Config:
|
|||||||
planner_model: str = "" # leer = Antwortmodell
|
planner_model: str = "" # leer = Antwortmodell
|
||||||
per_query_slots: int = 2 # Multi-Query: garantierte Kontext-Slots
|
per_query_slots: int = 2 # Multi-Query: garantierte Kontext-Slots
|
||||||
# je Sub-Query (Multi-Hop-Abdeckung)
|
# 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
|
temporal_boost: float = 0.0 # Bonus fuer kv-Eintraege im gefragten
|
||||||
# Geltungsjahr (0 = nur FTS-Tag-Signal)
|
# 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_num_predict=_env_int("PV_PLANNER_NUM_PREDICT", d.planner_num_predict),
|
||||||
planner_model=_env_str("PV_PLANNER_MODEL", d.planner_model),
|
planner_model=_env_str("PV_PLANNER_MODEL", d.planner_model),
|
||||||
per_query_slots=_env_int("PV_PER_QUERY_SLOTS", d.per_query_slots),
|
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),
|
temporal_boost=_env_float("PV_TEMPORAL_BOOST", d.temporal_boost),
|
||||||
port=_env_int("PV_PORT", d.port),
|
port=_env_int("PV_PORT", d.port),
|
||||||
)
|
)
|
||||||
+44
-4
@@ -47,11 +47,22 @@ Verbindliche Regeln:
|
|||||||
existierendes Gesetz), korrigiere die Annahme anhand der Blöcke und
|
existierendes Gesetz), korrigiere die Annahme anhand der Blöcke und
|
||||||
gib die zutreffende, belegte Aussage. Muster: Auf „Was regelt das
|
gib die zutreffende, belegte Aussage. Muster: Auf „Was regelt das
|
||||||
Mindestlohngesetz?“ antworte sinngemäß „Ein Mindestlohngesetz existiert
|
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.
|
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."""
|
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")
|
CITE_RE = re.compile(r"\b(?:lb|wk|kv|ris)-[a-z0-9]+-\d+\b")
|
||||||
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
||||||
|
|
||||||
@@ -157,15 +168,19 @@ def answer_question(
|
|||||||
|
|
||||||
sub_queries = [SubQuery(text=question)]
|
sub_queries = [SubQuery(text=question)]
|
||||||
planned = False
|
planned = False
|
||||||
|
qtype = "specific"
|
||||||
if cfg.planner_enabled:
|
if cfg.planner_enabled:
|
||||||
try:
|
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]
|
sub_queries = sub_queries[: cfg.planner_max_queries] or sub_queries[:1]
|
||||||
except Exception:
|
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:
|
try:
|
||||||
results = retriever.search_multi(sub_queries, n_entries=top_k)
|
results = retriever.search_multi(sub_queries, n_entries=n_entries)
|
||||||
finally:
|
finally:
|
||||||
if own_retriever:
|
if own_retriever:
|
||||||
retriever.close()
|
retriever.close()
|
||||||
@@ -202,6 +217,15 @@ def answer_question(
|
|||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": build_user_content(question, results)},
|
{"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):
|
def chat(msgs):
|
||||||
return strip_think(
|
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)
|
final = chat(messages)
|
||||||
violations = validate_answer(final, allowed)
|
violations = validate_answer(final, allowed)
|
||||||
regenerations = 0
|
regenerations = 0
|
||||||
|
|||||||
+18
-12
@@ -37,9 +37,11 @@ beantworten. Regeln:
|
|||||||
- Setze "scope" je Suchanfrage: "gesetz", wenn nach der gesetzlichen/
|
- Setze "scope" je Suchanfrage: "gesetz", wenn nach der gesetzlichen/
|
||||||
allgemeinen Grundlage gefragt ist (nur Gesetze und Fachbriefings, ohne
|
allgemeinen Grundlage gefragt ist (nur Gesetze und Fachbriefings, ohne
|
||||||
Branchen-Kollektivverträge); "kv", wenn ausdrücklich nach kollektivvertrag-
|
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:
|
- 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
|
@dataclass
|
||||||
@@ -69,8 +71,9 @@ def should_plan(question: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def parse_plan(raw: str, original: str) -> list[SubQuery]:
|
def parse_plan(raw: str, original: str) -> tuple[list[SubQuery], str]:
|
||||||
"""Robustes JSON-Parsing; jeder Fehler → [Originalfrage]."""
|
"""Robustes JSON-Parsing; jeder Fehler → [Originalfrage]. Liefert
|
||||||
|
(Sub-Queries, Fragetyp 'survey' | 'specific')."""
|
||||||
try:
|
try:
|
||||||
match = JSON_RE.search(raw)
|
match = JSON_RE.search(raw)
|
||||||
if not match:
|
if not match:
|
||||||
@@ -89,8 +92,10 @@ def parse_plan(raw: str, original: str) -> list[SubQuery]:
|
|||||||
scope = item.get("scope")
|
scope = item.get("scope")
|
||||||
scope = scope if scope in ("gesetz", "kv") else None
|
scope = scope if scope in ("gesetz", "kv") else None
|
||||||
subs.append(SubQuery(text=text, stand_year=year, scope=scope))
|
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):
|
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||||
|
return [SubQuery(text=original)], "specific"
|
||||||
return [SubQuery(text=original)]
|
return [SubQuery(text=original)]
|
||||||
|
|
||||||
|
|
||||||
@@ -98,12 +103,12 @@ def plan_queries(
|
|||||||
question: str,
|
question: str,
|
||||||
client,
|
client,
|
||||||
cfg,
|
cfg,
|
||||||
) -> tuple[list[SubQuery], bool]:
|
) -> tuple[list[SubQuery], bool, str]:
|
||||||
"""Liefert (Sub-Queries, geplant?) — Call-/Parse-Fehler → Original als
|
"""Liefert (Sub-Queries, geplant?, Fragetyp) — Call-/Parse-Fehler →
|
||||||
Einzel-Query. Der Planer-Call ist klein (Frage ohne Kontext, kurzes
|
Original als Einzel-Query, Typ 'specific'. Der Planer-Call ist klein
|
||||||
num_predict); Temperature 0."""
|
(Frage ohne Kontext, kurzes num_predict); Temperature 0."""
|
||||||
if not should_plan(question):
|
if not should_plan(question):
|
||||||
return [SubQuery(text=question)], False
|
return [SubQuery(text=question)], False, "specific"
|
||||||
prompt = PLANNER_PROMPT.format(question=question.strip())
|
prompt = PLANNER_PROMPT.format(question=question.strip())
|
||||||
try:
|
try:
|
||||||
raw = client.chat(
|
raw = client.chat(
|
||||||
@@ -118,5 +123,6 @@ def plan_queries(
|
|||||||
think=False,
|
think=False,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return [SubQuery(text=question)], False
|
return [SubQuery(text=question)], False, "specific"
|
||||||
return parse_plan(raw, question), True
|
subs, qtype = parse_plan(raw, question)
|
||||||
|
return subs, True, qtype
|
||||||
@@ -306,4 +306,13 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
|
|||||||
46 Fragen (q-110–113). Offen für Stufe 2: Aggregation → Map-Reduce
|
46 Fragen (q-110–113). Offen für Stufe 2: Aggregation → Map-Reduce
|
||||||
(q-029), Rückfragen statt Verweigerung (API-first), danach
|
(q-029), Rückfragen statt Verweigerung (API-first), danach
|
||||||
Rechtsprechung-Intake (`rj-*`, Lexis-md/json).
|
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`.
|
- Betrieb: `agent/README.md`.
|
||||||
|
|||||||
+57
-16
@@ -27,40 +27,51 @@ def test_should_plan_gate():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_plan_valid_and_fallback():
|
def test_parse_plan_valid_and_fallback():
|
||||||
subs = parse_plan(
|
subs, qtype = parse_plan(
|
||||||
'Vorab: {"queries": [{"text": "mindestlohn friseur", "stand_year": "2024"}, '
|
'Vorab: {"type": "specific", "queries": [{"text": "mindestlohn friseur", "stand_year": "2024"}, '
|
||||||
'{"text": "lohnberechnung friseur", "stand_year": null}]}',
|
'{"text": "lohnberechnung friseur", "stand_year": null}]}',
|
||||||
original="Originalfrage?",
|
original="Originalfrage?",
|
||||||
)
|
)
|
||||||
assert [s.text for s in subs] == ["mindestlohn friseur", "lohnberechnung friseur"]
|
assert [s.text for s in subs] == ["mindestlohn friseur", "lohnberechnung friseur"]
|
||||||
assert subs[0].stand_year == "2024"
|
assert subs[0].stand_year == "2024"
|
||||||
assert subs[1].stand_year is None
|
assert subs[1].stand_year is None
|
||||||
|
assert qtype == "specific"
|
||||||
|
|
||||||
# Fallbacks: kaputtes JSON, leeres Array, leere Texte
|
# Fallbacks: kaputtes JSON, leeres Array, leere Texte
|
||||||
for raw in ("kein json", '{"queries": []}', '{"queries": [{"text": ""}]}'):
|
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 len(subs) == 1 and subs[0].text == "Originalfrage?"
|
||||||
|
assert qtype == "specific"
|
||||||
|
|
||||||
# Ungueltiges Jahr -> None erzwingen, Text bleibt
|
# Ungueltiges Jahr -> None erzwingen, Text bleibt; unbekannter scope -> None
|
||||||
subs = parse_plan(
|
subs, _ = parse_plan(
|
||||||
'{"queries": [{"text": "x", "stand_year": "98"}]}', original="orig"
|
'{"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():
|
def test_parse_plan_caps_at_three_queries():
|
||||||
raw = json.dumps(
|
raw = json.dumps(
|
||||||
{"queries": [{"text": f"q{i}"} for i in range(5)]}
|
{"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
|
assert len(subs) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_plan_queries_simple_question_no_llm_call():
|
def test_plan_queries_simple_question_no_llm_call():
|
||||||
client = FakeOllama(answers=[]) # darf nicht aufgerufen werden
|
client = FakeOllama(answers=[]) # darf nicht aufgerufen werden
|
||||||
cfg = Config(planner_enabled=True)
|
cfg = Config(planner_enabled=True)
|
||||||
subs, planned = plan_queries("Was ist Altersteilzeit?", client, cfg)
|
subs, planned, qtype = plan_queries("Was ist Altersteilzeit?", client, cfg)
|
||||||
assert planned is False
|
assert planned is False and qtype == "specific"
|
||||||
assert subs == [SubQuery(text="Was ist Altersteilzeit?")]
|
assert subs == [SubQuery(text="Was ist Altersteilzeit?")]
|
||||||
assert client.calls == 0
|
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():
|
def test_plan_queries_uses_planner_and_falls_back_on_error():
|
||||||
cfg = Config(planner_enabled=True)
|
cfg = Config(planner_enabled=True)
|
||||||
client = FakeOllama(
|
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
|
"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"
|
assert subs[0].text == "atz lohnausgleich"
|
||||||
|
|
||||||
# Fehler -> Originalfrage, geplant False
|
# Fehler -> Originalfrage, geplant False
|
||||||
failing = FakeOllama(answers=[])
|
failing = FakeOllama(answers=[])
|
||||||
failing.chat = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline"))
|
failing.chat = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline"))
|
||||||
subs, planned = plan_queries("Wie funktioniert der Lohnausgleich 2026?", failing, cfg)
|
subs, planned, qtype = plan_queries("Wie funktioniert der Lohnausgleich 2026?", failing, cfg)
|
||||||
assert planned is False
|
assert planned is False and qtype == "specific"
|
||||||
assert subs[0].text.startswith("Wie funktioniert")
|
assert subs[0].text.startswith("Wie funktioniert")
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +106,7 @@ def test_answer_question_planner_integration(mini_index):
|
|||||||
planner_max_queries=2,
|
planner_max_queries=2,
|
||||||
)
|
)
|
||||||
client = FakeOllama(answers=[
|
client = FakeOllama(answers=[
|
||||||
json.dumps({"queries": [
|
json.dumps({"type": "specific", "queries": [
|
||||||
{"text": "altersteilzeit lohnausgleich", "stand_year": None},
|
{"text": "altersteilzeit lohnausgleich", "stand_year": None},
|
||||||
{"text": "urlaubsanspruch", "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
|
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):
|
def test_search_multi_fuses_across_queries(mini_index):
|
||||||
"""Multi-Query-Retrieval: Sub-Queries summieren Beitraege; Treffer aus
|
"""Multi-Query-Retrieval: Sub-Queries summieren Beitraege; Treffer aus
|
||||||
beiden Themen erscheinen im Kontext."""
|
beiden Themen erscheinen im Kontext."""
|
||||||
|
|||||||
Reference in New Issue
Block a user