feat(#177): Programm-Treue im BELEGE-Layout — pro Partei zwei aufklappbare Blöcke (WP+PP)

- _row_to_detail liefert zitate inline pro Wahlprogramm/Parteiprogramm-Block
- Template rendert <details>: Summary mit Score-Chip, Body mit Einschätzung+Belege
- v2.css: neue Klassen v2-treue-block/-label/-body, v2-pill, v2-einschaetzung
- Separate "Belege — Partei"-Sektion entfernt (ist jetzt inline pro Programm)

Tests: tests/test_v2_pdf_consistency.py (#176 generalisiert) bleibt grün —
fraktions_scores trägt zusätzliche zitate-Felder, ändert aber keine
Score/Begründungs-Werte aus dem Vergleich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-07 09:21:42 +02:00
parent 38e58e4ee0
commit 70d9790b4b
4 changed files with 406 additions and 48 deletions

View File

@ -464,6 +464,17 @@ def _row_to_detail(row):
ist_reg = wp.get("istRegierung", wp.get("ist_regierung")) ist_reg = wp.get("istRegierung", wp.get("ist_regierung"))
if ist_reg is None: if ist_reg is None:
ist_reg = fraktion in regierung_set ist_reg = fraktion in regierung_set
# Zitate inline pro WP-/PP-Block (#177-Folge: Belege im Treue-Layout)
def _zitate_of(src: dict) -> list[dict]:
out = []
for z in (src.get("zitate") or []):
out.append({
"text": z.get("text", ""),
"source": z.get("quelle", ""),
"pdf_href": _build_pdf_href(z, bundesland),
})
return out
fraktions_scores.append({ fraktions_scores.append({
"fraktion": fraktion, "fraktion": fraktion,
"ist_antragsteller": ist_antrag, "ist_antragsteller": ist_antrag,
@ -472,11 +483,13 @@ def _row_to_detail(row):
"score": wp_src.get("score", 0), "score": wp_src.get("score", 0),
"begruendung": wp_src.get("begruendung", wp_src.get("begründung", "")), "begruendung": wp_src.get("begruendung", wp_src.get("begründung", "")),
"hat_zitate": bool(wp_src.get("zitate")), "hat_zitate": bool(wp_src.get("zitate")),
"zitate": _zitate_of(wp_src),
}, },
"parteiprogramm": { "parteiprogramm": {
"score": pp_src.get("score", 0), "score": pp_src.get("score", 0),
"begruendung": pp_src.get("begruendung", pp_src.get("begründung", "")), "begruendung": pp_src.get("begruendung", pp_src.get("begründung", "")),
"hat_zitate": bool(pp_src.get("zitate")), "hat_zitate": bool(pp_src.get("zitate")),
"zitate": _zitate_of(pp_src),
}, },
}) })

View File

