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:
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user