Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen
Halluzinations-Case gezeigt, den A+C nicht gefangen hat:
BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche
im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des
Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen
Q-IDs.
Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im
Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle
aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern,
solange wir der LLM-Selbstauskunft vertrauen.
Fix (Option B aus dem ursprünglichen Plan):
`embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im
analyzer **nach** json.loads aber **vor** Pydantic-Validation:
1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste.
2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks
matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`,
identische Logik wie Sub-D Test).
3. Match → quelle/url server-seitig durch _chunk_source_label und
_chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN.
4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert).
Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt
keinen Pfad mehr zu "echter Text, falsche quelle".
Tests:
- TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei
hallucinated, no-op bei leeren chunks, anchor-match-Fallback,
short-needle und soft-hyphen Edge-Cases
- 185/185 grün (179 + 6 neu)
Refs: #60, #54 (Sub-D)
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)
Root cause: der #55-Refactor (eb045d0) hat in get_relevant_quotes_for_antrag
``partei_upper`` zu ``partei_lookup`` umbenannt — aber die Dict-Write-Zeile
``results[partei_upper] = ...`` wurde übersehen. Bei jedem Aufruf knallt seither
ein NameError, der in analyzer.py vom breiten ``except Exception`` verschluckt
und still auf die Keyword-Fallback-Suche umgeleitet wird. Konsequenz: 100% der
Assessments seit eb045d0 (inkl. autonomer Roadmap-Run #59) liefen ohne
Embedding-Retrieval — daher die LLM-Halluzinationen aus #60.
Fix:
- embeddings.py:528: partei_upper → partei_lookup
- analyzer.py:249: NameError/AttributeError/TypeError/KeyError nicht mehr
schlucken. Programmierfehler im Embedding-Pfad sollen hart fehlschlagen,
damit die nächste Refactor-Regression nicht wieder 24h still degradiert
läuft. Echte Network-/API-Exceptions fallen weiterhin auf den
Keyword-Pfad zurück.
- tests/test_embeddings.py: Regression-Test, der get_relevant_quotes_for_antrag
mit gemockten chunks aufruft und sicherstellt, dass die Funktion nicht
crasht und ein populiertes Result liefert. Hätte den Bug bei eb045d0
sofort gefangen.
Refs: #60, #55, #59
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Even after format_quotes_for_prompt was fixed to expose the
fully-qualified programme name + page in each chunk, Qwen
continued to hallucinate familiar source labels (typically "CDU
Wahlprogramm NRW 2022") for parties whose actual MV/BE chunks
were thematically off-topic for the Antrag at hand. The model
preferred its training prior over the prompt context.
Smoke test: MV Drucksache 8/6390 (CDU "Krisenmechanismus
Kraftstoffpreise"). The CDU MV chunks the embedder retrieved
were about Senioren and Aussenwirtschaft — not about energy
prices — so qwen pulled what it knew about CDU NRW transport
policy and wrote that as the source, even though the prompt
listed only "CDU Mecklenburg-Vorpommern Wahlprogramm 2021"
chunks. The new explicit ZITATEREGEL block in the user prompt
forbids cross-Bundesland citations and instructs the model to
leave zitate empty rather than fabricate when no listed chunk
fits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>