From db3ada93282f44284b35b9937f3b3a912f6c74c1 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Thu, 9 Apr 2026 22:21:39 +0200 Subject: [PATCH] =?UTF-8?q?#60=20Fix=20A+C:=20ENUM-basiertes=20Zitieren=20?= =?UTF-8?q?+=20top=5Fk=202=E2=86=925?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strukturelle Lösung für die LLM-Halluzinations-Cases aus #60: A — ENUM-Anker - format_quotes_for_prompt nummeriert jeden retrievten Chunk als [Q1], [Q2], … - Neue ZITATEREGEL im Prompt erzwingt vier Bedingungen: 1. Jedes Zitat MUSS auf genau einen [Qn]-Chunk verweisen 2. Der text-String MUSS eine wörtliche, zusammenhängende Passage von min. 5 Wörtern aus genau diesem Chunk sein 3. Die quelle MUSS exakt das Source-Label des gewählten Chunks sein 4. Wenn kein Chunk passt: leeres zitate-Array — lieber 0 als erfunden - analyzer.py:get_system_prompt: Wichtige-Regeln-Block zieht den selben Mechanismus nach, damit das LLM den [Qn]-Anker auch im System-Prompt sieht und nicht nur im User-Prompt. C — Recall-Boost - analyzer.py:run_analysis: top_k_per_partei 2 → 5. In den drei Cases aus #60 lagen die "richtigen" Seiten (S.36, S.37) bisher außerhalb des Top-3-Windows; mit Top-5 erhöht sich die Wahrscheinlichkeit, dass sie überhaupt im Kontext landen. Hintergrund — die Halluzinationen waren KEIN Embedding-Bug: Die retrievten Chunks für Case 1 enthielten S.58 (richtige Seite, falscher Snippet) — das LLM hat den Snippet aus seinem Trainingswissen über GRÜNE-Wahlprogramme rekonstruiert statt aus dem retrievten Chunk-Text zu zitieren. Cases 2/3 hatten die zitierten Seiten gar nicht im Top-3-Window — das LLM hat sowohl Seite als auch Snippet halluziniert. ENUM-Anker verhindert beides strukturell, weil ein nicht-existenter [Qn] sofort als Cheating sichtbar wäre. Tests: - test_chunks_get_enum_ids - test_zitateregel_mentions_enum_anchor - 179/179 grün Refs: #60, #54 (Sub-D), #50 (Umbrella E2E) --- app/analyzer.py | 4 ++-- app/embeddings.py | 30 +++++++++++++++++++++++++----- tests/test_embeddings.py | 21 ++++++++++++++++++++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/app/analyzer.py b/app/analyzer.py index 648d3b9..431af6c 100644 --- a/app/analyzer.py +++ b/app/analyzer.py @@ -153,7 +153,7 @@ Antworte NUR mit einem JSON-Objekt im folgenden Format (keine Markdown-Codeblöc ## Wichtige Regeln - **Verbesserungsvorschläge**: Maximal 3! Fokussiere auf die wirkungsvollsten Änderungen, die den GWÖ-Score am meisten verbessern würden. -- **Zitate**: Nur echte Textstellen aus den Wahlprogrammen verwenden (werden als Kontext mitgeliefert). +- **Zitate**: Jedes Zitat MUSS auf einen `[Qn]`-Chunk aus dem mitgelieferten Kontext verweisen und den `text`-String **wörtlich** (mind. 5 zusammenhängende Wörter) aus genau diesem Chunk übernehmen. Kein Paraphrasieren, kein Cross-Referencing aus dem Trainingswissen. Wenn kein Chunk passt: lass `zitate` leer — lieber 0 Zitate als ein erfundenes. Die ausführliche ZITATEREGEL steht im wahlprogramm_zitate-Block. - **Matrix-Bewertung**: Bewerte nur Felder, die der Antrag tatsächlich berührt. Nicht jeder Antrag betrifft alle 25 Felder. - **Gesamtscore-Berechnung**: Der gwoeScore (0-10) berücksichtigt die Matrix-Bewertungen: - Wenn EIN Feld -4 oder -5 hat → Gesamtscore maximal 3/10 @@ -243,7 +243,7 @@ async def analyze_antrag(text: str, bundesland: str = "NRW", model: str = "qwen- if EMBEDDINGS_DB.exists(): try: semantic_quotes = get_relevant_quotes_for_antrag( - text, fraktionen, bundesland=bundesland, top_k_per_partei=2, + text, fraktionen, bundesland=bundesland, top_k_per_partei=5, ) quotes_context = format_quotes_for_prompt(semantic_quotes) except (NameError, AttributeError, TypeError, KeyError): diff --git a/app/embeddings.py b/app/embeddings.py index 8d4594a..cd67e17 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -550,6 +550,13 @@ def _chunk_source_label(chunk: dict) -> str: def format_quotes_for_prompt(quotes: dict) -> str: """Format quotes for inclusion in LLM prompt. + Each chunk gets a stable ENUM-ID ([Q1], [Q2], …) and the prompt + instructs the LLM to anchor every citation in one of those IDs and + to copy the snippet **verbatim** from the cited chunk. This is the + structural fix for Issue #60: pre-#60 the LLM was free to invent + snippets under real source labels because nothing in the prompt + bound a citation to a specific retrieved chunk. + Each quote is annotated with the fully-qualified source (programme name + page) so the LLM cannot fall back on training-set defaults when constructing its citations. @@ -559,25 +566,38 @@ def format_quotes_for_prompt(quotes: dict) -> str: lines = ["\n## Relevante Passagen aus Wahl- und Parteiprogrammen\n"] lines.append( - "Verwende **ausschließlich** die hier gelisteten Quellenangaben " - "(Programm-Name + Seite) wörtlich in deinen Zitaten — erfinde " - "keine Quellen aus dem Gedächtnis.\n" + "**ZITATEREGEL** — verbindlich für alle Zitate in `wahlprogramm`/" + "`parteiprogramm`-Blöcken:\n" + "1. Jedes Zitat MUSS auf genau einen der unten aufgelisteten " + "Chunks verweisen (Format `[Q1]`, `[Q2]`, …).\n" + "2. Der `text`-String MUSS eine **wörtliche, zusammenhängende** " + "Passage von mindestens 5 Wörtern aus genau diesem Chunk sein — " + "keine Paraphrasen, keine Zusammenfassungen, keine " + "Cross-References aus dem Gedächtnis.\n" + "3. Der `quelle`-String MUSS exakt das Source-Label des " + "gewählten Chunks sein (Programm-Name + Seitenzahl, wie unten " + "ausgeschrieben).\n" + "4. Wenn kein Chunk wirklich passt: lass das Zitat-Array leer. " + "Lieber 0 Zitate als ein erfundenes Zitat.\n" ) + counter = 0 for partei, data in quotes.items(): lines.append(f"\n### {partei}\n") if data.get("wahlprogramm"): lines.append("**Wahlprogramm:**") for chunk in data["wahlprogramm"]: + counter += 1 text = chunk["text"][:500] + "..." if len(chunk["text"]) > 500 else chunk["text"] - lines.append(f'- {_chunk_source_label(chunk)}: "{text}"') + lines.append(f'- [Q{counter}] {_chunk_source_label(chunk)}: "{text}"') if data.get("parteiprogramm"): lines.append("\n**Grundsatzprogramm:**") for chunk in data["parteiprogramm"]: + counter += 1 text = chunk["text"][:500] + "..." if len(chunk["text"]) > 500 else chunk["text"] - lines.append(f'- {_chunk_source_label(chunk)}: "{text}"') + lines.append(f'- [Q{counter}] {_chunk_source_label(chunk)}: "{text}"') return "\n".join(lines) diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index 5c48123..ee7349a 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -123,7 +123,26 @@ class TestFormatQuotesForPrompt: def test_contains_strict_citation_instruction(self): """The prompt header must explicitly forbid hallucinated sources.""" out = format_quotes_for_prompt(EXAMPLE_QUOTES) - assert "ausschließlich" in out.lower() or "verbatim" in out.lower() or "wörtlich" in out.lower() + assert "wörtlich" in out.lower() + + def test_chunks_get_enum_ids(self): + """Issue #60 fix: each chunk must be tagged with a stable [Qn] id + so the LLM can be forced to anchor every citation in a specific + retrieved chunk instead of inventing snippets from training data. + """ + out = format_quotes_for_prompt(EXAMPLE_QUOTES) + # 2 wahlprogramm chunks + 1 grundsatz chunk = 3 IDs total + assert "[Q1]" in out + assert "[Q2]" in out + assert "[Q3]" in out + assert "[Q4]" not in out # only 3 chunks in EXAMPLE_QUOTES + + def test_zitateregel_mentions_enum_anchor(self): + out = format_quotes_for_prompt(EXAMPLE_QUOTES) + # The prompt header must mention the ENUM anchor mechanism so + # the LLM understands what [Qn] means. + assert "[Q" in out + assert "ZITATEREGEL" in out def test_no_nrw_2022_appears_unless_chunks_are_actually_nrw(self): """Sanity: a pure MV+SPD chunk set must not mention NRW anywhere."""