Hybrid-Zitate: verified/unverified statt drop + UI-Labels

reconstruct_zitate droppt Zitate nicht mehr bei No-Match, sondern
markiert sie als verified=false. Das ist ehrlicher: paraphrasierte
Zitate sind wertvoller Kontext, sie brauchen nur ein visuelles
Unterscheidungsmerkmal.

UI:
- Verifizierte Zitate: grüner solid Border, "✓ verifiziert"
- Paraphrasierte Zitate: gelber dashed Border, "~ paraphrasiert
  (nicht wörtlich im Programm)"
- Warning-Text: "Zu diesem Themenkomplex konnten keine konkreten
  Formulierungen im Wahlprogramm gefunden werden"
- Antragsteller:in / Landesregierung als farbige Badges

Zitat-Model: neues Optional[bool] Feld "verified".

Tests: 206 passed (test_drops angepasst auf neues Verhalten).
This commit is contained in:
Dotty Dotter 2026-04-10 21:45:36 +02:00
parent 9c162d14ac
commit f1a7da8544
4 changed files with 44 additions and 26 deletions

View File

@ -797,20 +797,18 @@ def find_chunk_for_text(text: str, chunks: list[dict]) -> Optional[dict]:
def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict: def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict:
"""Replace LLM-emitted quelle/url with canonical chunk values; drop unbacked. """Verify and reconstruct LLM-emitted zitate against retrieved chunks.
Walks over ``data['wahlprogrammScores'][i][kind]['zitate']`` (the raw For each Zitat:
LLM-output dict, not the Pydantic model). For each Zitat: * **verified** (substring/4-word-anchor match): overwrite quelle/url
with canonical chunk values, set ``verified: true``.
* **unverified** (no match found): keep the Zitat but set
``verified: false``. The UI shows it with a different style so the
user knows it's an LLM-Paraphrase, not a wörtliches Zitat.
* Locate the chunk whose text contains the snippet (or a 5-word anchor This replaces the old drop-on-no-match behavior (ADR 0001 Option B)
from it). Search across **all** retrieved chunks regardless of party, with a more honest approach: paraphrased citations are still valuable
so cross-mixes between Q-IDs become invisible to the persisted output. context, they just need to be marked as such.
* If found: overwrite ``quelle`` and ``url`` with values derived from
the matching chunk's ``programm_id`` + ``seite``. The LLM is no longer
trusted for these fields.
* If not found: drop the Zitat entirely.
Returns the same ``data`` dict (mutated in place) for chaining.
""" """
if not semantic_quotes: if not semantic_quotes:
return data return data
@ -830,12 +828,16 @@ def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict:
for z in zitate: for z in zitate:
text = z.get("text", "") text = z.get("text", "")
matched = find_chunk_for_text(text, all_chunks) matched = find_chunk_for_text(text, all_chunks)
if matched is None: if matched is not None:
continue
z["quelle"] = _chunk_source_label(matched) z["quelle"] = _chunk_source_label(matched)
url = _chunk_pdf_url(matched) url = _chunk_pdf_url(matched)
if url: if url:
z["url"] = url z["url"] = url
z["verified"] = True
else:
# Kein Match — Zitat behalten aber als unverified markieren.
# Die LLM-emittierte quelle/url bleibt (best effort).
z["verified"] = False
cleaned.append(z) cleaned.append(z)
blk["zitate"] = cleaned blk["zitate"] = cleaned
return data return data

View File

@ -45,6 +45,7 @@ class Zitat(BaseModel):
text: str text: str
quelle: str quelle: str
url: Optional[str] = None url: Optional[str] = None
verified: Optional[bool] = None # True=wörtlich im Chunk, False=paraphrasiert, None=pre-#97
class ProgrammScore(BaseModel): class ProgrammScore(BaseModel):

View File

@ -1619,14 +1619,22 @@
const wahlprogrammHtml = (item.wahlprogrammScores || []).map(wp => { const wahlprogrammHtml = (item.wahlprogrammScores || []).map(wp => {
// Zitate formatieren mit klickbaren Links + Highlighting // Zitate formatieren mit klickbaren Links + Highlighting
const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => ` const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => {
<div style="margin: 0.5rem 0; padding: 0.5rem; background: #f8f9fa; border-left: 3px solid #889e33; font-size: 0.85rem;"> const isVerified = z.verified !== false;
const borderColor = isVerified ? '#889e33' : '#ffc107';
const bgColor = isVerified ? '#f8f9fa' : '#fffbf0';
const badge = isVerified
? '<span style="font-size:0.7rem;color:#889e33;">✓ verifiziert</span>'
: '<span style="font-size:0.7rem;color:#b8860b;">~ paraphrasiert (nicht wörtlich im Programm)</span>';
return `
<div style="margin: 0.5rem 0; padding: 0.5rem; background: ${bgColor}; border-left: 3px ${isVerified ? 'solid' : 'dashed'} ${borderColor}; font-size: 0.85rem;">
<em>"${z.text}"</em><br> <em>"${z.text}"</em><br>
<a href="${makeCiteUrl(z, item.drucksache, item.bundesland)}" target="_blank" style="color: #009da5; font-size: 0.8rem;"> ${z.quelle ? `<a href="${makeCiteUrl(z, item.drucksache, item.bundesland)}" target="_blank" style="color: #009da5; font-size: 0.8rem;">
📄 ${z.quelle} 📄 ${z.quelle}
</a> </a>` : ''}
</div> ${badge}
`).join(''); </div>`;
}).join('');
// Issue #63: Transparenz-Warnung bei Score > 0 ohne Zitate. // Issue #63: Transparenz-Warnung bei Score > 0 ohne Zitate.
// Differenziert zwischen "Score 0 = keine Quellen" (LLM hat // Differenziert zwischen "Score 0 = keine Quellen" (LLM hat

View File

@ -316,8 +316,15 @@ class TestReconstructZitate:
} }
out = reconstruct_zitate(data, semantic_quotes) out = reconstruct_zitate(data, semantic_quotes)
zitate = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"] zitate = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"]
assert len(zitate) == 1 # Beide Zitate bleiben erhalten — das nicht-matchende wird als
assert "geschlechtersensiblen" in zitate[0]["text"] # unverified markiert statt gedroppt (Hybrid-Ansatz).
assert len(zitate) == 2
# Das halluzinierte Zitat ist unverified
halluziniert = [z for z in zitate if "Rechtsextremismus" in z["text"]]
assert halluziniert[0]["verified"] is False
# Das echte Zitat ist verified
echt = [z for z in zitate if "geschlechtersensiblen" in z["text"]]
assert echt[0]["verified"] is True
def test_empty_semantic_quotes_is_noop(self): def test_empty_semantic_quotes_is_noop(self):
data = {"wahlprogrammScores": [{ data = {"wahlprogrammScores": [{