gwoe-antragspruefer/app/templates/v2/screens/antrag_detail.html

1235 lines
60 KiB
HTML
Raw Normal View History

{% extends "v2/base.html" %}
{% from "v2/components/score_hero.html" import score_hero %}
{% from "v2/components/matrix_mini.html" import matrix_mini %}
{% from "v2/components/quote_card.html" import quote_card %}
{% from "v2/components/kasten.html" import kasten %}
{% from "v2/components/redline.html" import redline %}
{% block title %}
{% if antrag is defined and antrag %}{{ antrag.title }} — {% endif %}GWÖ-Antragsprüfer
{% endblock %}
{% block head_extra %}
{% if antrag is defined and antrag %}
{# ── Open-Graph / Twitter-Card-Meta (#141) ────────────────────────── #}
{% set _og_img = "/api/og/" ~ (antrag.drucksache | urlencode) ~ ".png" %}
<meta property="og:type" content="article">
<meta property="og:title" content="{{ antrag.title }} — GWÖ-Antragsprüfer">
<meta property="og:description" content="GWÖ-Score {{ '%.1f'|format(antrag.score|float) }}/10 — {{ antrag.zusammenfassung | truncate(160, True) }}">
<meta property="og:image" content="{{ _og_img }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{% if antrag.updated_at_raw %}
<meta property="og:updated_time" content="{{ antrag.updated_at_raw }}">
{% endif %}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ antrag.title }} — GWÖ-Antragsprüfer">
<meta name="twitter:description" content="GWÖ-Score {{ '%.1f'|format(antrag.score|float) }}/10 — {{ antrag.zusammenfassung | truncate(160, True) }}">
<meta name="twitter:image" content="{{ _og_img }}">
{% endif %}
{% endblock %}
{% set v2_active_nav = "durchsuchen" %}
{% block main %}
{# ── Fehlerfall ──────────────────────────────────────────────────── #}
{% if error is defined and error %}
<div class="v2-kasten" style="border-color:var(--redline-contra);margin-top:32px;">
<h3 style="color:var(--redline-contra);">Antrag nicht gefunden</h3>
<p>{{ error }}</p>
<p><a href="/">← Zurück zur Übersicht</a></p>
</div>
{% elif antrag is not defined or not antrag %}
{# ── Demo-Daten wenn kein echtes Antrag-Objekt übergeben ────────── #}
{% set antrag = {
"drucksache": "18/4412",
"bundesland": "BW",
"parlament": "Landtag",
"typ": "Antrag",
"datum": "12.04.2026",
"analysiert": "14.04.2026",
"modell": "qwen-plus",
"parteien": ["GRÜNE", "SPD"],
"zitate_count": 3,
"title": "Kommunale Wärmeplanung bis 2028 verpflichtend machen",
"score": 9.1,
"verdict_title": "Vorbildlich",
"verdict_body": "Starker Beitrag zur ökologischen Nachhaltigkeit und Transparenz auf kommunaler Ebene.",
"zusammenfassung": "Der Antrag verpflichtet Kommunen ab 10 000 Einwohner:innen zur Erstellung einer kommunalen Wärmeplanung bis Ende 2028.",
"staerkster_wert": {
"titel": "Ökologische Nachhaltigkeit",
"text": "Verpflichtende Wärmeplanung führt zu messbaren Klimazielen. E3 = ++, D3 = ++."
},
"schwaechster_wert": {
"titel": "Soziale Gerechtigkeit",
"text": "Kostenverteilung auf Mieter:innen versus Eigentümer:innen ist im Antrag nicht geregelt."
},
"redline": {
"segments": [
{"type": "ctx", "text": "§ 3 Abs. 2 "},
{"type": "del", "text": "auf Antrag"},
{"type": "ins", "text": "verpflichtend"},
{"type": "ctx", "text": " eine sozialverträgliche Umlage"}
]
},
"matrix": {
"A1": {"rating": 0, "symbol": "○"}, "A2": {"rating": 1, "symbol": "+"},
"A3": {"rating": 2, "symbol": "++"}, "A4": {"rating": 0, "symbol": "○"},
"A5": {"rating": 1, "symbol": "+"},
"B1": {"rating": 0, "symbol": "○"}, "B2": {"rating": 1, "symbol": "+"},
"B3": {"rating": 2, "symbol": "++"}, "B4": {"rating": -1, "symbol": ""},
"B5": {"rating": 1, "symbol": "+"},
"C1": {"rating": 0, "symbol": "○"}, "C2": {"rating": 1, "symbol": "+"},
"C3": {"rating": 1, "symbol": "+"}, "C4": {"rating": 0, "symbol": "○"},
"C5": {"rating": 2, "symbol": "++"},
"D1": {"rating": 1, "symbol": "+"}, "D2": {"rating": 1, "symbol": "+"},
"D3": {"rating": 2, "symbol": "++"}, "D4": {"rating": 1, "symbol": "+"},
"D5": {"rating": 2, "symbol": "++"},
"E1": {"rating": 1, "symbol": "+"}, "E2": {"rating": 2, "symbol": "++"},
"E3": {"rating": 2, "symbol": "++"}, "E4": {"rating": 1, "symbol": "+"},
"E5": {"rating": 1, "symbol": "+"}
},
"zitate": [
{
"text": "Wir verpflichten alle Kommunen zu einer verbindlichen kommunalen Wärmeplanung bis 2028.",
"source": "Wahlprogramm GRÜNE 2022 · S. 84",
"partei": "GRÜNE",
"verified": True,
"contra": False,
"pdf_href": "/api/wahlprogramm-cite?pid=gruene-nrw-2022&seite=84&q=Wärmeplanung"
}
],
"verbesserungen": [],
"staerken": [],
"schwaechen": []
} %}
{# Fallthrough: Demo-Daten rendern wie echte Daten #}
{% set _render = True %}
{% else %}
{% set _render = True %}
{% endif %}
{# ── Eigentlicher Detail-Inhalt ──────────────────────────────────── #}
{% if _render is defined and _render and antrag is defined and antrag %}
{# ── Zurück-Link ─────────────────────────────────────────────────── #}
<p style="font-family:var(--font-mono);font-size:11px;margin-bottom:20px;">
<a href="/">← Zurück zur Übersicht</a>
</p>
{# ── Split-Layout ────────────────────────────────────────────────── #}
<div class="v2-detail">
{# ── Linke Spalte: Redaktionelle Analyse ── #}
<div class="left">
<div class="v2-antrag-id">
{{ antrag.bundesland | default("") }}
{% if antrag.drucksache %} · Drs. {{ antrag.drucksache }}{% endif %}
{% if antrag.typ %} · {{ antrag.typ }}{% endif %}
{% if antrag.datum %} · eingebracht {{ antrag.datum }}{% endif %}
</div>
<h1 class="v2-big-title">{{ antrag.title | default("Antrag") }}</h1>
{% if antrag.parteien or antrag.analysiert %}
<div class="v2-byline">
{% if antrag.parteien %}Eingebracht von {{ antrag.parteien | join(", ") }}{% endif %}
{% if antrag.analysiert %} — Analyse {{ antrag.analysiert }}{% endif %}
{% if antrag.modell %}, {{ antrag.modell }}{% endif %}
{% if antrag.zitate_count %} · {{ antrag.zitate_count }} Zitat{{ "e" if antrag.zitate_count != 1 else "" }} verifiziert{% endif %}
</div>
{% endif %}
{% if antrag.zusammenfassung %}
<h3 class="v2-h3">Zusammenfassung</h3>
<p style="font-size:14.5px;line-height:1.55">{{ antrag.zusammenfassung }}</p>
{% endif %}
{# Stärkster Wert #}
{% if antrag.staerkster_wert and antrag.staerkster_wert.text %}
<div class="v2-kasten outline-green" style="margin-top:20px;">
<h4>Stärkster Wert{% if antrag.staerkster_wert.titel %} — {{ antrag.staerkster_wert.titel }}{% endif %}</h4>
<p>{{ antrag.staerkster_wert.text }}</p>
</div>
{% elif antrag.staerken %}
<div class="v2-kasten outline-green" style="margin-top:20px;">
<h4>Stärken</h4>
<ul style="margin:0;padding-left:1.2em;">
{% for s in antrag.staerken %}
<li style="font-size:13.5px;line-height:1.5;">{{ s }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Schwächster Wert #}
{% if antrag.schwaechster_wert and antrag.schwaechster_wert.text %}
<div class="v2-kasten outline-blue">
<h4>Schwächster Wert{% if antrag.schwaechster_wert.titel %} — {{ antrag.schwaechster_wert.titel }}{% endif %}</h4>
<p>{{ antrag.schwaechster_wert.text }}</p>
</div>
{% elif antrag.schwaechen %}
<div class="v2-kasten outline-blue">
<h4>Schwächen</h4>
<ul style="margin:0;padding-left:1.2em;">
{% for s in antrag.schwaechen %}
<li style="font-size:13.5px;line-height:1.5;">{{ s }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Verbesserungsvorschläge: alle verbesserungen rendern wenn vorhanden #}
{% if antrag.verbesserungen %}
<h3 class="v2-h3" style="margin-top:24px;">Verbesserungsvorschläge</h3>
{% for v in antrag.verbesserungen %}
<div style="margin-bottom:16px;">
{% if antrag.verbesserungen | length > 1 %}
<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.65;margin-bottom:4px;">
Vorschlag {{ loop.index }} von {{ antrag.verbesserungen | length }}
</div>
{% endif %}
{% from "v2/components/redline.html" import redline %}
{% if v.segments %}
{{ redline(original=v.original | default(""), segments=v.segments) }}
{% else %}
{{ redline(original=v.original | default(""), vorschlag=v.vorschlag | default("")) }}
{% endif %}
{% if v.begruendung %}
<p style="font-size:12px;color:var(--ecg-dark);opacity:0.75;margin:4px 0 0;font-family:var(--font-mono);">
{{ v.begruendung }}
</p>
{% endif %}
</div>
{% endfor %}
{% elif antrag.redline and antrag.redline.segments %}
<h3 class="v2-h3" style="margin-top:24px;">Verbesserungsvorschlag</h3>
{% from "v2/components/redline.html" import redline %}
{{ redline(segments=antrag.redline.segments) }}
{% endif %}
</div>{# .left #}
{# ── Rechte Spalte: Bewertungs-Panel ── #}
<div class="right">
{# Bewertungs-Header mit Merken-Button rechts — visuelle Einheit
(User-Wunsch: „Bewertung passend zu Merken verschieben"). #}
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
<div class="v2-antrag-id">Bewertung</div>
<button id="v2-merkliste-btn"
onclick="v2DetailMerklisteToggle()"
style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;
border:1px solid var(--hairline);border-radius:4px;background:none;
cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
<span id="v2-merkliste-star"></span>
<span id="v2-merkliste-label">Merken</span>
</button>
</div>
{{ score_hero(antrag.score | default(0), antrag.verdict_title | default(""), antrag.verdict_body | default("")) }}
{# ── Namentliche Abstimmung (#106 Phase 1) ── #}
{% if antrag.abstimmungsverhalten %}
{% set aw = antrag.abstimmungsverhalten %}
<h3 class="v2-h3" style="margin-top:24px;">Namentliche Abstimmung</h3>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.75;margin-bottom:10px;">
{% if aw.datum %}{{ aw.datum }} · {% endif %}
{% if aw.accepted %}Angenommen{% else %}Abgelehnt{% endif %}
</div>
{% for f in aw.fraktionen %}
{% set total = (f.yes + f.no + f.abstain + f.no_show) | int %}
{% if total > 0 %}
<div style="margin-bottom:8px;">
<div style="font-family:var(--font-mono);font-size:11px;margin-bottom:3px;display:flex;justify-content:space-between;">
<span>{{ f.partei }}</span>
<span style="opacity:0.65;">{{ f.yes }}✓ {{ f.abstain }}○ {{ f.no }}✗</span>
</div>
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--ecg-light,#f0f0f0);">
{% if f.yes > 0 %}
<div style="flex:{{ f.yes }};background:#2da44e;" title="{{ f.yes }} Ja"></div>
{% endif %}
{% if f.abstain > 0 %}
<div style="flex:{{ f.abstain }};background:#adb5bd;" title="{{ f.abstain }} Enthaltung"></div>
{% endif %}
{% if f.no > 0 %}
<div style="flex:{{ f.no }};background:#cf222e;" title="{{ f.no }} Nein"></div>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}{# abstimmungsverhalten #}
{# ── Fraktions-aggregierte Plenum-Abstimmung aus Plenarprotokoll (#106) ── #}
{% if antrag.plenum_votes %}
<h3 class="v2-h3" style="margin-top:24px;">Abstimmungsergebnis</h3>
{% set ergebnis_color = {
"angenommen": "#2da44e",
"abgelehnt": "#cf222e",
"überwiesen": "#0969da",
"zurückgezogen": "#8250df",
"bestätigt": "#2da44e",
"sammel": "#0969da",
} %}
{# Konsistenz-Hinweis: GWÖ-Empfehlung vs. tatsächlicher Beschluss.
Logik in app/marker.py — siehe ADR 0010. #}
{% set _state = consistency_state(antrag.verdict_title, antrag.plenum_votes) %}
{% set _decisive = decisive_outcome(antrag.plenum_votes) %}
{% if _state %}
<div style="margin-bottom:10px;padding:8px 12px;border-radius:6px;font-size:12px;line-height:1.5;
background:{% if _state == 'conflict' %}color-mix(in srgb,#cf222e 8%,transparent){% else %}color-mix(in srgb,#2da44e 8%,transparent){% endif %};
border-left:3px solid {% if _state == 'conflict' %}#cf222e{% else %}#2da44e{% endif %};">
<strong>{% if _state == 'conflict' %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %}</strong>
— Empfohlen: <em>{{ antrag.verdict_title }}</em>; Beschluss: <em>{{ _decisive | capitalize }}</em>.
</div>
{% endif %}
{% for v in antrag.plenum_votes %}
<div style="border:1px solid var(--hairline);border-radius:6px;padding:12px 14px;margin-bottom:10px;background:var(--paper);">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
<span style="font-family:var(--font-display);font-size:14px;font-weight:700;color:{{ ergebnis_color.get(v.ergebnis, '#6e7781') }};">
{{ v.ergebnis | capitalize }}{% if v.einstimmig %} · einstimmig{% endif %}
</span>
{% if v.quelle_url %}
<a href="{{ v.quelle_url }}" target="_blank" rel="noopener"
class="v2-quelle-link"
style="font-family:var(--font-mono);font-size:10px;color:var(--ecg-blue);text-decoration:none;border-bottom:1px solid rgba(0,157,165,0.35);"
title="Plenarprotokoll im neuen Tab öffnen ({{ v.quelle_url }})">
{{ v.quelle_protokoll }} <span aria-hidden="true"></span>
</a>
{% else %}
<span style="font-family:var(--font-mono);font-size:10px;opacity:0.6;">{{ v.quelle_protokoll }}</span>
{% endif %}
</div>
{% if v.fraktionen_ja or v.fraktionen_nein or v.fraktionen_enthaltung %}
{# Mehrheits-Bar: Fraktions-Anzahlen pro Lager als Stacked Bar #}
{% set _n_ja = v.fraktionen_ja | length %}
{% set _n_nein = v.fraktionen_nein | length %}
{% set _n_enth = v.fraktionen_enthaltung | length %}
{% set _n_total = _n_ja + _n_nein + _n_enth %}
{% if _n_total > 0 %}
<div style="margin:6px 0 10px;">
<div style="display:flex;height:10px;border-radius:3px;overflow:hidden;border:1px solid var(--hairline);"
title="Fraktions-Mehrheit: {{ _n_ja }} Ja · {{ _n_nein }} Nein · {{ _n_enth }} Enth. (kein Sitz-/Stimm-Anteil)">
{% if _n_ja %}<div style="width:{{ (100 * _n_ja / _n_total) }}%;background:#2da44e;"></div>{% endif %}
{% if _n_enth %}<div style="width:{{ (100 * _n_enth / _n_total) }}%;background:#6e7781;"></div>{% endif %}
{% if _n_nein %}<div style="width:{{ (100 * _n_nein / _n_total) }}%;background:#cf222e;"></div>{% endif %}
</div>
<div style="font-family:var(--font-mono);font-size:9px;opacity:0.5;margin-top:3px;">
{{ _n_ja }}/{{ _n_total }} Fraktionen Ja · {{ _n_nein }} Nein · {{ _n_enth }} Enth.
<span style="opacity:0.7;cursor:help;" title="Mehrheit nach Fraktionsanzahl, nicht nach Sitzen — Plenarprotokoll liefert keine Sitz-/Stimm-Counts."></span>
</div>
</div>
{% endif %}
<div style="display:flex;flex-wrap:wrap;gap:12px;font-family:var(--font-mono);font-size:11px;">
{% if v.fraktionen_ja %}
<div><span style="color:#2da44e;font-weight:700;">Ja:</span>
{% for f in v.fraktionen_ja %}
{% set _opp_match = opportunismus_score(f, antrag.fraktions_scores) %}
<span class="v2-vote-pill v2-vote-ja">{{ f }}{% if _opp_match is not none %}<span class="v2-vote-marker v2-marker-opp" tabindex="0" role="button" aria-label="Opportunismus-Hinweis: Ja trotz schwacher Wahlprogramm-Übereinstimmung" title="Opportunismus-Marker — Diese Fraktion stimmte mit Ja, obwohl der Antrag nicht zum eigenen Wahlprogramm passt (WP-Score {{ '%.0f' | format(_opp_match) }}/10).">!</span>{% endif %}</span>
{% endfor %}
</div>
{% endif %}
{% if v.fraktionen_nein %}
<div><span style="color:#cf222e;font-weight:700;">Nein:</span>
{% for f in v.fraktionen_nein %}
{% set _wp_match = heuchelei_score(f, antrag.fraktions_scores) %}
<span class="v2-vote-pill v2-vote-nein">{{ f }}{% if _wp_match is not none %}<span class="v2-vote-marker v2-marker-heuchelei" tabindex="0" role="button" aria-label="Heuchelei-Hinweis: Nein trotz hoher Wahlprogramm-Übereinstimmung" title="Heuchelei-Marker — Diese Fraktion stimmte mit Nein, obwohl der Antrag gut zum eigenen Wahlprogramm passt (WP-Score {{ '%.0f' | format(_wp_match) }}/10)."></span>{% endif %}</span>
{% endfor %}
</div>
{% endif %}
{% if v.fraktionen_enthaltung %}
<div><span style="color:#6e7781;font-weight:700;cursor:help;border-bottom:1px dotted currentColor;" title="Enth. — Enthaltung: weder Zustimmung noch Ablehnung.">Enth.:</span>
{% for f in v.fraktionen_enthaltung %}<span class="v2-vote-pill v2-vote-enth">{{ f }}</span>{% endfor %}
</div>
{% endif %}
</div>
{# Marker-Legende: nur einblenden wenn auf dem Vote-Block ein
⚠- oder !-Marker tatsächlich vorkam, sonst würde das Bord der
Bürgerin Erklärungstext zeigen, wofür sie kein Symbol sieht. #}
{% set _has_heuchelei = false %}
{% for f in (v.fraktionen_nein or []) %}
{% if heuchelei_score(f, antrag.fraktions_scores) is not none %}{% set _has_heuchelei = true %}{% endif %}
{% endfor %}
{% set _has_opp = false %}
{% for f in (v.fraktionen_ja or []) %}
{% if opportunismus_score(f, antrag.fraktions_scores) is not none %}{% set _has_opp = true %}{% endif %}
{% endfor %}
{% if _has_heuchelei or _has_opp %}
<div class="v2-marker-legend">
{% if _has_heuchelei %}
<span><span class="v2-marker-icon v2-marker-heuchelei"></span> Heuchelei: Nein trotz hoher Wahlprogramm-Übereinstimmung (WP&nbsp;&nbsp;7/10)</span>
{% endif %}
{% if _has_opp %}
<span><span class="v2-marker-icon v2-marker-opp">!</span> Opportunismus: Ja trotz schwacher Wahlprogramm-Übereinstimmung (WP&nbsp;&lt;&nbsp;3/10)</span>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
<div style="font-family:var(--font-mono);font-size:10px;opacity:0.5;margin-top:-4px;margin-bottom:8px;">
Quelle: Plenarprotokoll · automatisch extrahiert
</div>
{% endif %}{# plenum_votes #}
{% if antrag.matrix %}
<h3 class="v2-h3">Matrix 2.0 · 25 Felder</h3>
{{ matrix_mini(antrag.matrix) }}
{% endif %}
{# Programm-Treue im BELEGE-Layout: pro Partei zwei aufklappbare Blöcke
(Wahlprogramm + Parteiprogramm). Summary zeigt Bewertung, expand
enthält Einschätzung + Belege. #}
{% if antrag.fraktions_scores %}
<h3 class="v2-h3" style="margin-top:24px;">Programm-Treue pro Fraktion</h3>
<div class="v2-fraktions-scores">
{% for fs in antrag.fraktions_scores %}
{% set wp_score = fs.wahlprogramm.score | float %}
{% set pp_score = fs.parteiprogramm.score | float %}
<div class="v2-fraktion-row">
<div class="v2-fraktion-head">
<span class="v2-fraktion-label">{{ fs.fraktion }}</span>
{% if fs.ist_antragsteller %}<span class="v2-pill v2-pill-antrag">Antragsteller:in</span>{% endif %}
{% if fs.ist_regierung %}<span class="v2-pill v2-pill-reg">Regierungsfraktion</span>{% endif %}
</div>
<details class="v2-treue-block">
<summary>
<span class="v2-treue-label">Wahlprogramm</span>
<span class="v2-treue-spacer"></span>
<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>
<span class="v2-treue-caret" aria-hidden="true"></span>
</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 %}
{% 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 %}
</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 %}
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
{# ── News-Match-Box: aktuelle News passend zu diesem Antrag (#170) ── #}
<div id="ad-news-box" style="display:none;margin-top:32px;background:var(--ecg-card-bg);border:1px solid var(--ecg-border);border-radius:6px;padding:16px;">
<h3 class="v2-h3" style="margin:0 0 8px;">Aktuelle News passend zu diesem Antrag</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.65;margin:0 0 12px;">
Embedding-Match aus den letzten 90 Tagen. Quelle: Tagesschau-API + Bundestag-RSS.
</p>
<div id="ad-news-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
</div>
</div>
{# Aktions-Links #}
<div style="margin-top:24px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.85;display:flex;gap:16px;flex-wrap:wrap;">
<a href="/api/assessment/pdf?drucksache={{ antrag.drucksache | urlencode }}"
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
PDF-Bericht
</a>
<a href="/api/assessment?drucksache={{ antrag.drucksache | urlencode }}"
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
JSON-Export
</a>
<a href="/antrag/{{ antrag.drucksache }}"
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
Permalink
</a>
</div>
{# ── Voting-Block ─────────────────────────────────────────────── #}
<div style="margin-top:24px;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Bewertung treffend?</div>
<div id="v2-vote-overall" style="display:flex;gap:8px;align-items:center;">
<button id="v2-vote-up"
onclick="v2DetailCastVote('{{ antrag.drucksache | e }}','up')"
style="display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);">
👍 <span id="v2-vote-up-count">0</span>
</button>
<button id="v2-vote-down"
onclick="v2DetailCastVote('{{ antrag.drucksache | e }}','down')"
style="display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);">
👎 <span id="v2-vote-down-count">0</span>
</button>
</div>
</div>
{# ── Share-Block (analog v1) ───────────────────────────────────── #}
<div style="margin-top:20px;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button onclick="v2DetailShareCopy()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📋 Kopieren
</button>
<button onclick="v2DetailShare('threads')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Threads
</button>
<button onclick="v2DetailShareMastodon()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Mastodon
</button>
<button onclick="v2DetailShare('linkedin')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
LinkedIn
</button>
<button onclick="v2DetailShareEmail()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📧 E-Mail
</button>
<button onclick="v2DetailShareImage()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
🖼 Bild
</button>
</div>
</div>
{# ── Re-Analyze-Block ─────────────────────────────────────────── #}
<div style="margin-top:20px;">
<button id="v2-reanalyze-btn"
onclick="v2DetailReAnalyze(this)"
style="padding:5px 12px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.8;">
Neu analysieren
</button>
</div>
{# ── Bewertungs-Historie ───────────────────────────────────────── #}
<div style="margin-top:24px;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Bewertungs-Historie</div>
<div id="v2-history-list">
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Lade…</span>
</div>
</div>
</div>{# .right #}
</div>{# .v2-detail #}
{# ── Kommentare ───────────────────────────────────────────────────────── #}
<div style="margin-top:40px;border-top:1px solid var(--hairline);padding-top:28px;">
<h3 class="v2-h3" style="margin-bottom:16px;">Kommentare</h3>
<div id="v2-comments-list" style="margin-bottom:20px;">
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.5;">Lade…</span>
</div>
{# Kommentar-Formular — wird per JS eingeblendet wenn angemeldet #}
<div id="v2-comment-form" style="display:none;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Kommentar hinzufügen</div>
<textarea id="v2-comment-input"
rows="3"
placeholder="Kommentar…"
style="width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:13px;background:var(--surface);color:var(--ecg-dark);resize:vertical;margin-bottom:8px;outline:none;"></textarea>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<select id="v2-comment-visibility"
style="padding:5px 8px;border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-mono);font-size:11px;background:var(--surface);color:var(--ecg-dark);">
<option value="all">Öffentlich</option>
<option value="authenticated">Nur Angemeldete</option>
<option value="private">Nur ich</option>
</select>
<button onclick="v2DetailAddComment('{{ antrag.drucksache | e }}')"
style="padding:5px 14px;border:none;border-radius:4px;background:var(--ecg-blue);color:#fff;cursor:pointer;font-family:var(--font-mono);font-size:11px;font-weight:700;">
Absenden
</button>
</div>
</div>
<div id="v2-comment-login-hint" style="display:none;">
<button onclick="v2AuthModalOpen()"
style="padding:5px 12px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-blue);">
Anmelden um zu kommentieren
</button>
</div>
</div>
{# ── Matrix-Feld-Info-Modal ───────────────────────────────────────────── #}
<div id="v2-matrix-field-modal"
role="dialog" aria-modal="true" aria-label="Matrix-Feld Erklärung"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);
z-index:9100;align-items:center;justify-content:center;"
onclick="if(event.target===this)this.style.display='none'">
<div style="background:var(--ecg-card-bg,#fff);border:1px solid var(--ecg-border,#ddd);
border-radius:8px;padding:24px 28px;min-width:300px;max-width:540px;
font-family:var(--font-sans);font-size:14px;color:var(--ecg-dark);
line-height:1.55;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:6px;">
<strong id="v2-matrix-field-title"
style="font-family:var(--font-display,inherit);font-size:16px;font-weight:900;
color:var(--ecg-teal,#009da5);letter-spacing:0.03em;"></strong>
<span id="v2-matrix-field-rating"
style="font-family:var(--font-mono);font-size:12px;font-weight:700;
padding:2px 8px;border-radius:3px;white-space:nowrap;"></span>
<button onclick="document.getElementById('v2-matrix-field-modal').style.display='none'"
style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--ecg-dark);
opacity:0.55;padding:0;line-height:1;margin-left:auto;"
aria-label="Schließen">×</button>
</div>
{# Antrags-spezifischer Block: Label + ausformulierte Begründung #}
<div id="v2-matrix-field-antrag" style="display:none;margin:14px 0 0;">
<div style="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;">
Bewertung in diesem Antrag
</div>
<div id="v2-matrix-field-label"
style="font-weight:700;font-size:14px;margin-bottom:4px;"></div>
<div id="v2-matrix-field-aspect"
style="font-size:13.5px;line-height:1.55;"></div>
</div>
{# Allgemeine Erklärung des Matrix-Felds (als Hintergrund) #}
<div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--hairline);">
<div style="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;">
Was misst dieses Feld?
</div>
<p id="v2-matrix-field-text" style="margin:0;font-size:13px;line-height:1.55;opacity:0.85;"></p>
</div>
</div>
</div>
{% endif %}{# _render #}
{% endblock %}
{% block body_scripts %}
<script>
/* Escape auf der Detailseite → history.back() (außer wenn Matrix-Modal offen) */
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !e.target.matches('input, textarea, select')) {
var modal = document.getElementById('v2-matrix-field-modal');
if (modal && modal.style.display === 'flex') {
modal.style.display = 'none';
return;
}
e.preventDefault();
history.back();
}
});
/* v3-Toggle in der Topbar: nur bei vorhandenem Antrag, Link auf
/v3/antrag/<drs>. Injektion via JS, damit v2/base.html unangetastet bleibt. */
{% if antrag is defined and antrag and antrag.drucksache %}
(function () {
var bar = document.querySelector('.v2-topbar');
if (!bar) return;
var link = document.createElement('a');
link.href = '/v3/antrag/' + encodeURIComponent({{ antrag.drucksache | tojson }});
link.textContent = '→ Bürger:innen-Modus · v3 Beta';
link.title = 'Vereinfachte Vorschau für Erst-Leser:innen (v3 Beta).';
link.style.marginLeft = 'auto';
bar.appendChild(link);
})();
{% endif %}
</script>
{# Matrix-Erklärungen als JSON in den Browser übertragen #}
{% if matrix_explanations is defined %}
<script>
window._v2MatrixExplanations = {{ matrix_explanations | tojson }};
window._v2MatrixCells = {{ (antrag.matrix if antrag is defined and antrag else {}) | tojson }};
window.v2ShowMatrixFieldInfo = function(field) {
var explains = window._v2MatrixExplanations || {};
var cells = window._v2MatrixCells || {};
var generalText = explains[field] || '';
var cell = cells[field] || {};
var titleEl = document.getElementById('v2-matrix-field-title');
var ratingEl = document.getElementById('v2-matrix-field-rating');
var antragEl = document.getElementById('v2-matrix-field-antrag');
var labelEl = document.getElementById('v2-matrix-field-label');
var aspectEl = document.getElementById('v2-matrix-field-aspect');
var textEl = document.getElementById('v2-matrix-field-text');
var modal = document.getElementById('v2-matrix-field-modal');
if (!modal) return;
if (titleEl) titleEl.textContent = 'Feld ' + field;
/* Rating-Chip mit gleichem Farbcode wie matrix_mini-Klassen */
if (ratingEl) {
var r = (cell.rating != null) ? cell.rating : 0;
var sym = cell.symbol || (r >= 4 ? '++' : r >= 1 ? '+' : r === 0 ? '○' : r <= -4 ? '' : '');
var bg = '#eee', fg = '#555';
if (r >= 4) { bg = 'rgba(0,157,165,0.20)'; fg = '#0d6f76'; }
else if (r >= 1) { bg = 'rgba(0,157,165,0.10)'; fg = '#0d6f76'; }
else if (r === 0) { bg = 'rgba(180,180,180,0.18)'; fg = '#666'; }
else if (r >= -3) { bg = 'rgba(207,34,46,0.12)'; fg = '#a40e26'; }
else { bg = 'rgba(207,34,46,0.22)'; fg = '#a40e26'; }
ratingEl.style.background = bg;
ratingEl.style.color = fg;
ratingEl.textContent = sym + ' ' + (r > 0 ? '+' : '') + r;
}
/* Antrags-spezifischer Block: nur sichtbar wenn LLM Begründung lieferte */
var hasAntrag = (cell.label || cell.aspect);
if (antragEl) antragEl.style.display = hasAntrag ? 'block' : 'none';
if (labelEl) labelEl.textContent = cell.label || '';
if (aspectEl) aspectEl.textContent = cell.aspect || '';
/* Allgemeine Erklärung des Felds */
if (textEl) textEl.textContent = generalText || '(Keine allgemeine Erklärung vorhanden)';
modal.style.display = 'flex';
};
</script>
{% endif %}
{% if antrag is defined and antrag and antrag.drucksache %}
<script>
(function () {
var DRS = {{ antrag.drucksache | tojson }};
var BL = {{ antrag.bundesland | tojson }};
var SHARE_THR = {{ (antrag.share_threads or '') | tojson }};
var SHARE_TWI = {{ (antrag.share_twitter or '') | tojson }};
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
var TITLE = {{ antrag.title | tojson }};
var SCORE = {{ antrag.score | tojson }};
window.ANTRAG_TOPICS = {{ (antrag.themen or []) | tojson }};
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
var currentUser = null;
/* ── Auth-State laden ─────────────────────────────────────────── */
async function initAuth() {
try {
var resp = await fetch('/api/auth/me');
var data = await resp.json();
currentUser = (data && data.authenticated) ? data : null;
} catch (_) { currentUser = null; }
var form = document.getElementById('v2-comment-form');
var loginHint = document.getElementById('v2-comment-login-hint');
if (currentUser) {
if (form) form.style.display = 'block';
if (loginHint) loginHint.style.display = 'none';
} else {
if (form) form.style.display = 'none';
if (loginHint) loginHint.style.display = 'block';
}
loadComments();
loadVotes();
}
/* ── Kommentare ───────────────────────────────────────────────── */
async function loadComments() {
var container = document.getElementById('v2-comments-list');
if (!container) return;
try {
var comments = await fetch('/api/comments?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
var visible = comments.filter(function(c) {
if (c.visibility === 'all') return true;
if (!currentUser) return false;
if (c.visibility === 'authenticated') return true;
if (c.visibility === 'private') return c.user_id === currentUser.sub;
if (c.visibility && c.visibility.startsWith('group:')) return true;
return false;
});
if (visible.length === 0) {
container.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Noch keine Kommentare.</span>';
return;
}
function visBadge(v) {
var lbl = v === 'private' ? '👤' : v === 'authenticated' ? '🔒' : '🌐';
return '<span style="font-family:var(--font-mono);font-size:10px;background:var(--surface);border:1px solid var(--hairline);padding:1px 4px;border-radius:2px;">' + lbl + '</span>';
}
container.innerHTML = visible.map(function(c) {
var dateStr = new Date(c.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
var delBtn = (currentUser && currentUser.sub === c.user_id)
? '<button onclick="v2DetailDeleteComment(' + c.id + ')" style="margin-left:auto;background:none;border:none;color:#c33;cursor:pointer;font-family:var(--font-mono);font-size:11px;padding:0;" title="Löschen"></button>'
: '';
return '<div style="padding:10px 0;border-bottom:1px solid var(--hairline);">'
+ '<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.75;margin-bottom:5px;display:flex;align-items:center;gap:6px;">'
+ '<strong>' + (c.user_name || 'Anonym') + '</strong>'
+ visBadge(c.visibility)
+ '<span style="opacity:0.6;">' + dateStr + '</span>'
+ delBtn
+ '</div>'
+ '<div style="font-size:13.5px;line-height:1.5;">' + c.text + '</div>'
+ '</div>';
}).join('');
} catch (_) {
document.getElementById('v2-comments-list').innerHTML = '';
}
}
window.v2DetailAddComment = async function(drucksache) {
var input = document.getElementById('v2-comment-input');
var visSel = document.getElementById('v2-comment-visibility');
if (!input || !input.value.trim()) return;
var visibility = visSel ? visSel.value : 'all';
await fetch('/api/comment', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'drucksache=' + encodeURIComponent(drucksache)
+ '&text=' + encodeURIComponent(input.value)
+ '&visibility=' + visibility
});
input.value = '';
loadComments();
};
window.v2DetailDeleteComment = async function(commentId) {
await fetch('/api/comment/' + commentId, {method: 'DELETE'});
loadComments();
};
/* ── Voting ───────────────────────────────────────────────────── */
async function loadVotes() {
try {
var data = await fetch('/api/votes?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
var counts = (data.counts && data.counts.overall) ? data.counts.overall : {up:0, down:0};
var myVote = data.my_votes && data.my_votes.overall;
var upEl = document.getElementById('v2-vote-up-count');
var downEl = document.getElementById('v2-vote-down-count');
var upBtn = document.getElementById('v2-vote-up');
var downBtn = document.getElementById('v2-vote-down');
if (upEl) upEl.textContent = counts.up || 0;
if (downEl) downEl.textContent = counts.down || 0;
if (upBtn) {
upBtn.style.background = myVote === 'up' ? 'rgba(137,158,51,0.12)' : '';
upBtn.style.borderColor = myVote === 'up' ? 'var(--ecg-green)' : '';
}
if (downBtn) {
downBtn.style.background = myVote === 'down' ? 'rgba(220,53,69,0.10)' : '';
downBtn.style.borderColor = myVote === 'down' ? '#dc3545' : '';
}
} catch (_) {}
}
window.v2DetailCastVote = async function(drucksache, vote) {
if (!currentUser) { v2AuthModalOpen(); return; }
try {
var resp = await fetch('/api/vote', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'drucksache=' + encodeURIComponent(drucksache) + '&target=overall&vote=' + vote
});
if (resp.ok) loadVotes();
} catch (_) {}
};
/* ── Share ────────────────────────────────────────────────────── */
// Limits pro Plattform (konservativ, mit Permalink-Reserve).
// Mastodon: meiste Instances erlauben 500 — minus Permalink (~80) = 420.
var SHARE_LIMITS = {threads: 460, mastodon: 420};
function buildShareText(platform) {
var limit = SHARE_LIMITS[platform]; // null = kein Limit (Copy/E-Mail)
var text;
if (platform === 'threads' && SHARE_THR) text = SHARE_THR;
else if (platform === 'mastodon' && SHARE_MAS) text = SHARE_MAS;
else if (platform === 'threads' && SHARE_MAS) text = SHARE_MAS; // Fallback Threads ← Mastodon
else {
var emoji = SCORE >= 8 ? '🟢' : SCORE >= 5 ? '🟡' : SCORE >= 3 ? '🟠' : '🔴';
text = emoji + ' GWÖ-Score ' + SCORE + '/10: „' + TITLE.substring(0, 70) + '" (' + DRS + ')\n\n#Gemeinwohl #GWÖ';
}
if (limit && text.length > limit) text = text.substring(0, limit - 1) + '…';
return text;
}
// Vollständiger Share-Body fuer LinkedIn/E-Mail/Copy — keine Längen-Cuts.
function buildLongShareText() {
var emoji = SCORE >= 8 ? '🟢' : SCORE >= 5 ? '🟡' : SCORE >= 3 ? '🟠' : '🔴';
var lines = [
emoji + ' GWÖ-Score ' + SCORE + '/10 für „' + TITLE + '" (' + DRS + ')',
'',
'Bewertet nach der Gemeinwohl-Matrix 2.0 — Würde, Solidarität,',
'Nachhaltigkeit, Gerechtigkeit, Demokratie. Vollständige Auswertung',
'mit Fraktions-Programmtreue, Verbesserungsvorschlägen und Belegen:',
'',
PERMALINK,
'',
'#Gemeinwohl #GWÖ #Antragspruefer'
];
return lines.join('\n');
}
window.v2DetailShare = function(platform) {
if (platform === 'linkedin') {
// LinkedIn legacy share-offsite akzeptiert nur url. Aber wir
// prefillen den Text via Clipboard + öffnen Composer parallel,
// damit der User mit Strg-V einfügen kann.
var body = buildLongShareText();
var urlOnly = 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(PERMALINK);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(body).then(function() {
window.open(urlOnly, '_blank', 'noopener');
v2ShareToast('LinkedIn-Composer geöffnet — Text liegt in der Zwischenablage (Strg/⌘-V einfügen)');
}, function() {
window.open(urlOnly, '_blank', 'noopener');
});
} else {
window.open(urlOnly, '_blank', 'noopener');
}
return;
}
var text = buildShareText(platform) + '\n' + PERMALINK;
var urls = {
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text),
};
if (urls[platform]) window.open(urls[platform], '_blank', 'noopener');
};
window.v2DetailShareCopy = function(evt) {
// Kompletter Body, keine Längen-Cuts. PERMALINK enthalten.
var text = buildLongShareText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
var btn = (evt && evt.currentTarget) || (window.event && window.event.currentTarget);
if (btn) {
var orig = btn.textContent;
btn.textContent = '✓ kopiert';
setTimeout(function(){ btn.textContent = orig; }, 1500);
}
});
} else {
prompt('Zum Kopieren markieren und Cmd/Strg-C drücken:', text);
}
};
window.v2DetailShareEmail = function() {
var subject = 'GWÖ-Bewertung: ' + (TITLE.substring(0, 60));
var emoji = SCORE >= 8 ? '🟢' : SCORE >= 5 ? '🟡' : SCORE >= 3 ? '🟠' : '🔴';
var body = [
emoji + ' GWÖ-Score ' + SCORE + '/10',
'',
'Antrag: ' + TITLE,
'Drucksache: ' + DRS,
'',
(SHARE_MAS || SHARE_THR || ('Eine Auswertung aus Sicht der Gemeinwohl-Ökonomie zum Antrag „' + TITLE.substring(0, 80) + '".')),
'',
'Vollständige Bewertung mit Matrix 2.0, Programm-Treue pro Fraktion,',
'Verbesserungsvorschlägen und Belegen:',
'',
PERMALINK,
'',
'— GWÖ-Antragsprüfer · gwoe.toppyr.de'
].join('\n');
window.location.href = 'mailto:?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
};
window.v2DetailShareImage = function() {
var topics = (window.ANTRAG_TOPICS || []).slice(0, 2).join(' ');
var query = (topics || TITLE.substring(0, 40)) + ' Politik';
// Magnific: license-Selection vorausgewählt (selection=1, last_filter=selection).
var url = 'https://www.magnific.com/search?format=search'
+ '&last_filter=selection&last_value=1'
+ '&query=' + encodeURIComponent(query)
+ '&selection=1';
window.open(url, '_blank', 'noopener');
};
window.v2DetailShareMastodon = function() {
// Encoding-Fix: encodeURIComponent kümmert sich um UTF-8 — wir
// dürfen den Text NICHT vorher manuell escapen.
var text = buildShareText('mastodon') + '\n' + PERMALINK;
var instance = localStorage.getItem('mastodon_instance');
if (!instance) {
instance = prompt('Deine Mastodon-Instanz (z.B. mastodon.social):');
if (!instance) return;
instance = instance.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
localStorage.setItem('mastodon_instance', instance);
}
window.open('https://' + instance + '/share?text=' + encodeURIComponent(text), '_blank', 'noopener');
};
// Kleine Toast-Helper-Funktion — verschwindet nach 2 s
function v2ShareToast(msg) {
var t = document.createElement('div');
t.textContent = msg;
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);'
+ 'background:var(--ecg-teal);color:#fff;padding:10px 18px;border-radius:4px;'
+ 'font-family:var(--font-mono);font-size:12px;z-index:9999;'
+ 'box-shadow:0 4px 12px rgba(0,0,0,0.15);';
document.body.appendChild(t);
setTimeout(function(){ t.remove(); }, 2500);
}
/* ── Re-Analyze ───────────────────────────────────────────────── */
function pollReAnalyze(jobId, btn) {
fetch('/status/' + jobId)
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.status === 'completed') {
btn.textContent = 'Fertig — lade neu…';
setTimeout(function(){ location.reload(); }, 800);
} else if (data.status === 'failed') {
btn.textContent = 'Analyse fehlgeschlagen';
btn.disabled = false;
} else {
setTimeout(function(){ pollReAnalyze(jobId, btn); }, 2000);
}
})
.catch(function() { btn.textContent = 'Polling-Fehler'; btn.disabled = false; });
}
window.v2DetailReAnalyze = async function(btn) {
if (!currentUser) { v2AuthModalOpen(); return; }
btn.disabled = true;
btn.textContent = 'Lösche alte Bewertung…';
try {
var delResp = await fetch('/api/assessment/delete?drucksache=' + encodeURIComponent(DRS), {method: 'DELETE'});
if (delResp.status === 401) {
btn.textContent = 'Nicht angemeldet';
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 3000);
return;
}
btn.textContent = 'Analyse läuft…';
var resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'drucksache=' + encodeURIComponent(DRS) + '&bundesland=' + encodeURIComponent(BL)
});
if (resp.status === 401) {
btn.textContent = 'Nicht angemeldet';
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 3000);
return;
}
var data = await resp.json();
if (data.status === 'queued') {
btn.textContent = 'Wird analysiert…';
pollReAnalyze(data.job_id, btn);
} else {
btn.textContent = 'Fehler: ' + (data.detail || 'unbekannt');
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 4000);
}
} catch (e) {
btn.textContent = 'Fehler: ' + e.message;
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 4000);
}
};
/* ── Merkliste (#140) ────────────────────────────────────────── */
var _merklisteActive = false;
async function initMerkliste() {
if (!currentUser) return;
try {
var resp = await fetch('/api/me/merkliste');
if (!resp.ok) return;
var entries = await resp.json();
var isInList = entries.some(function(e) { return e.antrag_id === DRS; });
_updateMerkliste(isInList);
} catch (_) {}
}
function _updateMerkliste(active) {
_merklisteActive = active;
var star = document.getElementById('v2-merkliste-star');
var label = document.getElementById('v2-merkliste-label');
var btn = document.getElementById('v2-merkliste-btn');
if (!star) return;
star.textContent = active ? '★' : '☆';
label.textContent = active ? 'Gemerkt' : 'Merken';
if (btn) {
btn.style.background = active ? 'rgba(0,157,165,0.10)' : '';
btn.style.borderColor = active ? 'var(--ecg-teal)' : '';
btn.style.color = active ? 'var(--ecg-teal)' : '';
}
}
window.v2DetailMerklisteToggle = async function() {
if (!currentUser) { v2AuthModalOpen(); return; }
try {
if (_merklisteActive) {
var r = await fetch('/api/me/merkliste/' + encodeURIComponent(DRS), { method: 'DELETE' });
if (r.ok) _updateMerkliste(false);
} else {
var r = await fetch('/api/me/merkliste', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ antrag_id: DRS })
});
if (r.ok) _updateMerkliste(true);
}
} catch (_) {}
};
/* ── Bewertungs-Historie ─────────────────────────────────────── */
async function loadHistory() {
var container = document.getElementById('v2-history-list');
if (!container) return;
try {
var entries = await fetch('/api/assessment/history?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
if (!Array.isArray(entries) || entries.length === 0) {
container.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Nur eine Version vorhanden.</span>';
return;
}
container.innerHTML = entries.map(function(v, i) {
var dateStr = v.created_at
? new Date(v.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: '';
var version = v.version !== undefined ? v.version : (entries.length - i);
return '<div style="display:flex;justify-content:space-between;align-items:baseline;'
+ 'padding:5px 0;border-bottom:1px solid var(--hairline);'
+ 'font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">'
+ '<span>v' + version + ' &nbsp;<span style="opacity:0.55;">' + dateStr + '</span></span>'
+ '<a href="/api/assessment?drucksache=' + encodeURIComponent(DRS) + '&version=' + version + '" target="_blank" rel="noopener"'
+ ' style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">JSON</a>'
+ '</div>';
}).join('');
} catch (_) {
if (container) container.innerHTML = '';
}
}
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
/* ── News-Match-Box (#170 Reverse-Sicht) ──────────────────────── */
async function loadNewsMatches() {
const ds = "{{ antrag.drucksache | e }}";
const box = document.getElementById('ad-news-box');
const list = document.getElementById('ad-news-list');
if (!box || !list) return;
try {
feat: Stand-Dashboard, Score-Histogram, PM-Markdown, Live-Polling, Cluster-Indicator Sechs zusammengehoerige UX/Performance-Erweiterungen: **1. /v2/admin/stand — System-Stand-Dashboard** KPI-Kacheln (Bewertungen, Plenum-Votes, Match, Vote-Orphans, News, PM- Drafts, Bookmarks) + GWÖ-Score-Histogram + Per-BL-Tabelle + News-Source- Tabelle. Auto-Refresh 30 s. Endpoint /api/admin/stand liefert alles in einem Roundtrip. Nav-Eintrag "Stand" in der Admin-Sektion. **2. /auswertungen Score-Histogram-Tab** 4. Tab "Score-Verteilung" mit Bar-Chart 0–10. Endpoint /api/auswertungen/score-histogram liefert Buckets, optional gefiltert nach Bundesland + Wahlperiode. Reagiert auf den globalen BL-Filter. **3. PM-Body Markdown-Rendering** Mini-Renderer im Modal: **bold** / __bold__ / *italic* / _italic_ / - list-bullets / Doppel-Newline-Paragraphen. Kein externer Markdown- Parser, keine neue Dependency. Body wird HTML-escaped, Patterns dann zu Tags umgesetzt. **4. Performance-Cache fuer themen_matching** TTL-Cache (60 s) fuer aggregate_top_themen und aggregate_news_cluster. Cache-Key inkl. aller Filter-Parameter. Automatische Invalidation in news_aggregator.run_aggregator nach erfolgreichem Insert/Embed. 4 neue Tests fuer cache_get/set/clear-Verhalten. **5. Stimmverhalten Banner Live-Update** Statt setTimeout(800) jetzt pollQueueUntilDrained: alle 4 s GET /api/queue/status, Banner zeigt pending + elapsed live. Bei pending=0 zwei Polls in Folge: Banner + Stimmverhalten-Charts neu laden. Max 5 Min Polling-Timeout. Bricht ab wenn Tab gewechselt wird. **6. Antrag-Detail Cluster-Indicator** News-Match-Box im Antrag-Detail laedt parallel /aktuelle-themen/cluster und mappt URL → Cluster. Pro News-Card ein "🔗 Cluster (N News)"-Badge mit Hover-Tooltip der anderen Cluster-Members. Macht thematische Bündel sichtbar, ohne Pop-Out auf den Cluster-Tab. Suite: 1088 → 1092 grün (4 Cache-Tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:49:06 +02:00
// Parallel: News-Matches + Cluster-Map (fuer Cluster-Indicator)
const [matchesResp, clusterResp] = await Promise.all([
fetch('/api/aktuelle-themen/news-fuer-antrag?drucksache='
+ encodeURIComponent(ds)
+ '&top_k=5&min_similarity=0.4&days=90'),
fetch('/api/aktuelle-themen/cluster?days=90&min_cluster_size=2'),
]);
const matchData = await matchesResp.json();
const matches = matchData.matches || [];
if (!matches.length) return; // Box bleibt unsichtbar
// Cluster-Lookup-Map: URL → {clusterIdx, size, otherTitles}
let clusterByUrl = {};
try {
const clusterData = await clusterResp.json();
(clusterData.clusters || []).forEach((c, i) => {
(c.members || []).forEach(m => {
clusterByUrl[m.url] = {
size: c.size,
tags: c.top_tags || [],
others: (c.members || [])
.filter(o => o.url !== m.url)
.map(o => `${o.source}: ${o.titel}`)
.slice(0, 5),
};
});
});
} catch (_) { /* clusters optional */ }
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
box.style.display = '';
let html = '';
for (const n of matches) {
const d = (n.datum || '').slice(0, 10);
const tags = (n.tags || []).slice(0, 3).map(
t => '<span style="display:inline-block;padding:1px 6px;background:var(--ecg-bg-subtle);border-radius:3px;font-family:var(--font-mono);font-size:10px;margin-right:4px;">' + t + '</span>'
).join('');
const summary = n.summary
? '<p style="font-size:12px;margin:4px 0 8px;opacity:0.85;line-height:1.5;">' + n.summary + '</p>'
: '';
feat: Stand-Dashboard, Score-Histogram, PM-Markdown, Live-Polling, Cluster-Indicator Sechs zusammengehoerige UX/Performance-Erweiterungen: **1. /v2/admin/stand — System-Stand-Dashboard** KPI-Kacheln (Bewertungen, Plenum-Votes, Match, Vote-Orphans, News, PM- Drafts, Bookmarks) + GWÖ-Score-Histogram + Per-BL-Tabelle + News-Source- Tabelle. Auto-Refresh 30 s. Endpoint /api/admin/stand liefert alles in einem Roundtrip. Nav-Eintrag "Stand" in der Admin-Sektion. **2. /auswertungen Score-Histogram-Tab** 4. Tab "Score-Verteilung" mit Bar-Chart 0–10. Endpoint /api/auswertungen/score-histogram liefert Buckets, optional gefiltert nach Bundesland + Wahlperiode. Reagiert auf den globalen BL-Filter. **3. PM-Body Markdown-Rendering** Mini-Renderer im Modal: **bold** / __bold__ / *italic* / _italic_ / - list-bullets / Doppel-Newline-Paragraphen. Kein externer Markdown- Parser, keine neue Dependency. Body wird HTML-escaped, Patterns dann zu Tags umgesetzt. **4. Performance-Cache fuer themen_matching** TTL-Cache (60 s) fuer aggregate_top_themen und aggregate_news_cluster. Cache-Key inkl. aller Filter-Parameter. Automatische Invalidation in news_aggregator.run_aggregator nach erfolgreichem Insert/Embed. 4 neue Tests fuer cache_get/set/clear-Verhalten. **5. Stimmverhalten Banner Live-Update** Statt setTimeout(800) jetzt pollQueueUntilDrained: alle 4 s GET /api/queue/status, Banner zeigt pending + elapsed live. Bei pending=0 zwei Polls in Folge: Banner + Stimmverhalten-Charts neu laden. Max 5 Min Polling-Timeout. Bricht ab wenn Tab gewechselt wird. **6. Antrag-Detail Cluster-Indicator** News-Match-Box im Antrag-Detail laedt parallel /aktuelle-themen/cluster und mappt URL → Cluster. Pro News-Card ein "🔗 Cluster (N News)"-Badge mit Hover-Tooltip der anderen Cluster-Members. Macht thematische Bündel sichtbar, ohne Pop-Out auf den Cluster-Tab. Suite: 1088 → 1092 grün (4 Cache-Tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:49:06 +02:00
const clusterInfo = clusterByUrl[n.url];
const clusterBadge = clusterInfo
? '<span style="display:inline-block;padding:1px 7px;background:rgba(0,157,165,0.15);border-radius:11px;font-family:var(--font-mono);font-size:10px;margin-left:6px;color:var(--ecg-teal);" '
+ 'title="' + clusterInfo.others.map(s => s.replace(/"/g, '&quot;')).join(' • ') + '">'
+ '🔗 Cluster (' + clusterInfo.size + ' News)</span>'
: '';
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
html += '<div style="border-bottom:1px dotted var(--ecg-border);padding:10px 0;">';
html += '<div style="font-family:var(--font-mono);font-size:10px;opacity:0.6;margin-bottom:3px;">'
+ d + ' · ' + n.source + (n.ressort ? ' / ' + n.ressort : '')
feat: Stand-Dashboard, Score-Histogram, PM-Markdown, Live-Polling, Cluster-Indicator Sechs zusammengehoerige UX/Performance-Erweiterungen: **1. /v2/admin/stand — System-Stand-Dashboard** KPI-Kacheln (Bewertungen, Plenum-Votes, Match, Vote-Orphans, News, PM- Drafts, Bookmarks) + GWÖ-Score-Histogram + Per-BL-Tabelle + News-Source- Tabelle. Auto-Refresh 30 s. Endpoint /api/admin/stand liefert alles in einem Roundtrip. Nav-Eintrag "Stand" in der Admin-Sektion. **2. /auswertungen Score-Histogram-Tab** 4. Tab "Score-Verteilung" mit Bar-Chart 0–10. Endpoint /api/auswertungen/score-histogram liefert Buckets, optional gefiltert nach Bundesland + Wahlperiode. Reagiert auf den globalen BL-Filter. **3. PM-Body Markdown-Rendering** Mini-Renderer im Modal: **bold** / __bold__ / *italic* / _italic_ / - list-bullets / Doppel-Newline-Paragraphen. Kein externer Markdown- Parser, keine neue Dependency. Body wird HTML-escaped, Patterns dann zu Tags umgesetzt. **4. Performance-Cache fuer themen_matching** TTL-Cache (60 s) fuer aggregate_top_themen und aggregate_news_cluster. Cache-Key inkl. aller Filter-Parameter. Automatische Invalidation in news_aggregator.run_aggregator nach erfolgreichem Insert/Embed. 4 neue Tests fuer cache_get/set/clear-Verhalten. **5. Stimmverhalten Banner Live-Update** Statt setTimeout(800) jetzt pollQueueUntilDrained: alle 4 s GET /api/queue/status, Banner zeigt pending + elapsed live. Bei pending=0 zwei Polls in Folge: Banner + Stimmverhalten-Charts neu laden. Max 5 Min Polling-Timeout. Bricht ab wenn Tab gewechselt wird. **6. Antrag-Detail Cluster-Indicator** News-Match-Box im Antrag-Detail laedt parallel /aktuelle-themen/cluster und mappt URL → Cluster. Pro News-Card ein "🔗 Cluster (N News)"-Badge mit Hover-Tooltip der anderen Cluster-Members. Macht thematische Bündel sichtbar, ohne Pop-Out auf den Cluster-Tab. Suite: 1088 → 1092 grün (4 Cache-Tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:49:06 +02:00
+ ' · sim ' + n.similarity + clusterBadge + '</div>';
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
html += '<a href="' + n.url + '" target="_blank" rel="noopener" '
+ 'style="color:var(--ecg-teal);text-decoration:none;font-weight:500;">'
+ n.titel + '</a>';
html += summary;
if (tags) html += '<div style="margin-bottom:6px;">' + tags + '</div>';
html += '<button onclick="adGeneratePresse(\''
+ ds.replace(/'/g, "\\'") + '\', \''
+ encodeURIComponent(n.url) + '\', this)" '
+ 'style="font-family:var(--font-mono);font-size:11px;padding:4px 10px;'
+ 'border:1px solid var(--ecg-teal);background:var(--ecg-card-bg);'
+ 'color:var(--ecg-teal);border-radius:3px;cursor:pointer;">'
+ 'PM-Vorschlag generieren</button>';
html += '</div>';
}
list.innerHTML = html;
} catch (e) {
// Bei Fehler: Box bleibt unsichtbar — kein Stoerfaktor
}
}
window.adGeneratePresse = async function(drucksache, newsUrlEnc, btn) {
if (!confirm('Pressemitteilung für ' + drucksache + ' anzeigen / generieren?\n\n'
+ 'Falls bereits ein Entwurf existiert, wird dieser ohne LLM-Call zurückgegeben.\n'
+ 'Sonst: qwen-max generiert (~6 Cent, ~30 s).')) return;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch('/api/aktuelle-themen/generate-presse'
+ '?drucksache=' + encodeURIComponent(drucksache)
+ '&news_url=' + newsUrlEnc, { method: 'POST' });
if (!r.ok) {
const err = await r.json().catch(() => ({}));
alert('Fehler: ' + (err.detail || r.statusText));
return;
}
const d = await r.json();
const note = d._was_existing
? '(bereits generiert am ' + (d.created_at || '').slice(0, 10) + ')'
: '(neu generiert)';
alert(d.titel + '\n' + note + '\n\n' + d.body
+ '\n\n— Auf /aktuelle-themen sichtbar im Tab "PM-Entwürfe".');
} catch (e) {
alert('Fehler: ' + e);
} finally {
btn.disabled = false;
btn.textContent = 'PM-Vorschlag generieren';
}
};
/* ── Init ─────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', function() {
initAuth();
initMerkliste();
loadHistory();
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
loadNewsMatches();
});
})();
</script>
{% endif %}
{% endblock %}