@ -938,25 +938,111 @@ body.v2 ul.v2-manual ul li::before {
} }
.v2-fraktion-row { .v2-fraktion-row {
display: flex; display: block;
align-items: center; padding: 10px 12px;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
background: var(--paper); background: var(--paper);
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
} }
.v2-fraktion-head {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.v2-fraktion-label { .v2-fraktion-label {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 13px;
font-weight: 600; font-weight: 700;
letter-spacing: 0.02em;
}
/* Rolle-Pills (Antragsteller:in / Regierungsfraktion) */
.v2-pill {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.v2-pill-antrag {
color: #bf6c10;
background: rgba(247, 148, 29, 0.15);
}
.v2-pill-reg {
color: #1e6a90;
background: rgba(0, 157, 165, 0.15);
}
/* Aufklappbare Programm-Blöcke (Wahlprogramm/Parteiprogramm) */
.v2-treue-block {
border-top: 1px solid var(--hairline);
margin: 0;
}
.v2-treue-block:first-of-type {
border-top: none;
}
.v2-treue-block > summary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 8px;
min-width: 90px; padding: 8px 0;
cursor: pointer;
list-style: none;
user-select: none;
}
.v2-treue-block > summary::-webkit-details-marker {
display: none;
}
.v2-treue-label {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #0d6f76;
font-weight: 600;
}
.v2-treue-spacer {
flex: 1 1 auto;
}
.v2-treue-caret {
font-family: var(--font-mono);
font-size: 10px;
color: var(--ecg-dark);
opacity: 0.5;
transition: transform 0.15s ease;
}
.v2-treue-block[open] > summary .v2-treue-caret {
transform: rotate(180deg);
}
.v2-treue-body {
padding: 4px 0 10px;
font-size: 12.5px;
line-height: 1.5;
}
.v2-einschaetzung {
margin-bottom: 8px;
}
.v2-einschaetzung-label {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ecg-dark);
opacity: 0.6;
margin-bottom: 4px;
}
.v2-einschaetzung-text {
color: var(--ecg-dark);
line-height: 1.55;
}
.v2-belege {
margin-top: 6px;
} }
.v2-fraktion-scores { .v2-fraktion-scores {

View File

@ -353,55 +353,74 @@
{{ matrix_mini(antrag.matrix) }} {{ matrix_mini(antrag.matrix) }}
{% endif %} {% endif %}
{# Fraktions-Score-Tabelle: Score-Chips + ausgeschriebene Rolle + {# Programm-Treue im BELEGE-Layout: pro Partei zwei aufklappbare Blöcke
sichtbare Begründung für jeden Score (#177). #} (Wahlprogramm + Parteiprogramm). Summary zeigt Bewertung, expand
enthält Einschätzung + Belege. #}
{% if antrag.fraktions_scores %} {% if antrag.fraktions_scores %}
<h3 class="v2-h3" style="margin-top:24px;">Programm-Treue pro Fraktion</h3> <h3 class="v2-h3" style="margin-top:24px;">Programm-Treue pro Fraktion</h3>
<div class="v2-fraktions-scores"> <div class="v2-fraktions-scores">
{% for fs in antrag.fraktions_scores %} {% for fs in antrag.fraktions_scores %}
{% set wp_score = fs.wahlprogramm.score | float %} {% set wp_score = fs.wahlprogramm.score | float %}
{% set pp_score = fs.parteiprogramm.score | float %} {% set pp_score = fs.parteiprogramm.score | float %}
<div class="v2-fraktion-row" style="display:block;border-bottom:1px solid var(--hairline);padding:10px 0;"> <div class="v2-fraktion-row">
<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:6px;"> <div class="v2-fraktion-head">
<span class="v2-fraktion-label" style="font-weight:700;">{{ fs.fraktion }}</span> <span class="v2-fraktion-label">{{ fs.fraktion }}</span>
{% if fs.ist_antragsteller %}<span style="font-family:var(--font-mono);font-size:10px;color:#bf6c10;background:rgba(247,148,29,0.15);padding:1px 6px;border-radius:3px;">Antragsteller:in</span>{% endif %} {% if fs.ist_antragsteller %}<span class="v2-pill v2-pill-antrag">Antragsteller:in</span>{% endif %}
{% if fs.ist_regierung %}<span style="font-family:var(--font-mono);font-size:10px;color:#1e6a90;background:rgba(0,157,165,0.15);padding:1px 6px;border-radius:3px;">Regierungsfraktion</span>{% endif %} {% if fs.ist_regierung %}<span class="v2-pill v2-pill-reg">Regierungsfraktion</span>{% endif %}
<span style="margin-left:auto;display:inline-flex;gap:4px;">
<span class="v2-score-chip {% if wp_score >= 7 %}chip-green{% elif wp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">
WP {{ "%.0f"|format(wp_score) }}/10
</span>
<span class="v2-score-chip {% if pp_score >= 7 %}chip-green{% elif pp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">
PP {{ "%.0f"|format(pp_score) }}/10
</span>
</span>
</div> </div>
{% if fs.wahlprogramm.begruendung %}
<div style="font-size:12px;line-height:1.5;color:var(--ecg-dark);opacity:0.85;margin-bottom:4px;">
<strong style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:#0d6f76;">Wahlprogramm</strong>
— {{ fs.wahlprogramm.begruendung }}
</div>
{% endif %}
{% if fs.parteiprogramm.begruendung %}
<div style="font-size:12px;line-height:1.5;color:var(--ecg-dark);opacity:0.85;">
<strong style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:#0d6f76;">Parteiprogramm</strong>
— {{ fs.parteiprogramm.begruendung }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{# Zitate nach Partei gruppiert; Fraktion ohne Zitate erhält Hinweis via fraktions_scores-Block oben #} <details class="v2-treue-block">
{% if antrag.zitate %} <summary>
{% set current_partei = namespace(value="") %} <span class="v2-treue-label">Wahlprogramm</span>
{% for z in antrag.zitate %} <span class="v2-treue-spacer"></span>
{% if z.partei != current_partei.value %} <span class="v2-score-chip {% if wp_score >= 7 %}chip-green{% elif wp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">{{ "%.0f"|format(wp_score) }}/10</span>
{% set current_partei.value = z.partei %} <span class="v2-treue-caret" aria-hidden="true"></span>
<h3 class="v2-h3" style="margin-top:24px;">Belege — {{ z.partei }}</h3> </summary>
<div class="v2-treue-body">
{% if fs.wahlprogramm.begruendung %}
<div class="v2-einschaetzung">
<div class="v2-einschaetzung-label">Einschätzung</div>
<div class="v2-einschaetzung-text">{{ fs.wahlprogramm.begruendung }}</div>
</div>
{% endif %} {% endif %}
{{ quote_card(z.text, z.source, z.verified | default(True), z.contra | default(False), z.pdf_href | default("")) }} {% if fs.wahlprogramm.zitate %}
<div class="v2-belege">
<div class="v2-einschaetzung-label">Belege</div>
{% for z in fs.wahlprogramm.zitate %}
{{ quote_card(z.text, z.source, True, False, z.pdf_href) }}
{% endfor %} {% endfor %}
</div>
{% endif %}
</div>
</details>
<details class="v2-treue-block">
<summary>
<span class="v2-treue-label">Parteiprogramm</span>
<span class="v2-treue-spacer"></span>
<span class="v2-score-chip {% if pp_score >= 7 %}chip-green{% elif pp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">{{ "%.0f"|format(pp_score) }}/10</span>
<span class="v2-treue-caret" aria-hidden="true"></span>
</summary>
<div class="v2-treue-body">
{% if fs.parteiprogramm.begruendung %}
<div class="v2-einschaetzung">
<div class="v2-einschaetzung-label">Einschätzung</div>
<div class="v2-einschaetzung-text">{{ fs.parteiprogramm.begruendung }}</div>
</div>
{% endif %}
{% if fs.parteiprogramm.zitate %}
<div class="v2-belege">
<div class="v2-einschaetzung-label">Belege</div>
{% for z in fs.parteiprogramm.zitate %}
{{ quote_card(z.text, z.source, True, False, z.pdf_href) }}
{% endfor %}
</div>
{% endif %}
</div>
</details>
</div>
{% endfor %}
</div>
{% endif %} {% endif %}
{# ── News-Match-Box: aktuelle News passend zu diesem Antrag (#170) ── #} {# ── News-Match-Box: aktuelle News passend zu diesem Antrag (#170) ── #}

View File

@ -0,0 +1,240 @@
"""Konsistenz-Tests v2-Detail ↔ PDF (#176 generalisiert).
Stichprobe aus assessments. Pro Drucksache vergleicht der Test die
kritischen Felder:
- gwoe_score
- empfehlung
- Matrix-Cells (rating + abgeleitetes Symbol + abgeleitete Klasse)
- wahlprogramm_scores (pro Fraktion: WP-Score, PP-Score, Begründungen)
Quelle für v2: app.main._row_to_detail(row).
Quelle für PDF: app.models.Assessment.model_validate(row) + die
get_rating_symbol/get_rating_class-Funktionen aus app.report.
Wenn beide Renderer dieselben kritischen Werte aus row ziehen, gibt es
keine v2/PDF-Drift mehr.
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
import pytest
def _has_app() -> bool:
try:
from app import main, report # noqa
return True
except Exception:
return False
pytestmark = pytest.mark.skipif(not _has_app(), reason="app nicht importierbar")
def _live_rows(limit: int = 20) -> list[dict]:
"""Hole bis zu N Assessment-Rows direkt aus der prod-DB.
Der Test läuft nur wenn die Container-DB lokal mountbar ist
sonst skippen wir mit Hinweis (nicht failen).
"""
candidates = [
Path("/app/data/gwoe-antraege.db"), # innerhalb des Containers
Path(__file__).resolve().parent.parent / "data" / "gwoe-antraege.db",
]
db = next((p for p in candidates if p.exists()), None)
if not db:
pytest.skip(f"keine gwoe-antraege.db gefunden (versucht: {candidates})")
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM assessments WHERE gwoe_score IS NOT NULL "
"ORDER BY datum DESC LIMIT ?",
(limit,),
).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
# JSON-Felder deserialisieren wie _row_to_detail es erwartet
import json as _json
for k in ("fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
"wahlprogramm_scores", "verbesserungen", "staerken",
"schwaechen", "themen", "antrag_kernpunkte"):
if k in d and isinstance(d[k], str):
try:
d[k] = _json.loads(d[k])
except Exception:
d[k] = None
out.append(d)
return out
def _v2_score(row: dict):
"""Wert wie er im v2-Detail erscheint."""
return row.get("gwoe_score")
def _pdf_score(row: dict):
"""Wert wie er im PDF erscheint."""
from app.models import Assessment
a = Assessment.model_validate(row)
return a.gwoe_score
def _v2_empfehlung(row: dict):
return row.get("empfehlung")
def _pdf_empfehlung(row: dict):
from app.models import Assessment
a = Assessment.model_validate(row)
return a.empfehlung.value
def _v2_matrix_keys(row: dict) -> dict[str, tuple[int, str, str]]:
"""Dict {field: (rating, symbol, class)} wie es im v2-Template gerendert wird."""
out = {}
for cell in (row.get("gwoe_matrix") or []):
if not isinstance(cell, dict):
continue
field = cell.get("field")
if not field:
continue
rating_raw = cell.get("rating", 0)
try:
rating = int(rating_raw)
except (TypeError, ValueError):
rating = 0
if rating < -5: rating = -5
if rating > 5: rating = 5
if rating >= 4: sym, cls = "++", "m-pp"
elif rating >= 1: sym, cls = "+", "m-p"
elif rating == 0: sym, cls = "", "m-0"
elif rating <= -4: sym, cls = "", "m-nn"
else: sym, cls = "", "m-n"
out[field] = (rating, sym, cls)
return out
def _pdf_matrix_keys(row: dict) -> dict[str, tuple[int, str, str]]:
from app.models import Assessment
from app.report import get_rating_symbol, get_rating_class
a = Assessment.model_validate(row)
out = {}
# Mapping: rating-pp/p/0/n/nn ↔ m-pp/p/0/n/nn
cls_map = {
"rating-pp": "m-pp", "rating-p": "m-p", "rating-0": "m-0",
"rating-n": "m-n", "rating-nn": "m-nn",
}
for entry in a.gwoe_matrix:
sym = get_rating_symbol(entry.rating)
cls = cls_map.get(get_rating_class(entry.rating), "?")
out[entry.field] = (entry.rating, sym, cls)
return out
# ─── Tests ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def rows():
return _live_rows(limit=30)
def test_score_consistency(rows):
if not rows:
pytest.skip("DB leer")
for r in rows:
assert _v2_score(r) == _pdf_score(r), (
f"score-Drift bei {r.get('drucksache')}: "
f"v2={_v2_score(r)} pdf={_pdf_score(r)}"
)
def test_empfehlung_consistency(rows):
if not rows:
pytest.skip("DB leer")
for r in rows:
assert _v2_empfehlung(r) == _pdf_empfehlung(r), (
f"empfehlung-Drift bei {r.get('drucksache')}: "
f"v2={_v2_empfehlung(r)!r} pdf={_pdf_empfehlung(r)!r}"
)
def test_matrix_consistency(rows):
if not rows:
pytest.skip("DB leer")
drift_count = 0
drift_examples: list[str] = []
for r in rows:
v2 = _v2_matrix_keys(r)
pdf = _pdf_matrix_keys(r)
common_keys = set(v2.keys()) & set(pdf.keys())
for k in common_keys:
if v2[k] != pdf[k]:
drift_count += 1
drift_examples.append(
f"{r.get('drucksache')} {k}: v2={v2[k]} pdf={pdf[k]}"
)
if len(drift_examples) >= 10:
break
if len(drift_examples) >= 10:
break
assert drift_count == 0, (
f"{drift_count} Matrix-Drifts. Erste 10:\n "
+ "\n ".join(drift_examples)
)
def test_matrix_field_set_consistency(rows):
"""v2 und PDF rendern für jede Drucksache dieselben Matrix-Felder
(kein 'verschluckter' Cell durch unterschiedliche Schema-Lookups)."""
if not rows:
pytest.skip("DB leer")
for r in rows:
v2_keys = set(_v2_matrix_keys(r).keys())
pdf_keys = set(_pdf_matrix_keys(r).keys())
assert v2_keys == pdf_keys, (
f"Field-Set-Drift bei {r.get('drucksache')}: "
f"v2={sorted(v2_keys - pdf_keys)} pdf={sorted(pdf_keys - v2_keys)}"
)
def test_wahlprogramm_scores_consistency(rows):
"""Für jede Fraktion: WP-Score und PP-Score und Begründung gleich."""
if not rows:
pytest.skip("DB leer")
from app.models import Assessment
drift_count = 0
drift_examples: list[str] = []
for r in rows:
a = Assessment.model_validate(r)
# v2-Sicht: aus row.wahlprogramm_scores roh
v2_by_fr = {
(wp.get("fraktion") or ""): wp
for wp in (r.get("wahlprogramm_scores") or [])
if isinstance(wp, dict)
}
for s in a.wahlprogramm_scores:
v2 = v2_by_fr.get(s.fraktion or "")
if not v2:
continue
v2_wp = float((v2.get("wahlprogramm") or {}).get("score") or 0)
v2_pp = float((v2.get("parteiprogramm") or {}).get("score") or 0)
pdf_wp = float(s.wahlprogramm.score or 0)
pdf_pp = float(s.parteiprogramm.score or 0)
if v2_wp != pdf_wp or v2_pp != pdf_pp:
drift_count += 1
drift_examples.append(
f"{r.get('drucksache')} {s.fraktion}: "
f"v2_wp={v2_wp} pdf_wp={pdf_wp} v2_pp={v2_pp} pdf_pp={pdf_pp}"
)
if len(drift_examples) >= 10:
break
assert drift_count == 0, (
f"{drift_count} WP/PP-Score-Drifts. Erste 10:\n "
+ "\n ".join(drift_examples)
)