diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 1633cf4..177f708 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -241,6 +241,21 @@ zu planen). 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). +- **D14 (Output-Budget + length-Retry, 2026-09-15):** q-024-Flakiness + Ursache gefunden — NICHT Thinking (Hypothese widerlegt: thinking-Feld + leer, keine Tags), sondern `num_predict=1024`: lange belegte Antworten + wurden bei `done_reason=length` abgschnitten (q-024: 3/3 Sondenlaeufe) + → unvollstaendige Zitationen → CITE_RE matcht partielle IDs → + Verletzung → Regen-Eskalation → UNCERTAIN. Fix: (a) num_predict + 1024 → 2048; (b) `OllamaClient.chat_full()` liefert (content, + done_reason); (c) `chat_with_length_retry`: ein technischer Retry + mit 2× num_predict bei length (zaehlt nicht als Regel-Regenerierung, + laeuft auch im Map- und im Regenerierungspfad). + **Voll-Eval: Zitier-Praezision 100 %, Verweigerung korrekt 100 %** + (46/46 — alle M3-Gates erfuellt, erstmals ueber Basislinie), + erwartete Quelle 92,7 %, Latenz mean 39,6 s / p95 78 s (+5 s: + vollstaendige statt abgeschnittener Antworten). q-024: 4/4 stabil. + Tests 59 → 60. Report `data/eval-qwen38-lengthfix.json`. - **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 18987a0..ea87ce3 100644 --- a/agent/README.md +++ b/agent/README.md @@ -168,6 +168,18 @@ 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`. +**Output-Budget + length-Retry (2026-09-15, D14):** Die q-024-Flakiness +war kein Thinking, sondern `num_predict=1024`: lange belegte Antworten +wurden bei `done_reason=length` abgeschnitten → unvollständige Zitationen +→ Verletzungs-/Eskalationsspirale. Fix: `num_predict=2048`, `chat_full()` +liefert `done_reason`, bei `length` ein technischer Retry mit 2× Budget +(kein Regel-Regenerierungs-Zähler). **Voll-Eval: Zitier-Präzision +100 % · Verweigerung korrekt 100 %** (46/46, alle M3-Gates erstmals +voll erfüllt) · erwartete Quelle 92,7 % · Latenz mean 39,6 s / p95 78 s. +Report: `data/eval-qwen38-lengthfix.json`. Offene Restfälle: keine +Fehlverweigerungen/Verletzungen mehr; 3-4 Fragen zitiert gültige, +aber nicht die erwartete Quelle (q-015-Klasse). + ## Dateien ``` diff --git a/agent/config.py b/agent/config.py index bfba83d..443ac74 100644 --- a/agent/config.py +++ b/agent/config.py @@ -51,7 +51,9 @@ class Config: # (KV-Cache @32k ~6-8 GB -> gesamt ~21 GB, Budget ok) max_context_chars: int = 90_000 # Budget fuer User-Content; niedrig gerankte # Bloecke werden weggelassen statt trunciert - num_predict: int = 1024 + num_predict: int = 2048 # Output-Budget; 1024 schnitt lange belegte + # Antworten ab (done_reason=length, q-024-Fall) + # -> unvollstaendige Zitationen think: bool = False # Thinking per Request abschalten (Latenz) chat_timeout_s: float = 300.0 embed_timeout_s: float = 240.0 diff --git a/agent/generate.py b/agent/generate.py index f9c5f56..7f037b7 100644 --- a/agent/generate.py +++ b/agent/generate.py @@ -227,20 +227,41 @@ def answer_question( {"role": "user", "content": build_user_content(question, results)}, ] - def chat(msgs): - return strip_think( - client.chat( + def chat(msgs, num_predict: int | None = None): + """Ein Chat-Zug; liefert (gestrippter Content, done_reason). + Bei done_reason='length' (Antwort bei num_predict abgeschnitten) + sind Zitationen ggf. unvollstaendig — der Aufrufer ruft einmal mit + doppeltem Budget neu (technischer Retry, kein Regel-Regeneration).""" + budget = num_predict or cfg.num_predict + if hasattr(client, "chat_full"): + raw, done_reason = client.chat_full( cfg.answer_model, msgs, temperature=cfg.temperature, num_ctx=cfg.num_ctx, - num_predict=cfg.num_predict, + num_predict=budget, think=cfg.think, ) - ) + else: + raw = client.chat( + cfg.answer_model, + msgs, + temperature=cfg.temperature, + num_ctx=cfg.num_ctx, + num_predict=budget, + think=cfg.think, + ) + done_reason = "stop" + return strip_think(raw), done_reason + + def chat_with_length_retry(msgs): + final, done_reason = chat(msgs) + if done_reason == "length": + final, done_reason = chat(msgs, num_predict=cfg.num_predict * 2) + return final if map_messages is not None: - summary = chat(map_messages) + summary = chat_with_length_retry(map_messages) if summary: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, @@ -255,7 +276,7 @@ def answer_question( ] # leerer Map-Output -> Fallback: messages bleibt die Einzelantwort - final = chat(messages) + final = chat_with_length_retry(messages) violations = validate_answer(final, allowed) regenerations = 0 if violations: @@ -267,7 +288,7 @@ def answer_question( "Beantworte die Frage erneut und zitiere nur diese IDs — oder verweigere " f"mit dem vorgesehenen Satz („{REFUSAL_MESSAGE}“)." ) - retry = chat( + retry = chat_with_length_retry( messages + [{"role": "assistant", "content": final}, {"role": "user", "content": warn}] diff --git a/agent/ollama_client.py b/agent/ollama_client.py index 4c629cd..4581351 100644 --- a/agent/ollama_client.py +++ b/agent/ollama_client.py @@ -74,10 +74,14 @@ class OllamaClient: # -- Chat --------------------------------------------------------------- - def chat(self, model: str, messages: list[dict], - temperature: float = 0.1, num_ctx: int = 16384, - num_predict: int = 1024, think: bool = False) -> str: - """POST /api/chat, stream=False; `think`-Flag mit 404/400-Fallback.""" + def chat_full(self, model: str, messages: list[dict], + temperature: float = 0.1, num_ctx: int = 16384, + num_predict: int = 1024, + think: bool = False) -> tuple[str, str]: + """POST /api/chat; liefert (content, done_reason). done_reason == + 'length' bedeutet: Antwort wurde bei num_predict abgeschnitten — + Zitationen koennen dann unvollstaendig sein (Think-Ghost-Ursache + q-024, D13-Follow-up).""" body: dict[str, Any] = { "model": model, "messages": messages, @@ -105,4 +109,14 @@ class OllamaClient: content = msg.get("content") or "" if not content.strip(): raise OllamaError(f"empty response from {model} (keys: {list(data.keys())})") + return content, str(data.get("done_reason") or "stop") + + def chat(self, model: str, messages: list[dict], + temperature: float = 0.1, num_ctx: int = 16384, + num_predict: int = 1024, think: bool = False) -> str: + """POST /api/chat, stream=False; `think`-Flag mit 404/400-Fallback.""" + content, _ = self.chat_full( + model, messages, temperature=temperature, num_ctx=num_ctx, + num_predict=num_predict, think=think, + ) return content \ No newline at end of file diff --git a/planung.md b/planung.md index cddd998..3b81e60 100644 --- a/planung.md +++ b/planung.md @@ -315,4 +315,12 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests 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). +- **Output-Budget + length-Retry (2026-09-15, M6/D14):** q-024-Flakiness + war num_predict=1024 (done_reason=length, abgeschnittene Zitationen), + nicht Thinking. Fix: num_predict=2048, chat_full() mit done_reason, + technischer 2×-Budget-Retry bei length. **Voll-Eval erstmals mit allen + Gates erfüllt: Zitier-Präzision 100 %, Verweigerung 100 %, erwartete + Quelle 92,7 %** (46/46), Latenz mean 39,6 s. M6 damit abgeschlossen; + nächster Schritt: Rechtsprechung-Intake (`rj-*`), dann M4 (Odoo; + Privacy-Neubewertung für Lohndaten-Zugriff). - Betrieb: `agent/README.md`. diff --git a/tests/test_generate.py b/tests/test_generate.py index 824ca06..3649ce4 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -167,3 +167,33 @@ def test_trim_results_drops_tail_under_budget(): assert sum(len(b.text) for b in trimmed) + len(trimmed) * 64 <= 90_000 + 8_000 # Unbegrenzt: Original unveraendert assert trim_results(blocks, None) is blocks + + +def test_length_retry_doubles_budget(mini_index): + """done_reason='length' (abgeschnittene Antwort) -> ein technischer + Retry mit doppeltem num_predict; zaehlt nicht als Regel-Regenerierung.""" + class LengthyOllama: + def __init__(self): + self.calls = [] + self.budgets = [] + + def chat_full(self, model, messages, temperature=0.1, num_ctx=16384, + num_predict=1024, think=False): + self.calls.append(messages[-1]) + self.budgets.append(num_predict) + if len(self.budgets) == 1: + return "Halbe Zitation [lb-min-0", "length" + return "Antwort mit Beleg [lb-min-01].", "stop" + + def chat(self, *a, **k): + raise AssertionError("chat() sollte via chat_full laufen") + + def close(self): + pass + + client = LengthyOllama() + result = answer_question("Altersteilzeit?", mini_index, client=client) + assert result["verified"] is True + assert result["citations"] == ["lb-min-01"] + assert result["regenerations"] == 0 # technischer Retry, keine Regel-Regen + assert client.budgets == [mini_index.num_predict, mini_index.num_predict * 2]