From 4b9c65c5f874faa1c8f94a0f2e5977e98fcd8912 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Sun, 10 May 2026 13:20:36 +0200 Subject: [PATCH] fix(citation-binding): Zitat aus Grundsatzprogramm wandert in den parteiprogramm-Block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/embeddings.py | 48 ++++++++++++++++++++++++++------------ tests/test_embeddings.py | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/app/embeddings.py b/app/embeddings.py index 3819bc4..257f063 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -705,11 +705,13 @@ def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict: Match-Reihenfolge pro Zitat: 1. Partei + exakte Programm-Kategorie (z.B. AfD-Parteiprogramm-Chunks fuer ein Zitat im AfD-Parteiprogramm-Block) → ``verified: true`` mit - kanonischer ``quelle``/``url`` aus dem Chunk. - 2. Partei + andere Programm-Kategorie (z.B. AfD hat nur Grundsatz-/ - Parteiprogramm im Index, der LLM hat den Text aber im Wahlprogramm- - Block emittiert) → ``verified: true`` mit korrigierter ``quelle``, - Block bleibt wie vom LLM gesetzt. + kanonischer ``quelle``/``url`` aus dem Chunk; Zitat bleibt im Block. + 2. Partei + andere Programm-Kategorie (z.B. der LLM hat einen + Grundsatzprogramm-Text in den Wahlprogramm-Block geschrieben) → + ``verified: true`` mit korrigierter ``quelle``, **Zitat wandert in + 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 Zitate als eines mit falscher Partei-Zuschreibung. Vorher wurde 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_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"): blk = fs.get(kind) 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" - fallback = partei_pool.get(cross_kind) or [] + secondary = partei_pool.get(cross_kind) or [] - cleaned = [] for z in zitate: text = z.get("text", "") or "" - # 1. Strikter Match in (Partei, eigenes Programm) - matched = find_chunk_for_text(text, allowed) if allowed else None - if matched is None and fallback: - # 2. Fallback: gleiche Partei, andere Programm-Kategorie - matched = find_chunk_for_text(text, fallback) + # 1. Match im Pool, der zum vom LLM gewaehlten Block passt + matched = find_chunk_for_text(text, primary) if primary else None + target_kind = kind + if matched is None and secondary: + # 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: # 3. Kein Match in der eigenen Partei → verwerfen. @@ -783,9 +796,14 @@ def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict: if url: z["url"] = url 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 diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index 92c163a..e86b31d 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -391,6 +391,56 @@ class TestReconstructZitate: z = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"][0] 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): chunk = self._make_chunk("x", 1, "egal was hier steht") assert find_chunk_for_text("ja", [chunk]) is None