#60 Fix A+C: ENUM-basiertes Zitieren + top_k 2→5

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)
This commit is contained in:
Dotty Dotter 2026-04-09 22:21:39 +02:00
parent ed64399dbb
commit db3ada9328
3 changed files with 47 additions and 8 deletions

View File

@ -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):

View File

@ -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)

View File

@ -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."""