fix(citation-binding): Zitat aus Grundsatzprogramm wandert in den parteiprogramm-Block

Bug auf Antrag 18/18246: Bei den GRUENEN landeten unter 'Wahlprogramm'
zwei Zitate — eines aus dem NRW-Wahlprogramm 2022, eines aus dem
Bundes-Grundsatzprogramm 2020. Letzteres haette in den parteiprogramm-Block
gehoert.

Ursache in reconstruct_zitate: bei einem Cross-Kind-Fallback-Match
wurde nur die quelle korrigiert, das Zitat blieb aber im urspruenglichen
Block (dort wo der LLM es hingeschrieben hatte).

Fix: zwei Pass-Verarbeitung. Pass 1 sammelt alle Zitate ueber beide
Bloecke und klassifiziert nach tatsaechlich matchendem Pool, Pass 2
schreibt sie in die jeweils passenden Bloecke. Damit landen Wahl- und
Grundsatzprogramm-Zitate konsequent in getrennten Bloecken.

Test: tests/test_embeddings.py::TestReconstructZitate::test_zitat_aus_grundsatzprogramm_landet_im_parteiprogramm_block reproduziert den 18/18246-Fall.
This commit is contained in:
Dotty Dotter 2026-05-10 13:20:36 +02:00
parent b7256b6d24
commit 4b9c65c5f8
2 changed files with 83 additions and 15 deletions

View File

@ -705,11 +705,13 @@ def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict:
Match-Reihenfolge pro Zitat: Match-Reihenfolge pro Zitat:
1. Partei + exakte Programm-Kategorie (z.B. AfD-Parteiprogramm-Chunks 1. Partei + exakte Programm-Kategorie (z.B. AfD-Parteiprogramm-Chunks
fuer ein Zitat im AfD-Parteiprogramm-Block) ``verified: true`` mit fuer ein Zitat im AfD-Parteiprogramm-Block) ``verified: true`` mit
kanonischer ``quelle``/``url`` aus dem Chunk. kanonischer ``quelle``/``url`` aus dem Chunk; Zitat bleibt im Block.
2. Partei + andere Programm-Kategorie (z.B. AfD hat nur Grundsatz-/ 2. Partei + andere Programm-Kategorie (z.B. der LLM hat einen
Parteiprogramm im Index, der LLM hat den Text aber im Wahlprogramm- Grundsatzprogramm-Text in den Wahlprogramm-Block geschrieben)
Block emittiert) ``verified: true`` mit korrigierter ``quelle``, ``verified: true`` mit korrigierter ``quelle``, **Zitat wandert in
Block bleibt wie vom LLM gesetzt. den passenden Block**, sodass `wahlprogramm.zitate` nur Zitate aus
Wahlprogrammen enthaelt und `parteiprogramm.zitate` nur Zitate aus
Grundsatz-/Parteiprogrammen.
3. Kein Match in der eigenen Partei **Zitat verwerfen**. Lieber 0 3. Kein Match in der eigenen Partei **Zitat verwerfen**. Lieber 0
Zitate als eines mit falscher Partei-Zuschreibung. Vorher wurde Zitate als eines mit falscher Partei-Zuschreibung. Vorher wurde
solche Zitate als ``verified: false`` mit der LLM-quelle behalten solche Zitate als ``verified: false`` mit der LLM-quelle behalten
@ -752,22 +754,33 @@ def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict:
partei_name = fs.get("fraktion", "") partei_name = fs.get("fraktion", "")
partei_pool = _pool_for(partei_name) partei_pool = _pool_for(partei_name)
# Zwei-Pass-Verarbeitung: erst alle Zitate sammeln und nach
# tatsaechlichem Programmtyp klassifizieren (basierend auf welchem
# Chunk-Pool sie matchen), dann erst in die Bloecke schreiben.
# Damit landet ein Zitat aus dem Grundsatzprogramm immer im
# parteiprogramm-Block, auch wenn der LLM es ins wahlprogramm
# gepackt hatte.
classified: dict[str, list] = {"wahlprogramm": [], "parteiprogramm": []}
for kind in ("wahlprogramm", "parteiprogramm"): for kind in ("wahlprogramm", "parteiprogramm"):
blk = fs.get(kind) or {} blk = fs.get(kind) or {}
zitate = blk.get("zitate") or [] zitate = blk.get("zitate") or []
allowed = partei_pool.get(kind) or [] primary = partei_pool.get(kind) or []
cross_kind = "parteiprogramm" if kind == "wahlprogramm" else "wahlprogramm" cross_kind = "parteiprogramm" if kind == "wahlprogramm" else "wahlprogramm"
fallback = partei_pool.get(cross_kind) or [] secondary = partei_pool.get(cross_kind) or []
cleaned = []
for z in zitate: for z in zitate:
text = z.get("text", "") or "" text = z.get("text", "") or ""
# 1. Strikter Match in (Partei, eigenes Programm) # 1. Match im Pool, der zum vom LLM gewaehlten Block passt
matched = find_chunk_for_text(text, allowed) if allowed else None matched = find_chunk_for_text(text, primary) if primary else None
if matched is None and fallback: target_kind = kind
# 2. Fallback: gleiche Partei, andere Programm-Kategorie if matched is None and secondary:
matched = find_chunk_for_text(text, fallback) # 2. Fallback: gleiche Partei, andere Kategorie —
# Zitat wandert in den passenden Block.
matched = find_chunk_for_text(text, secondary)
if matched is not None:
target_kind = cross_kind
if matched is None: if matched is None:
# 3. Kein Match in der eigenen Partei → verwerfen. # 3. Kein Match in der eigenen Partei → verwerfen.
@ -783,9 +796,14 @@ def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict:
if url: if url:
z["url"] = url z["url"] = url
z["verified"] = True z["verified"] = True
cleaned.append(z) classified[target_kind].append(z)
# Klassifizierte Zitate in die jeweils passenden Bloecke schreiben.
for kind in ("wahlprogramm", "parteiprogramm"):
blk = fs.get(kind) or {}
blk["zitate"] = classified[kind]
fs[kind] = blk
blk["zitate"] = cleaned
return data return data

