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:
parent
b7256b6d24
commit
4b9c65c5f8
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user