#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:
parent
ed64399dbb
commit
db3ada9328
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user