View File

@ -391,6 +391,56 @@ class TestReconstructZitate:
z = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"][0] z = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"][0]
assert z["quelle"] == "CDU NRW Wahlprogramm 2022, S. 24" assert z["quelle"] == "CDU NRW Wahlprogramm 2022, S. 24"
def test_zitat_aus_grundsatzprogramm_landet_im_parteiprogramm_block(self):
"""Wenn der LLM ein Zitat aus dem Grundsatzprogramm in den
wahlprogramm-Block schreibt, darf reconstruct_zitate das nicht
einfach mit korrigierter quelle dort lassen das Zitat muss
in den parteiprogramm-Block wandern, sonst stehen WP- und
Grundsatzprogramm-Quellen vermischt unter 'Wahlprogramm'.
Reproduzierte aus 18/18246 (NRW), Bewertung GRÜNE."""
wp_chunk = self._make_chunk(
"gruene-nrw-2022", 64,
"es darf nicht laenger vom wohnort abhaengen ob kinder die chance auf eine beitragsfreie ganztaegige bildung bekommen",
)
# Grundsatzprogramm = typ='parteiprogramm' im Pool
gs_chunk = {
"programm_id": "gruene-grundsatz",
"partei": "GRÜNE",
"typ": "parteiprogramm",
"seite": 94,
"text": "alle kitas und schulen in deutschland sollen sich zu inklusiven orten weiterentwickeln",
"similarity": 0.7,
}
semantic_quotes = {
"GRÜNE": {"wahlprogramm": [wp_chunk], "parteiprogramm": [gs_chunk]},
}
data = {
"wahlprogrammScores": [{
"fraktion": "GRÜNE",
"wahlprogramm": {
"score": 10, "begründung": "...",
"zitate": [
{"text": "Alle Kitas und Schulen in Deutschland sollen sich zu inklusiven Orten weiterentwickeln",
"quelle": "Grüne Grundsatzprogramm 2020, S. 94"}, # eigentlich Grundsatz!
{"text": "Es darf nicht laenger vom Wohnort abhaengen ob Kinder die Chance auf eine beitragsfreie ganztaegige Bildung bekommen",
"quelle": "Grüne NRW Wahlprogramm 2022, S. 64"},
],
},
"parteiprogramm": {"score": 10, "begründung": "...", "zitate": []},
}],
}
out = reconstruct_zitate(data, semantic_quotes)
wp_zitate = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"]
pp_zitate = out["wahlprogrammScores"][0]["parteiprogramm"]["zitate"]
# WP-Block: nur das Wahlprogramm-Zitat
assert len(wp_zitate) == 1, f"WP-Block hat {len(wp_zitate)} Zitate, erwartet 1"
assert "Wohnort" in wp_zitate[0]["text"]
# PP-Block: das Grundsatz-Zitat ist hierhin gewandert
assert len(pp_zitate) == 1, f"PP-Block hat {len(pp_zitate)} Zitate, erwartet 1"
assert "inklusiven" in pp_zitate[0]["text"]
def test_find_chunk_for_text_short_needle_returns_none(self): def test_find_chunk_for_text_short_needle_returns_none(self):
chunk = self._make_chunk("x", 1, "egal was hier steht") chunk = self._make_chunk("x", 1, "egal was hier steht")
assert find_chunk_for_text("ja", [chunk]) is None assert find_chunk_for_text("ja", [chunk]) is None