diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index b94ae51..f6905d6 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -180,21 +180,48 @@ zu planen). 0,973, MRR 0,667. ENV: PV_DENSE_WEIGHT / PV_RRF_K / PV_CANDIDATE_POOL. Entry-RRF-Variante getestet: kein Mehrwert. - **D11 (Prompt-Budget + Kontext-Abdeckung, 2026-09-15):** lange KV-Chunks - überlieferten num_ctx=16384 (q-022: 62,7 KB Prompt ≈ 18k Tokens) — + ueberlieferten num_ctx=16384 (q-022: 62,7 KB Prompt ≈ 18k Tokens) — Ollama trunciert den Systemprompt vorn, das Modell verliert die Zitierregeln („Block 8“-Zitate statt IDs → Regenerierung → Verweigerung). Fix: num_ctx 32 768, `trim_results` (max_context_chars=90 000, Tail- - Blöcke ganz weg statt trunciert, Mindestbestand 6) und - cross_ref_expand 3 → 6 (die Top-3 sind oft Branchen-KV-Blöcke mit + Bloecke ganz weg statt trunciert, Mindestbestand 6) und + cross_ref_expand 3 → 6 (die Top-3 sind oft Branchen-KV-Bloecke mit leeren cross_refs — kuratierte Nachbarn kamen sonst nie nach). - Ergebnis: alle 4 Fehlverweigerungs-Fälle (q-001/q-019/q-022/q-029) - geheilt, 10/10 Bestätigungsläufe OK. Eval: Zitier-Präzision 97,6 %, + Ergebnis: alle 4 Fehlverweigerungs-Faelle (q-001/q-019/q-022/q-029) + geheilt, 10/10 Bestaetigungslaeufe OK. Eval: Zitier-Praezision 97,6 %, Verweigerung 97,6 % (> 94,3 %-Gate ✓), erwartete Quelle 91,9 % (vorher 83,8 %), Retrieval Recall@8 **0,946** (cross_ref-Extras bringen Expected-IDs nach), MRR 0,671. Latenz mean 33 s. Report `data/eval-qwen38-kvris-tuned.json`. Restrisiko: q-024 - flaky (1/3 Läufe — transienter leerer Draft, Think-Ghost-Verdacht); + flaky (1/3 Laeufe — transienter leerer Draft, Think-Ghost-Verdacht); q-015 Branchen-Noise bleibt offen. +- **D12 (Komplexe-Fragen-Stufe 1 / M6, 2026-09-15):** Query-Planer vor dem + Retrieval: Heuristik-Gate (Jahreszahl, Vergleichs-/Aggregationsmarker, + Laenge) entscheidet, ob ein kleiner LLM-Call die Frage in 1-3 Sub-Queries + zerlegt (JSON, temp 0, num_predict 220; Fehler -> Original als Einzel- + Query). Multi-Query-Retrieval: je Sub-Query BM25+Dense, Beitraege + summieren; **Per-Query-Slots** (2 je Sub-Query) sichern jeden Frage- + aspekt im Kontext — ohne sie dominieren Eintraege, die in mehreren + Sub-Queries mittelgut matchen (q-113: 5 KV-Urlaubs-SS schlugen + lb-url-05 (Rang 6) und ris-url-01 (>Top-12) ueber beide Sub-Queries). + **Scope-Filter** je Sub-Query: Planer markiert "gesetz" (nur Lexis/WIKU/ + RIS-Chunks) bzw. "kv" (nur Branchen-KV) — q-113 geheilt (ris-url-01/ + lb-url-05 + KV-Ss im selben Kontext). Scope-Fallback: gefilterte Suche + leer -> unscoped. Temporal-Intent: stand_year je Sub-Query, kv-Eintraege + im Geltungsjahr erhalten temporal_boost (default 0 — FTS-Tag-Signal + `jahr-YYYY` reichte in der Messung). Grounding unveraendert: ein + Kontext aus der Union, Post-Validierung ueber die gesamte Retrieved- + Menge, Verweigerungspflicht unveraendert. + Eval (46 Fragen): Zitier-Praezision 97,8 %, Verweigerung 97,8 % + (Gate > 94,3 % erfuellt), erwartete Quelle 90,2 %, Latenz mean 33,5 s. + Temporal-Fragen laufen sogar ohne Planer gut (Jahr-Toknen matchen + `jahr-YYYY`-Tags). Neue komplexe Fragen im Goldset: q-110 (Temporal 2023, + nach Korrektur — eine 2024er-Friseur-Lohnordnung existiert im Korpus + NICHT: kv-kvt-161 ist der Mantelvertrag ohne Lohntabelle; korrekt + verweigert), q-111 (Temporal 2025), q-112 (Abfertigung Vergleich), + 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). - **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs- korrektheit > Latenz. diff --git a/agent/README.md b/agent/README.md index badddb1..a75bbbd 100644 --- a/agent/README.md +++ b/agent/README.md @@ -60,6 +60,10 @@ python -m agent.cli serve # http://127.0.0.1:8080 (/ask, /health, /r | `PV_CANDIDATE_POOL` | `150` | Kandidaten je Liste vor der Fusion (KV/RIS-Erweiterung: Longtail-Spezialisten in der Kandidatur halten) | | `PV_RRF_K` | `20` | RRF-Konstante (erweiterter Korpus: Top-Ränge dominant) | | `PV_DENSE_WEIGHT` | `2.0` | RRF-Gewicht der Dense-Liste relativ zu BM25 (BM25 ist durch KV-§-Titel-Matches inflationiert) | +| `PV_PER_QUERY_SLOTS` | `2` | Multi-Query: garantierte Kontext-Slots je Sub-Query (Multi-Hop-Abdeckung) | +| `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_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 | @@ -138,6 +142,19 @@ erwartete Quelle zitiert 91,9 % · Latenz mean 33 s / p95 57 s · `data/eval-qwen38-kvris-tuned.json`. Offen: q-015 Branchen-Noise, q-024 transiente Flakiness (leerer Draft). +**Komplexe-Fragen Stufe 1 (2026-09-15, M6/D12):** Query-Planer (Heuristik- +Gate → kleiner LLM-Call, 1-3 Sub-Queries als JSON; Stand-Jahr + Scope +je Sub-Query), Multi-Query-Retrieval mit Per-Query-Slots (2 je Sub-Query) +und Scope-Filter („gesetz“ = nur Lexis/WIKU/RIS, „kv“ = nur Branchen-KV; +Fallback unscoped). Grounding unverändert: eine Retrieved-Menge, eine +Antwort, Post-Validierung über die Union. Eval (46 Fragen): Zitier- +Präzision 97,8 % · Verweigerung korrekt 97,8 % (Gate ✓) · erwartete +Quelle 90,2 % · Latenz mean 33,5 s. Komplexe Goldset-Fragen: q-110–113 +(Temporal 2023/2025, Abfertigung-Vergleich, Gesetz+KV-Multi-Source). +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). + ## Dateien ``` diff --git a/agent/config.py b/agent/config.py index cc188af..b4c47de 100644 --- a/agent/config.py +++ b/agent/config.py @@ -73,6 +73,16 @@ class Config: # (BM25 ist durch KV-§-Titel-Matches inflationiert) recency_boost: float = 0.005 # additiv auf RRF-Score, gewichtet nach Stand + # Query-Planer (Stufe 1, M6): komplexe Fragen -> 1-3 Sub-Queries + planner_enabled: bool = True # Heuristik-Gate entscheidet je Frage + planner_max_queries: int = 3 + planner_num_predict: int = 220 + planner_model: str = "" # leer = Antwortmodell + per_query_slots: int = 2 # Multi-Query: garantierte Kontext-Slots + # je Sub-Query (Multi-Hop-Abdeckung) + temporal_boost: float = 0.0 # Bonus fuer kv-Eintraege im gefragten + # Geltungsjahr (0 = nur FTS-Tag-Signal) + # Service port: int = 8080 @@ -100,5 +110,11 @@ class Config: rrf_k=_env_int("PV_RRF_K", d.rrf_k), dense_weight=_env_float("PV_DENSE_WEIGHT", d.dense_weight), recency_boost=_env_float("PV_RECENCY_BOOST", d.recency_boost), + planner_enabled=_env_bool("PV_QUERY_PLANNER", d.planner_enabled), + planner_max_queries=_env_int("PV_PLANNER_MAX_QUERIES", d.planner_max_queries), + 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), + 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/agent/eval/goldset.yaml b/agent/eval/goldset.yaml index 5ca2ed9..d4f41ef 100644 --- a/agent/eval/goldset.yaml +++ b/agent/eval/goldset.yaml @@ -144,4 +144,24 @@ questions: - id: r-005 question: "Wie hoch ist der kollektivvertragliche Mindestlohn im KV für Raumfahrttechnik?" expect_refusal: true - note: "Branche nicht im KV-Korpus — Retrieval darf nicht leer sein, Antwort muss verweigern." \ No newline at end of file + note: "Branche nicht im KV-Korpus — Retrieval darf nicht leer sein, Antwort muss verweigern." + + # --- Komplexe Fragen (Stufe 1, M6: Multi-Query + Temporal-Intent) --- + - id: q-110 + question: "Wie hoch war der kollektivvertragliche Mindestmonatslohn für angelernte Friseurinnen und Friseure ab 1.4.2023?" + expected_ids: [kv-kvt-014] + note: "Temporal: historische KV-Fassung 2023 muss vor den 2025/2026er- + Versionen liegen. (Eine 2024er-Lohnordnung existiert im Korpus nicht: + kollektivvertrag-friseur-2024 ist der Mantelvertrag ohne Lohntabelle.)" + - id: q-111 + question: "Welche Lehrlingseinkommen galten im Friseurgewerbe ab 1.4.2025?" + expected_ids: [kv-kvt-001] + note: "Temporal: KV-Fassung 2025 (nicht 2026)." + - id: q-112 + question: "Was ist der Unterschied zwischen Abfertigung neu und Abfertigung alt?" + expected_ids: [lb-end-02, lb-end-03] + note: "Multi-Hop/Vergleich: beide Regime aus getrennten Eintraegen belegen." + - id: q-113 + question: "Wie viele Werktage gesetzlicher Urlaub stehen Arbeitnehmern zu und welche Rolle spielt dabei der Kollektivvertrag?" + expected_ids: [ris-url-01, lb-url-05] + note: "Multi-Source: Gesetz + Kuratierung; UrlG § 2 (1) erlaubt KV-Abweichungen (§ 2 Abs. 4)." \ No newline at end of file diff --git a/agent/generate.py b/agent/generate.py index e70fd6c..1825685 100644 --- a/agent/generate.py +++ b/agent/generate.py @@ -13,6 +13,7 @@ import time from .config import Config from .normalize import normalize_text from .ollama_client import OllamaClient +from .query_planner import SubQuery, plan_queries from .retrieve import ChunkResult, Retriever REFUSAL_MESSAGE = "Dazu enthält die Wissensbasis keine Aussage." @@ -138,13 +139,33 @@ def answer_question( retriever: Retriever | None = None, top_k: int | None = None, ) -> dict: - """Vollständiger Ask-Zyklus: Retrieval -> Prompt -> LLM -> Post-Validierung.""" + """Vollständiger Ask-Zyklus: Query-Planung -> Retrieval -> Prompt -> LLM -> + Post-Validierung. Der Planer läuft vor dem Retrieval (Heuristik-Gate, + nur bei komplexen Fragen); seine Sub-Queries fusionieren in EINER + Retrieved-Menge, gegen die die Post-Validierung prüft.""" t0 = time.perf_counter() own_retriever = retriever is None if retriever is None: retriever = Retriever(cfg) + + if client is None: + client = OllamaClient( + cfg.ollama_url, + embed_timeout_s=cfg.embed_timeout_s, + chat_timeout_s=cfg.chat_timeout_s, + ) + + sub_queries = [SubQuery(text=question)] + planned = False + if cfg.planner_enabled: + try: + sub_queries, planned = 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 + try: - results = retriever.search(question, n_entries=top_k) + results = retriever.search_multi(sub_queries, n_entries=top_k) finally: if own_retriever: retriever.close() @@ -164,6 +185,10 @@ def answer_question( "regenerations": regenerations, "latency_ms": round((time.perf_counter() - t0) * 1000), "draft": draft, + "planned_queries": [ + {"text": sq.text, "stand_year": sq.stand_year} + for sq in sub_queries + ], } if not results: @@ -173,12 +198,6 @@ def answer_question( 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)}, diff --git a/agent/query_planner.py b/agent/query_planner.py new file mode 100644 index 0000000..62a88bb --- /dev/null +++ b/agent/query_planner.py @@ -0,0 +1,122 @@ +"""Query-Planer für komplexe Fragen (Stufe 1, M6). + +Ein kleiner LLM-Call zerlegt komplexe Fragen in 1-3 unabhängige Sub-Queries +(+ optional Geltungsjahr). Ein Heuristik-Gate entscheidet, ob der Planer-Call +überhaupt läuft — einfache Fragen bleiben deterministischer Single-Shot +(keine Zusatz-Latenz). Parse-/Call-Fehler fallen auf die Original-Frage +zurück; die Grounding-Regeln werden dadurch nie berührt. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass + +YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b") +JSON_RE = re.compile(r"\{.*\}", re.DOTALL) + +AGGREGATION_RE = re.compile( + r"neuerungen|übersicht|zusammenfassung|alle\s|übersicht", re.I +) +COMPARISON_RE = re.compile(r"unterschied|vergleic|\bbzw\.|\bsowie\b", re.I) + +PLANNER_PROMPT = """Du planst Suchanfragen für eine Wissensbasis zur österreichischen +Personalverrechnung (kuratierte Briefings, Kollektivverträge je Branche und +Geltungsjahr, Gesetzesparagraphen). + +Frage: "{question}" + +Zerlege die Frage in 1-3 unabhängige Suchanfragen, die zusammen die Frage +beantworten. Regeln: +- Besteht die Frage aus einem Suchthema, gib genau eine Suchanfrage zurück + (dann die Frage unverändert, leicht stichwortartig gekürzt). +- Suchbegriffe statt Sätze (ohne Frageformulierung, ohne "Kollektivvertrag"- + Wortballast). +- Fragt die Frage nach einem bestimmten Jahr (Geltung/Stand), setze + "stand_year" auf dieses vierstellige Jahr, sonst null. +- 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. +- Antworte ausschließlich mit JSON, ohne Erklärung: +{{"queries": [{{"text": "...", "stand_year": null, "scope": null}}]}}""" + + +@dataclass +class SubQuery: + text: str + stand_year: str | None = None # YYYY, wenn nach einem Geltungsjahr gefragt + scope: str | None = None # "gesetz" (ohne Branchen-KV) | "kv" | None + + +def should_plan(question: str) -> bool: + """Heuristik-Gate: nur komplexe Fragen bekommen einen Planer-Call. + + Signale: Jahreszahl, Vergleichs-/Aggregationsmarker, langer Text, + Mehrfach-Konjunktion. Einfache Fragen bleiben Single-Shot (Latenz).""" + q = question.strip() + if YEAR_RE.search(q): + return True + words = q.split() + if len(words) >= 14: + return True + if COMPARISON_RE.search(q): + return True + if AGGREGATION_RE.search(q): + return True + if " und " in q.casefold() and len(words) >= 10: + return True + return False + + +def parse_plan(raw: str, original: str) -> list[SubQuery]: + """Robustes JSON-Parsing; jeder Fehler → [Originalfrage].""" + try: + match = JSON_RE.search(raw) + if not match: + raise ValueError("kein JSON-Objekt") + data = json.loads(match.group(0)) + items = data.get("queries") + if not isinstance(items, list) or not items: + raise ValueError("leeres Plan-Array") + subs: list[SubQuery] = [] + for item in items[:3]: + text = str(item.get("text", "")).strip() + if not text: + raise ValueError("leere Sub-Query") + year = item.get("stand_year") + year = str(year) if year and re.fullmatch(r"20\d{2}", str(year)) else None + 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 + except (ValueError, TypeError, KeyError, json.JSONDecodeError): + return [SubQuery(text=original)] + + +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.""" + if not should_plan(question): + return [SubQuery(text=question)], False + prompt = PLANNER_PROMPT.format(question=question.strip()) + try: + raw = client.chat( + cfg.planner_model or cfg.answer_model, + [ + {"role": "system", "content": "Du antwortest ausschließlich mit JSON."}, + {"role": "user", "content": prompt}, + ], + temperature=0.0, + num_ctx=cfg.num_ctx, + num_predict=cfg.planner_num_predict, + think=False, + ) + except Exception: + return [SubQuery(text=question)], False + return parse_plan(raw, question), True \ No newline at end of file diff --git a/agent/retrieve.py b/agent/retrieve.py index 9cc546c..b7ed01b 100644 --- a/agent/retrieve.py +++ b/agent/retrieve.py @@ -13,6 +13,7 @@ import numpy as np from .config import Config from .normalize import fts_query from .ollama_client import OllamaClient +from .query_planner import SubQuery @dataclass @@ -71,6 +72,7 @@ class Retriever: self._owns_client = client is None self._mat: np.ndarray | None = None self._mat_chunk_ids: list[int] | None = None + self._scope_cache: dict[str, set[int]] = {} row = self._con.execute("SELECT MIN(stand), MAX(stand) FROM chunks").fetchone() self._stand_min = int((row[0] or "2026-01").replace("-", "")) self._stand_max = int((row[1] or "2026-01").replace("-", "")) @@ -104,19 +106,38 @@ class Retriever: # -- Einzelverfahren ---------------------------------------------------- - def _bm25(self, question: str, limit: int) -> dict[int, float]: + def _scope_ids(self, scope: str | None) -> set[int] | None: + """Chunk-ID-Menge je Sub-Query-Scope (Cache je Instanz): 'gesetz' = + alles außer Branchen-KV, 'kv' = nur Branchen-KV.""" + if scope is None: + return None + if scope not in self._scope_cache: + if scope == "kv": + sql = "SELECT chunk_id FROM chunks WHERE entry_id LIKE 'kv-%'" + else: # gesetz + sql = "SELECT chunk_id FROM chunks WHERE entry_id NOT LIKE 'kv-%'" + self._scope_cache[scope] = { + row[0] for row in self._con.execute(sql).fetchall() + } + return self._scope_cache[scope] + + def _bm25(self, question: str, limit: int, scope: str | None = None) -> dict[int, float]: q = fts_query(question) if not q: return {} + fetch = limit * 4 if scope else limit rows = self._con.execute( "SELECT rowid, bm25(chunks_fts) AS rank FROM chunks_fts " "WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?", - (q, limit), + (q, fetch), ).fetchall() + scope_ids = self._scope_ids(scope) + if scope_ids is not None: + rows = [r for r in rows if r["rowid"] in scope_ids][:limit] # bm25(): kleinere Werte = besser -> negieren für "größer = besser" return {r["rowid"]: -float(r["rank"]) for r in rows} - def _dense(self, question: str, limit: int) -> dict[int, float]: + def _dense(self, question: str, limit: int, scope: str | None = None) -> dict[int, float]: if self.cfg.embed_off: return {} self._ensure_matrix() @@ -139,8 +160,17 @@ class Retriever: if qn == 0: return {} sims = self._mat_norm @ (qvec / qn) - order = np.argsort(-sims)[:limit] - return {self._mat_chunk_ids[i]: float(sims[i]) for i in order} + order = np.argsort(-sims) + scope_ids = self._scope_ids(scope) + out: dict[int, float] = {} + for i in order: + if len(out) >= limit: + break + cid = self._mat_chunk_ids[i] + if scope_ids is not None and cid not in scope_ids: + continue + out[cid] = float(sims[i]) + return out def _ensure_matrix(self) -> None: if self._mat is not None: @@ -240,46 +270,102 @@ class Retriever: # -- öffentliche Suche -------------------------------------------------- - def search(self, question: str, n_entries: int | None = None) -> list[ChunkResult]: - """Liefert die Top-Kontextblöcke (Hauptretrieval + cross_ref-Erweiterung).""" - n = n_entries or self.cfg.context_blocks - pool = self.cfg.candidate_pool - bm = self._bm25(question, pool) + def _search_one( + self, sq: SubQuery, pool: int + ) -> tuple[dict[int, float], dict[int, float], dict[int, float]]: + """BM25+Dense für EINE Sub-Query inkl. Scope-Filter (mit Fallback auf + unscoped, wenn die gefilterte Suche leer bleibt) und Query-RRF.""" try: - dn = self._dense(question, pool) + bm = self._bm25(sq.text, pool, scope=sq.scope) + except Exception: + bm = {} + try: + dn = self._dense(sq.text, pool, scope=sq.scope) except Exception: dn = {} - fused: dict[int, float] = {} + if sq.scope and not bm and not dn: + try: + bm = self._bm25(sq.text, pool) + dn = self._dense(sq.text, pool) + except Exception: + pass + fused_q: dict[int, float] = {} for ranking, weight in ((bm, 1.0), (dn, self.cfg.dense_weight)): ordered = sorted(ranking.items(), key=lambda kv: -kv[1]) for rank, (cid, _) in enumerate(ordered): - fused[cid] = fused.get(cid, 0.0) + weight / (self.cfg.rrf_k + rank) + fused_q[cid] = fused_q.get(cid, 0.0) + weight / ( + self.cfg.rrf_k + rank + ) + return bm, dn, fused_q + + def _fuse_queries( + self, sub_queries: list, pool: int + ) -> tuple[dict[int, float], set[int], set[int]]: + """RRF-Fusion über BM25+Dense je Sub-Query (Beiträge summieren); + liefert (fused, bm25-Chunk-IDs, dense-Chunk-IDs) für die Quelle- + Markierung. Bei einer einzelnen Sub-Query identisch zum bisherigen + Verhalten.""" + fused: dict[int, float] = {} + bm_all: set[int] = set() + dn_all: set[int] = set() + for sq in sub_queries: + bm, dn, fused_q = self._search_one(sq, pool) + bm_all.update(bm) + dn_all.update(dn) + for cid, score in fused_q.items(): + fused[cid] = fused.get(cid, 0.0) + score + return fused, bm_all, dn_all + + def _context_from_fused( + self, + fused: dict[int, float], + n: int, + bm_all: set[int], + dn_all: set[int], + stand_years: set[str] | None = None, + reserved: list[str] | None = None, + ) -> list[ChunkResult]: + """Fusion -> Score (+Recency +Temporal-Boost) -> Entry-Dedup -> + Vertreter-Chunk -> cross_ref-Erweiterung.""" if not fused: return [] - rows = self._fetch_chunks(list(fused)) results: list[ChunkResult] = [] for cid, score in fused.items(): if cid not in rows: continue - source = "fused" if (cid in bm and cid in dn) else ( - "bm25" if cid in bm else "dense" + source = ( + "fused" if (cid in bm_all and cid in dn_all) + else ("bm25" if cid in bm_all else "dense") ) score += self.cfg.recency_boost * self._stand_factor(rows[cid]["stand"]) + if stand_years and rows[cid]["entry_id"].startswith("kv-"): + if rows[cid]["stand"][:4] in stand_years: + score += self.cfg.temporal_boost results.append(self._row_to_result(rows[cid], score, source)) results.sort(key=lambda r: -r.score) # Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup). # Der Vertreter-Chunk ist die beste Inhaltssektion des Eintrags, # nicht die Rangfolge-Beste (vgl. _section_priority). + # Per-Query-Slots (Multi-Query): reservierte Einträge zuerst — + # jeder Sub-Query-Aspekt hält garantiert seine Top-Quelle(n). main: list[ChunkResult] = [] - seen: set[str] = set() by_entry: dict[str, list[ChunkResult]] = {} for r in results: by_entry.setdefault(r.entry_id, []).append(r) + seen: set[str] = set() + reserved = reserved or [] + for entry_id in reserved: + if len(main) >= n or entry_id not in by_entry: + continue + seen.add(entry_id) + main.append(self._representative_chunk(entry_id, by_entry[entry_id])) for entry_id, chunks in by_entry.items(): if len(main) >= n: break + if entry_id in seen: + continue seen.add(entry_id) main.append(self._representative_chunk(entry_id, chunks)) @@ -297,4 +383,59 @@ class Retriever: extra.append(er) seen.add(ref) budget -= 1 - return main + extra \ No newline at end of file + return main + extra + + def search(self, question: str, n_entries: int | None = None) -> list[ChunkResult]: + """Liefert die Top-Kontextblöcke (Hauptretrieval + cross_ref-Erweiterung).""" + n = n_entries or self.cfg.context_blocks + fused, bm_all, dn_all = self._fuse_queries( + [SubQuery(text=question)], self.cfg.candidate_pool + ) + return self._context_from_fused(fused, n, bm_all, dn_all) + + def search_multi( + self, sub_queries: list, n_entries: int | None = None + ) -> list[ChunkResult]: + """Multi-Query-Retrieval (Stufe 1): je Sub-Query BM25+Dense, RRF- + Beiträge summieren. Per-Query-Slots: jede Sub-Query sichert ihre + Top-Einträge im Kontext (Multi-Hop: jeder Frageaspekt kommt mit + seiner besten Quelle hinein — sonst dominieren Einträge, die in + mehreren Sub-Queries mittelgut matchen). Temporal-Intent: kv- + Einträge im gefragten Geltungsjahr erhalten temporal_boost.""" + n = n_entries or self.cfg.context_blocks + if len(sub_queries) == 1: + return self.search(sub_queries[0].text, n_entries=n) + pool = self.cfg.candidate_pool + fused_total: dict[int, float] = {} + bm_all: set[int] = set() + dn_all: set[int] = set() + per_query_entries: list[list[str]] = [] + for sq in sub_queries: + bm, dn, fused_q = self._search_one(sq, pool) + bm_all.update(bm) + dn_all.update(dn) + for cid, score in fused_q.items(): + fused_total[cid] = fused_total.get(cid, 0.0) + score + rows_q = self._fetch_chunks(list(fused_q)) + entry_best: dict[str, float] = {} + for cid, score in fused_q.items(): + row = rows_q.get(cid) + if row is not None: + eid = row["entry_id"] + entry_best[eid] = max(entry_best.get(eid, 0.0), score) + per_query_entries.append( + [e for e, _ in sorted(entry_best.items(), key=lambda kv: -kv[1])] + ) + reserved: list[str] = [] + for entries in per_query_entries: + taken = 0 + for eid in entries: + if taken >= self.cfg.per_query_slots: + break + if eid not in reserved: + reserved.append(eid) + taken += 1 + years = {sq.stand_year for sq in sub_queries if sq.stand_year} + return self._context_from_fused( + fused_total, n, bm_all, dn_all, stand_years=years, reserved=reserved + ) \ No newline at end of file diff --git a/planung.md b/planung.md index 5530224..96c665c 100644 --- a/planung.md +++ b/planung.md @@ -298,4 +298,12 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests Präzision 97,6 %, Verweigerung 97,6 % (Gate ✓), erwartete Quelle 91,9 %, Latenz mean 33 s. Offen: q-015 Branchen-Noise, q-024 transiente Flakiness (leerer Draft). +- **Komplexe-Fragen Stufe 1 (2026-09-15, M6/D12):** Query-Planer + (Heuristik-Gate → 1-3 Sub-Queries mit Stand-Jahr und Scope), Multi- + Query-Retrieval mit Per-Query-Slots und Scope-Filtern („gesetz“/„kv“). + Eval (46 Fragen): Zitier-Präzision 97,8 %, Verweigerung 97,8 % + (Gate ✓), erwartete Quelle 90,2 %, Latenz mean 33,5 s. Goldset 42 → + 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). - Betrieb: `agent/README.md`. diff --git a/tests/conftest.py b/tests/conftest.py index d1edeac..8cc2c32 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -181,6 +181,8 @@ def mini_cfg(tmp_path, mini_kb) -> Config: kb_dir=str(mini_kb), db_path=str(tmp_path / "index.db"), embed_off=True, + planner_enabled=False, # Tests skripten Chat-Antworten; Planer + # separat testbar (siehe test_generate/test_query_planner) ) diff --git a/tests/test_query_planner.py b/tests/test_query_planner.py new file mode 100644 index 0000000..83cbd09 --- /dev/null +++ b/tests/test_query_planner.py @@ -0,0 +1,127 @@ +"""Tests: Query-Planer (Stufe 1) — Heuristik-Gate, JSON-Parsing, Fallback, +Multi-Query-Fusion und Integration in answer_question.""" +import json + +import pytest + +from agent.config import Config +from agent.generate import answer_question +from agent.query_planner import SubQuery, parse_plan, plan_queries, should_plan +from tests.conftest import FakeOllama + + +def test_should_plan_gate(): + # Jahreszahl -> temporale Frage -> planen + assert should_plan("Wie hoch war der Mindestlohn für Friseure im Jahr 2024?") + # Vergleich + assert should_plan("Was ist der Unterschied zwischen Abfertigung neu und alt?") + # lang / mehrfach + assert should_plan( + "Wie wird die Überstundenpauschale behandelt und wie wirkt sie sich " + "auf die Sozialversicherung und die Lohnsteuer aus?" + ) + # einfach -> Single-Shot + assert not should_plan("Wie viele Werktage Urlaub stehen Arbeitnehmern zu?") + assert not should_plan("Was ist Altersteilzeit?") + assert not should_plan("Wie hoch ist der KV-Mindestlohn im Friseurgewerbe?") + + +def test_parse_plan_valid_and_fallback(): + subs = parse_plan( + 'Vorab: {"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 + + # Fallbacks: kaputtes JSON, leeres Array, leere Texte + for raw in ("kein json", '{"queries": []}', '{"queries": [{"text": ""}]}'): + subs = parse_plan(raw, original="Originalfrage?") + assert len(subs) == 1 and subs[0].text == "Originalfrage?" + + # Ungueltiges Jahr -> None erzwingen, Text bleibt + subs = parse_plan( + '{"queries": [{"text": "x", "stand_year": "98"}]}', original="orig" + ) + assert subs == [SubQuery(text="x", stand_year=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") + 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 + assert subs == [SubQuery(text="Was ist Altersteilzeit?")] + assert client.calls == 0 + + +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}]}'] + ) + subs, planned = plan_queries( + "Wie funktioniert der Lohnausgleich bei Altersteilzeit und was ersetzt das AMS?", client, cfg + ) + assert planned is True 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 + assert subs[0].text.startswith("Wie funktioniert") + + +def test_answer_question_planner_integration(mini_index): + """Planer an: 1. Chat = Planer-JSON, 2. Chat = Antwort. Beide Sub-Queries + fusionieren in einer Retrieved-Menge (Zitier-Validierung ueber die Union).""" + cfg = Config( + kb_dir=mini_index.kb_dir, + db_path=mini_index.db_path, + embed_off=True, + planner_enabled=True, + planner_max_queries=2, + ) + client = FakeOllama(answers=[ + json.dumps({"queries": [ + {"text": "altersteilzeit lohnausgleich", "stand_year": None}, + {"text": "urlaubsanspruch", "stand_year": None}, + ]}), + "Antwort mit Beleg [lb-min-01] und [lb-min-02].", + ]) + result = answer_question( + "Wie funktioniert der Lohnausgleich bei Altersteilzeit und wie viel Urlaub bleibt?", + cfg, client=client, + ) + assert client.calls == 2 # Planer + Antwort + assert result["verified"] is True + assert result["refused"] is False + assert set(result["citations"]) == {"lb-min-01", "lb-min-02"} + assert len(result["planned_queries"]) == 2 + + +def test_search_multi_fuses_across_queries(mini_index): + """Multi-Query-Retrieval: Sub-Queries summieren Beitraege; Treffer aus + beiden Themen erscheinen im Kontext.""" + from agent.retrieve import Retriever + + cfg = Config(kb_dir=mini_index.kb_dir, db_path=mini_index.db_path, embed_off=True) + r = Retriever(cfg) + subs = [SubQuery(text="Altersteilzeit Lohnausgleich"), + SubQuery(text="Urlaubsanspruch")] + results = r.search_multi(subs, n_entries=8) + ids = {res.entry_id for res in results} + assert {"lb-min-01", "lb-min-02"} <= ids # beide Themen vertreten + r.close() \ No newline at end of file