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:
parent
9c162d14ac
commit
f1a7da8544
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": [{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user