#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate

Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."

Fix C — Force-Honesty im Prompt:

- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
  Parteien, für die kein Chunk retrievt wurde, werden explizit als
  "KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
  zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
  Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
  searched_parties durchgereicht.

Fix B — UI-Transparenz:

- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
  wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
  "Keine belegbaren Quellen im Index gefunden — Score basiert auf
  LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
  keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
  idealerweise Score=0 haben, aber die Warning ist ein Fallback für
  den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.

Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.

Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).

Refs: #63, ADR 0001
This commit is contained in:
Dotty Dotter 2026-04-10 09:32:31 +02:00
parent 45379a2639
commit 92dcd25f73
3 changed files with 51 additions and 5 deletions

View File

@ -251,7 +251,9 @@ async def analyze_antrag(text: str, bundesland: str = "NRW", model: str = "qwen-
semantic_quotes = get_relevant_quotes_for_antrag(
text, fraktionen, bundesland=bundesland, top_k_per_partei=5,
)
quotes_context = format_quotes_for_prompt(semantic_quotes)
quotes_context = format_quotes_for_prompt(
semantic_quotes, searched_parties=fraktionen,
)
except (NameError, AttributeError, TypeError, KeyError):
# Programmierfehler (z.B. der partei_upper-Refactor-Rest aus
# #55/eb045d0, der zu Issue #60 führte) sollen hart fehlschlagen

View File

@ -769,7 +769,10 @@ def reconstruct_zitate(data: dict, semantic_quotes: dict) -> dict:
return data
def format_quotes_for_prompt(quotes: dict) -> str:
def format_quotes_for_prompt(
quotes: dict,
searched_parties: Optional[list[str]] = None,
) -> str:
"""Format quotes for inclusion in LLM prompt.
Each chunk gets a stable ENUM-ID ([Q1], [Q2], ) and the prompt
@ -782,8 +785,14 @@ def format_quotes_for_prompt(quotes: dict) -> str:
Each quote is annotated with the fully-qualified source (programme
name + page) so the LLM cannot fall back on training-set defaults
when constructing its citations.
Issue #63 erweitert: wenn ``searched_parties`` übergeben wird, werden
Parteien, für die **kein** Chunk retrievt wurde, im Prompt explizit
als "keine Quellen im Index" markiert. Das LLM wird angewiesen, für
diese Parteien ``score: null`` zu setzen statt aus dem Trainingswissen
zu raten.
"""
if not quotes:
if not quotes and not searched_parties:
return ""
lines = ["\n## Relevante Passagen aus Wahl- und Parteiprogrammen\n"]
@ -801,6 +810,11 @@ def format_quotes_for_prompt(quotes: dict) -> str:
"ausgeschrieben).\n"
"4. Wenn kein Chunk wirklich passt: lass das Zitat-Array leer. "
"Lieber 0 Zitate als ein erfundenes Zitat.\n"
"5. **Wenn für eine Fraktion unten KEINE QUELLEN VORHANDEN "
"steht**: setze `score: 0` für `wahlprogramm` UND "
"`parteiprogramm` dieser Fraktion und schreibe in die "
"`begründung`: 'Keine Quellen im Index — Bewertung nicht "
"möglich.' Erfinde KEINEN Score aus dem Trainingswissen.\n"
)
counter = 0
@ -821,6 +835,21 @@ def format_quotes_for_prompt(quotes: dict) -> str:
text = chunk["text"][:500] + "..." if len(chunk["text"]) > 500 else chunk["text"]
lines.append(f'- [Q{counter}] {_chunk_source_label(chunk)}: "{text}"')
# Issue #63: Parteien ohne jegliche retrievte Chunks explizit markieren,
# damit das LLM nicht aus Trainingswissen halluziniert.
if searched_parties:
parties_with_chunks = set(quotes.keys())
missing = [p for p in searched_parties if p not in parties_with_chunks]
if missing:
lines.append("\n### KEINE QUELLEN VORHANDEN\n")
lines.append(
"Für folgende Fraktionen sind weder Wahl- noch "
"Grundsatzprogramm-Passagen im Index vorhanden. "
"Bewerte sie mit `score: 0` und `zitate: []`:\n"
)
for p in missing:
lines.append(f"- **{p}**: KEINE QUELLEN — score 0, keine Zitate.")
return "\n".join(lines)

View File

@ -1508,17 +1508,32 @@
</a>
</div>
`).join('');
// Issue #63: Transparenz-Warnung bei Score > 0 ohne Zitate.
// Differenziert zwischen "Score 0 = keine Quellen" (LLM hat
// Force-Honesty befolgt) und "Score > 0 aber 0 Zitate" (LLM
// hat trotzdem geratet oder Zitate wurden von reconstruct_zitate
// verworfen). Nur bei Scores > 0 warnen, weil Score 0 schon
// selbsterklärend ist.
const wpScore = wp.wahlprogramm?.score ?? 0;
const wpZitateCount = (wp.wahlprogramm?.zitate || []).length;
const noQuotesWarning = (wpScore > 0 && wpZitateCount === 0) ? `
<div style="margin: 0.5rem 0; padding: 0.5rem; background: #fff3cd; border-left: 3px solid #ffc107; font-size: 0.8rem; color: #856404;">
&#9888; Keine belegbaren Quellen im Index gefunden — Score basiert auf LLM-Einschätzung, nicht auf verifizierten Programm-Stellen.
</div>
` : '';
return `
<div style="margin: 0.75rem 0; padding: 0.75rem; background: var(--color-bg); border-radius: 4px;">
<strong>${wp.fraktion}</strong>
${wp.istAntragsteller ? ' <span style="color:#889e33">(Antragsteller)</span>' : ''}
${wp.istRegierung ? ' <span style="color:#009da5">(Regierung)</span>' : ''}<br>
<div style="margin: 0.5rem 0;">
Wahlprogramm: <strong>${wp.wahlprogramm?.score || '-'}/10</strong> ·
Wahlprogramm: <strong>${wp.wahlprogramm?.score || '-'}/10</strong> ·
Parteiprogramm: <strong>${wp.parteiprogramm?.score || '-'}/10</strong>
</div>
${wp.wahlprogramm?.begründung ? `<div style="font-size: 0.9rem; color: #555; margin-bottom: 0.5rem;">${wp.wahlprogramm.begründung}</div>` : ''}
${noQuotesWarning}
${zitateHtml}
</div>
`}).join('');