gwoe-antragspruefer/app/templates/v2/screens/aktuelle-themen.html
Dotty Dotter e27dfc30a2 feat(#170 followup 2): Pre-Filter, Cluster, Antrags-Initiative, PM-Versionierung, Mail-Link
User-Feedback: Aktuelle-Themen-Dashboard war "Detective-Modus" — durch
viele News scrollen, Match-Stärke selbst interpretieren. Komplett-Refactor
zur kuratierten Sicht mit Tabs.

**1. Pre-Filter + GWÖ-Relevanz-Score (#134)**

`compute_relevance(matches)`: Score = max(antrag.gwoe_score × similarity).
Level: high (≥4.0) / mid (≥2.5) / low (>0) / none.
Pro News in der UI ein farbiger Pill (gruen/orange/grau) + Reason-Text:
"GWÖ-9.0/10-Antrag „Klimaschutzgesetz" (GRÜNE) passt mit Similarity 0.55."

Default-Filter "Nur GWÖ-relevant" aktiv (only_relevant=true) — zeigt
nur high/mid News, blendet Rauschen aus. Toggle-Checkbox.

`/api/aktuelle-themen/top` neuer Param `only_relevant=true|false`.

**2. PM-Versionierung im Modal (#135)**

`list_drafts_for(drucksache, news_url)`: alle Versionen, neueste oben.
Endpoint `/api/aktuelle-themen/drafts-versions`. Modal zeigt Dropdown
wenn >1 Version, Switch ohne LLM-Call. Force-Regen bleibt als Button
im "bestehender Entwurf"-Banner.

**3. News-Cluster-View (#136)**

`aggregate_news_cluster(intra_threshold=0.55, min_cluster_size=2)`:
Greedy-Embedding-Cluster + zentralster Antrags-Match per Centroid-
Vektor. Zweiter Tab "Themen-Cluster": 5 News über "Pflege" → 1 Cluster
mit gemeinsamem Antrag-Vorschlag, statt 5 separate Cards.
Endpoint: `/api/aktuelle-themen/cluster`.

**4. Mail-Direkt-Link + Clipboard (#137)**

Im PM-Modal zwei Buttons:
- "📧 Per Mail versenden" (mailto: mit subject + body, ~1900 Char Limit)
- "📋 In Zwischenablage kopieren" (navigator.clipboard.writeText)
- Bei langem PM (>1900 Char): mailto-Link wird ausgegraut, Hinweis
  "PM zu lang für Mail-Link — Clipboard nutzen"

**5. Antrags-Initiative (#138)**

`aggregate_top_antraege_with_news(min_gwoe_score=8.0, days=14)`:
Reverse-Sicht — pro Antrag mit GWÖ ≥ 8 die News-Resonanz. Antraege
ohne Match werden trotzdem angezeigt mit "keine News"-Pill.
Dritter Tab "GWÖ-Top-Anträge". Endpoint `.../top-antraege`.

**UI-Restrukturierung:** statt einer langen Scroll-Liste jetzt
5 Tabs mit gemeinsamer Filter-Bar:
- News × Anträge (Default, kuratiert via Pre-Filter)
- Themen-Cluster (Bündel ähnlicher News)
- GWÖ-Top-Anträge (Reverse)
- News-Volumen (Chart)
- PM-Entwürfe (Drafts-Liste)

Default min_similarity 0.40 → 0.50 erhoeht (weniger Rauschen).

Tests: 14 neue (compute_relevance × 5, only_relevant + sort × 3,
cluster × 3, top_antraege × 3). Suite 1067 gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:41:31 +02:00

669 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "v2/base.html" %}
{% block title %}Aktuelle Themen — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "aktuelle-themen" %}
{% block head_extra %}
<script src="/static/chart.umd.min.js"></script>
<style>
.at-controls {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
font-family: var(--font-mono);
font-size: 11px;
}
.at-controls select, .at-controls input[type="number"] {
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 8px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
background: var(--ecg-card-bg);
color: var(--ecg-dark);
}
.at-controls button {
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 12px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
cursor: pointer;
background: var(--ecg-teal);
color: #fff;
}
.at-news-card {
background: var(--ecg-card-bg);
border: 1px solid var(--ecg-border);
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 14px;
}
.at-news-head {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
margin-bottom: 4px;
}
.at-news-title {
font-family: var(--font-display);
font-size: 15px;
color: var(--ecg-teal);
margin: 0 0 6px;
line-height: 1.3;
}
.at-news-title a { color: inherit; text-decoration: none; }
.at-news-title a:hover { text-decoration: underline; }
.at-news-summary {
font-size: 12px;
line-height: 1.5;
margin: 0 0 10px;
opacity: 0.85;
}
.at-news-tags {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.55;
margin-bottom: 8px;
}
.at-tag {
display: inline-block;
padding: 1px 6px;
background: var(--ecg-bg-subtle);
border-radius: 3px;
margin-right: 4px;
}
.at-matches {
border-top: 1px solid var(--ecg-border);
margin-top: 10px;
padding-top: 10px;
}
.at-matches-label {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
margin-bottom: 6px;
}
.at-match {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 0;
font-size: 12px;
border-bottom: 1px dotted var(--ecg-border);
}
.at-match:last-child { border-bottom: none; }
.at-score-pill {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
background: var(--ecg-bg-subtle);
min-width: 28px;
text-align: center;
}
.at-score-pill.s-high { background: rgba(136,158,51,0.25); color: #44570a; }
.at-score-pill.s-mid { background: rgba(247,148,29,0.18); color: #875e10; }
.at-score-pill.s-low { background: rgba(200,0,0,0.15); color: #931515; }
.at-sim {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.5;
}
.at-presse-btn {
background: var(--ecg-card-bg);
color: var(--ecg-teal);
border: 1px solid var(--ecg-teal);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
margin-left: auto;
}
.at-presse-btn:hover { background: var(--ecg-teal); color: #fff; }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1.5rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Aktuelle Themen</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Tagesschau + Bundestag-RSS · gematcht mit deinen Anträgen ·
Pressemitteilungs-Vorschläge
</p>
</div>
<div class="v2-kasten outline-blue" style="margin-bottom:1rem;">
<p style="font-size:12px;line-height:1.5;margin:0 0 0.5rem;">
Die täglich aktuellen politischen Top-Themen aus
<strong>öffentlich-rechtlichen + parlamentarischen Quellen</strong>
(Tagesschau-API + Bundestag-RSS) werden semantisch mit den von dir
bewerteten Anträgen verschnitten. Pro News-Artikel siehst du die
GWÖ-Bewertung der dazu passendsten Anträge — und kannst per Klick
eine Pressemitteilung generieren lassen.
</p>
<p style="font-size:11px;line-height:1.5;opacity:0.75;margin:0;">
Bewusst <strong>nicht</strong> verwendet: Quellen mit AI-Bann in
robots.txt (z.B. RND.de). Die UI zeigt nur Titel + URL + erste Sätze
— Volltexte werden nicht persistiert.
</p>
</div>
<div class="at-controls">
<label for="at-days">Zeitfenster:</label>
<select id="at-days" onchange="loadActiveTab()">
<option value="3">3 Tage</option>
<option value="7" selected>7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
</select>
<label for="at-topk">Top-N News:</label>
<input type="number" id="at-topk" value="20" min="3" max="50" style="width:60px;" onchange="loadActiveTab()" />
<label for="at-minsim">Min. Similarity:</label>
<select id="at-minsim" onchange="loadActiveTab()">
<option value="0.30">0.30 (locker)</option>
<option value="0.40">0.40</option>
<option value="0.50" selected>0.50 (default)</option>
</select>
<label style="display:inline-flex;align-items:center;gap:5px;cursor:pointer;">
<input type="checkbox" id="at-only-relevant" checked onchange="loadActiveTab()" />
Nur GWÖ-relevant
</label>
<button onclick="loadActiveTab()">Aktualisieren</button>
</div>
<!-- Tabs -->
<div class="auswert-tabs" role="tablist" style="display:flex;gap:4px;margin:1.5rem 0 1rem;border-bottom:2px solid var(--ecg-border);padding-bottom:0;">
<button class="at-tab active" role="tab" data-tab="news" onclick="switchAtTab('news', this)" style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;padding:6px 14px;border:none;background:none;cursor:pointer;color:var(--ecg-teal);opacity:1;border-bottom:2px solid var(--ecg-teal);margin-bottom:-2px;">News × Anträge</button>
<button class="at-tab" role="tab" data-tab="cluster" onclick="switchAtTab('cluster', this)" style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;padding:6px 14px;border:none;background:none;cursor:pointer;color:var(--ecg-dark);opacity:0.55;border-bottom:2px solid transparent;margin-bottom:-2px;">Themen-Cluster</button>
<button class="at-tab" role="tab" data-tab="antraege" onclick="switchAtTab('antraege', this)" style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;padding:6px 14px;border:none;background:none;cursor:pointer;color:var(--ecg-dark);opacity:0.55;border-bottom:2px solid transparent;margin-bottom:-2px;">GWÖ-Top-Anträge</button>
<button class="at-tab" role="tab" data-tab="zeitreihe" onclick="switchAtTab('zeitreihe', this)" style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;padding:6px 14px;border:none;background:none;cursor:pointer;color:var(--ecg-dark);opacity:0.55;border-bottom:2px solid transparent;margin-bottom:-2px;">News-Volumen</button>
<button class="at-tab" role="tab" data-tab="drafts" onclick="switchAtTab('drafts', this)" style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;padding:6px 14px;border:none;background:none;cursor:pointer;color:var(--ecg-dark);opacity:0.55;border-bottom:2px solid transparent;margin-bottom:-2px;">PM-Entwürfe</button>
</div>
<!-- Tab Panels -->
<div id="at-tab-news" class="at-panel">
<div id="at-themen-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
</div>
</div>
<div id="at-tab-cluster" class="at-panel" style="display:none;">
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
News mit ähnlicher Thematik werden gebündelt — z.B. 4 Tagesschau- + 2 Bundestag-Artikel
zur gleichen Debatte ergeben einen Cluster mit gemeinsamem Antrags-Match.
</p>
<div id="at-cluster-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
</div>
</div>
<div id="at-tab-antraege" class="at-panel" style="display:none;">
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Reverse-Sicht: <strong>GWÖ-bewertete Anträge mit Score ≥ 8</strong>, sortiert nach
aktueller Pressewirkung. Anträge ohne News-Match werden gezeigt — als Hinweis
„Top-Antrag, aktuell ohne mediale Resonanz".
</p>
<div id="at-antraege-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
</div>
</div>
<div id="at-tab-zeitreihe" class="at-panel" style="display:none;">
<h3 style="font-family:var(--font-display);font-size:14px;color:var(--ecg-teal);margin:0 0 0.5rem;">
News-Volumen pro Quelle (letzte 30 Tage)
</h3>
<div class="matrix-wrap" style="background:var(--ecg-card-bg);border:1px solid var(--ecg-border);border-radius:4px;padding:14px;">
<canvas id="at-zeitreihe-chart" style="max-height:320px;"></canvas>
</div>
<div id="at-zeitreihe-meta" class="meta-line" style="font-family:var(--font-mono);font-size:11px;opacity:0.6;margin:8px 0 1.5rem;"></div>
</div>
<div id="at-tab-drafts" class="at-panel" style="display:none;">
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Bisher generierte Pressemitteilungs-Entwürfe (zuletzt generiert oben).
</p>
<div id="at-drafts-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
</div>
</div>
<!-- Modal für Draft-Anzeige -->
<div class="v2-modal-backdrop" id="at-modal-backdrop" onclick="atCloseModal(event)" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:500;align-items:center;justify-content:center;">
<div class="v2-modal" onclick="event.stopPropagation()" style="background:var(--ecg-card-bg);border-radius:6px;padding:20px 24px;max-width:680px;width:90%;max-height:80vh;overflow-y:auto;position:relative;">
<button class="v2-modal-close" onclick="atCloseModal()" style="position:absolute;top:12px;right:14px;background:none;border:none;font-size:18px;cursor:pointer;opacity:0.5;">&times;</button>
<h2 id="at-modal-title" style="font-family:var(--font-display);font-size:16px;color:var(--ecg-teal);margin:0 0 12px;">Pressemitteilung</h2>
<div id="at-modal-body" style="font-size:13px;line-height:1.5;">Generiere …</div>
</div>
</div>
{% endblock %}
{% block body_scripts %}
<script>
let _atZeitreiheChart = null;
let _atActiveTab = 'news';
function atScoreClass(score) {
if (score == null) return '';
if (score >= 7) return 's-high';
if (score >= 4) return 's-mid';
return 's-low';
}
function atRelevancePill(rel) {
if (!rel) return '';
const map = {
'high': {bg: 'rgba(136,158,51,0.30)', fg: '#3d4f0a', label: 'GWÖ-relevant'},
'mid': {bg: 'rgba(247,148,29,0.25)', fg: '#7a4a00', label: 'GWÖ-mittel'},
'low': {bg: 'rgba(150,150,150,0.25)', fg: '#555', label: 'schwach'},
'none': {bg: 'rgba(150,150,150,0.15)', fg: '#888', label: 'kein Match'},
};
const s = map[rel.level] || map.none;
return `<span style="display:inline-block;padding:2px 9px;border-radius:11px;font-family:var(--font-mono);font-size:10px;font-weight:700;background:${s.bg};color:${s.fg};margin-right:6px;">${s.label} · ${rel.score}</span>`;
}
function atFmtDatum(s) {
if (!s || s.length < 10) return '';
return s.slice(0, 10);
}
function switchAtTab(name, btn) {
_atActiveTab = name;
document.querySelectorAll('.at-tab').forEach(b => {
b.style.color = 'var(--ecg-dark)';
b.style.opacity = '0.55';
b.style.borderBottomColor = 'transparent';
});
btn.style.color = 'var(--ecg-teal)';
btn.style.opacity = '1';
btn.style.borderBottomColor = 'var(--ecg-teal)';
document.querySelectorAll('.at-panel').forEach(p => p.style.display = 'none');
document.getElementById('at-tab-' + name).style.display = 'block';
loadActiveTab();
}
function loadActiveTab() {
switch (_atActiveTab) {
case 'news': loadThemen(); break;
case 'cluster': loadCluster(); break;
case 'antraege': loadAntraege(); break;
case 'zeitreihe': loadZeitreihe(); break;
case 'drafts': loadDrafts(); break;
}
}
async function loadThemen() {
const days = document.getElementById('at-days').value;
const topk = document.getElementById('at-topk').value;
const minsim = document.getElementById('at-minsim').value;
const onlyRel = document.getElementById('at-only-relevant').checked ? '1' : '0';
const list = document.getElementById('at-themen-list');
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>';
try {
const r = await fetch(`/api/aktuelle-themen/top?days=${days}&top_k=${topk}&min_similarity=${minsim}&matches_per_news=3&only_relevant=${onlyRel}`);
const data = await r.json();
if (!data.buckets || !data.buckets.length) {
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine News im Zeitfenster ' + (onlyRel === '1' ? '(Filter: nur GWÖ-relevant aktiv — versuch ohne Filter)' : 'oder noch nicht embedded') + '.</div>';
return;
}
let html = '';
for (const b of data.buckets) {
const n = b.news;
const rel = b.relevance;
const tags = (n.tags || []).map(t => `<span class="at-tag">${t}</span>`).join('');
html += '<div class="at-news-card">';
html += `<div class="at-news-head">${atRelevancePill(rel)}${atFmtDatum(n.datum)} · ${n.source}${n.ressort ? ' / ' + n.ressort : ''}</div>`;
html += `<h4 class="at-news-title"><a href="${n.url}" target="_blank" rel="noopener">${n.titel}</a></h4>`;
if (n.summary) html += `<div class="at-news-summary">${n.summary}</div>`;
if (tags) html += `<div class="at-news-tags">${tags}</div>`;
if (b.matches && b.matches.length) {
html += '<div class="at-matches">';
html += `<div class="at-matches-label">Warum: ${rel.reason}</div>`;
for (const m of b.matches) {
const sc = m.gwoe_score != null ? m.gwoe_score.toFixed(1) : '—';
const fr = (m.fraktionen || []).join(', ');
html += '<div class="at-match">';
html += `<span class="at-score-pill ${atScoreClass(m.gwoe_score)}">${sc}</span>`;
html += `<a href="/antrag/${encodeURIComponent(m.drucksache)}" style="color:var(--ecg-teal);text-decoration:none;font-weight:500;">${m.drucksache}</a>`;
html += `<span style="opacity:0.85;">${m.title || ''}</span>`;
if (fr) html += `<span style="opacity:0.6;font-size:11px;">— ${fr}</span>`;
html += `<span class="at-sim">sim ${m.similarity}</span>`;
html += `<button class="at-presse-btn" onclick="generatePresse('${m.drucksache.replace(/'/g, "\\'")}', '${encodeURIComponent(n.url)}', this)">PM-Vorschlag</button>`;
html += '</div>';
}
html += '</div>';
} else {
html += '<div class="at-matches"><div class="at-matches-label">Keine GWÖ-bewerteten Anträge passen — wäre ein Kandidat für eine neue Bewertung.</div></div>';
}
html += '</div>';
}
list.innerHTML = html;
} catch (e) {
list.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
}
}
async function loadCluster() {
const days = document.getElementById('at-days').value;
const list = document.getElementById('at-cluster-list');
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>';
try {
const r = await fetch(`/api/aktuelle-themen/cluster?days=${days}&intra_threshold=0.55&min_cluster_size=2&antrag_threshold=0.4`);
const data = await r.json();
if (!data.clusters || !data.clusters.length) {
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine Themen-Cluster im Zeitfenster (jeder Cluster braucht mind. 2 inhaltlich ähnliche News).</div>';
return;
}
let html = '';
for (const c of data.clusters) {
html += '<div class="at-news-card">';
const tagPills = (c.top_tags || []).map(t => `<span class="at-tag">${t}</span>`).join('');
html += `<div class="at-news-head">Cluster · ${c.size} News · ${tagPills || '<em>keine Tags</em>'}</div>`;
html += '<div style="margin:6px 0;">';
for (const m of c.members.slice(0, 6)) {
html += `<div style="font-size:12px;line-height:1.5;"><span style="opacity:0.6;font-family:var(--font-mono);font-size:10px;">[${m.source}]</span> <a href="${m.url}" target="_blank" rel="noopener" style="color:var(--ecg-teal);text-decoration:none;">${m.titel}</a></div>`;
}
if (c.members.length > 6) {
html += `<div style="font-size:11px;opacity:0.6;">… und ${c.members.length - 6} weitere</div>`;
}
html += '</div>';
if (c.antrag_matches && c.antrag_matches.length) {
html += '<div class="at-matches"><div class="at-matches-label">Passende Anträge:</div>';
for (const m of c.antrag_matches) {
const sc = m.gwoe_score != null ? m.gwoe_score.toFixed(1) : '—';
const fr = (m.fraktionen || []).join(', ');
const firstNewsUrl = c.members[0].url;
html += '<div class="at-match">';
html += `<span class="at-score-pill ${atScoreClass(m.gwoe_score)}">${sc}</span>`;
html += `<a href="/antrag/${encodeURIComponent(m.drucksache)}" style="color:var(--ecg-teal);text-decoration:none;font-weight:500;">${m.drucksache}</a>`;
html += `<span style="opacity:0.85;">${m.title || ''}</span>`;
if (fr) html += `<span style="opacity:0.6;font-size:11px;">— ${fr}</span>`;
html += `<span class="at-sim">sim ${m.similarity}</span>`;
html += `<button class="at-presse-btn" onclick="generatePresse('${m.drucksache.replace(/'/g, "\\'")}', '${encodeURIComponent(firstNewsUrl)}', this)">PM-Vorschlag</button>`;
html += '</div>';
}
html += '</div>';
} else {
html += '<div class="at-matches"><div class="at-matches-label">Kein Antrag-Match — Themen-Cluster ohne GWÖ-Anker.</div></div>';
}
html += '</div>';
}
list.innerHTML = html;
} catch (e) {
list.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
}
}
async function loadAntraege() {
const days = document.getElementById('at-days').value;
const list = document.getElementById('at-antraege-list');
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>';
try {
const r = await fetch(`/api/aktuelle-themen/top-antraege?min_gwoe_score=8.0&days=${days}&min_similarity=0.4&top_k_news=5`);
const data = await r.json();
if (!data.antraege || !data.antraege.length) {
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine GWÖ-≥8-Anträge in der DB.</div>';
return;
}
let html = '';
for (const a of data.antraege) {
const sc = a.gwoe_score != null ? a.gwoe_score.toFixed(1) : '—';
const fr = (a.fraktionen || []).join(', ');
const newsBadge = a.news_count > 0
? `<span style="display:inline-block;padding:2px 9px;border-radius:11px;font-family:var(--font-mono);font-size:10px;font-weight:700;background:rgba(136,158,51,0.30);color:#3d4f0a;margin-right:6px;">${a.news_count} aktuelle News</span>`
: `<span style="display:inline-block;padding:2px 9px;border-radius:11px;font-family:var(--font-mono);font-size:10px;font-weight:700;background:rgba(150,150,150,0.20);color:#777;margin-right:6px;">keine News</span>`;
html += '<div class="at-news-card">';
html += `<div class="at-news-head">${newsBadge}<span class="at-score-pill ${atScoreClass(a.gwoe_score)}" style="margin-right:6px;">${sc}</span>${a.bundesland} · ${atFmtDatum(a.datum)}${fr ? ' · ' + fr : ''}</div>`;
html += `<h4 class="at-news-title"><a href="/antrag/${encodeURIComponent(a.drucksache)}" style="color:var(--ecg-teal);text-decoration:none;">${a.drucksache}${a.title || ''}</a></h4>`;
if (a.antrag_zusammenfassung) {
html += `<div class="at-news-summary">${a.antrag_zusammenfassung.slice(0, 200)}${a.antrag_zusammenfassung.length > 200 ? '…' : ''}</div>`;
}
if (a.top_news && a.top_news.length) {
html += '<div class="at-matches"><div class="at-matches-label">Aktuelle News:</div>';
for (const n of a.top_news) {
html += '<div class="at-match">';
html += `<span style="font-family:var(--font-mono);font-size:10px;opacity:0.6;">[${n.source}]</span>`;
html += `<a href="${n.url}" target="_blank" rel="noopener" style="color:var(--ecg-teal);text-decoration:none;">${n.titel}</a>`;
html += `<span class="at-sim">sim ${n.similarity}</span>`;
html += `<button class="at-presse-btn" onclick="generatePresse('${a.drucksache.replace(/'/g, "\\'")}', '${encodeURIComponent(n.url)}', this)">PM-Vorschlag</button>`;
html += '</div>';
}
html += '</div>';
} else {
html += '<div class="at-matches"><div class="at-matches-label">Keine aktuellen News passen — Top-Antrag wartet auf passende mediale Welle.</div></div>';
}
html += '</div>';
}
list.innerHTML = html;
} catch (e) {
list.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
}
}
async function loadZeitreihe() {
const meta = document.getElementById('at-zeitreihe-meta');
try {
const r = await fetch('/api/aktuelle-themen/zeitreihe?days=30');
const data = await r.json();
if (_atZeitreiheChart) _atZeitreiheChart.destroy();
if (!data.buckets || !data.buckets.length) {
meta.textContent = 'Noch keine News-Artikel in der DB.';
return;
}
const colors = ['rgba(0,157,165,0.7)', 'rgba(247,148,29,0.7)', 'rgba(136,158,51,0.7)',
'rgba(200,30,30,0.7)', 'rgba(150,100,200,0.7)'];
const datasets = data.sources.map((s, i) => ({
label: s,
data: data.series[s],
backgroundColor: colors[i % colors.length],
borderColor: colors[i % colors.length].replace('0.7', '1'),
fill: true,
tension: 0.2,
}));
const ctx = document.getElementById('at-zeitreihe-chart');
_atZeitreiheChart = new Chart(ctx, {
type: 'line',
data: { labels: data.buckets, datasets: datasets },
options: {
responsive: true,
scales: {
y: { beginAtZero: true, stacked: true, title: { display: true, text: 'Artikel/Tag' } },
x: { title: { display: true, text: 'Datum' } },
},
plugins: {
legend: { position: 'bottom' }
}
}
});
const total = Object.values(data.series).reduce((s, arr) => s + arr.reduce((a, b) => a + b, 0), 0);
meta.textContent = `${total} News-Artikel über ${data.buckets.length} Tage, ${data.sources.length} Quellen.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
async function loadDrafts() {
const wrap = document.getElementById('at-drafts-list');
try {
const r = await fetch('/api/aktuelle-themen/drafts?limit=10');
const data = await r.json();
if (!data.drafts || !data.drafts.length) {
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Noch keine Pressemitteilungen generiert.</div>';
return;
}
let html = '';
for (const d of data.drafts) {
html += '<div class="at-news-card" style="cursor:pointer;" onclick="showDraft(' + d.id + ')">';
html += `<div class="at-news-head">${atFmtDatum(d.created_at)} · DS ${d.drucksache} (${d.bundesland})</div>`;
html += `<h4 class="at-news-title">${d.titel}</h4>`;
html += `<div class="at-news-tags">Bezug: ${d.news_titel}</div>`;
html += '</div>';
}
wrap.innerHTML = html;
} catch (e) {
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
}
}
async function generatePresse(drucksache, newsUrlEnc, btn) {
if (!confirm(`Pressemitteilung für ${drucksache} anzeigen / generieren?\n\nFalls bereits ein Entwurf existiert, wird dieser ohne LLM-Call zurückgegeben.\nSonst wird mit qwen-max generiert (~6 Cent, ~30 s).`)) return;
btn.textContent = '…';
btn.disabled = true;
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();
alert('Fehler: ' + (err.detail || r.statusText));
btn.textContent = 'PM-Vorschlag';
btn.disabled = false;
return;
}
const data = await r.json();
showDraftFromData(data);
if (!data._was_existing) loadDrafts(); // Nur bei NEU laden
} catch (e) {
alert('Fehler: ' + e);
} finally {
btn.textContent = 'PM-Vorschlag';
btn.disabled = false;
}
}
async function regeneratePresse(drucksache, newsUrlEnc) {
if (!confirm(`Wirklich neu generieren?\n\nDas macht einen NEUEN LLM-Call (~6 Cent, ~30 s) und legt einen weiteren Draft an.`)) return;
try {
const r = await fetch(`/api/aktuelle-themen/generate-presse?drucksache=${encodeURIComponent(drucksache)}&news_url=${newsUrlEnc}&force=true`, {
method: 'POST',
});
if (!r.ok) {
const err = await r.json();
alert('Fehler: ' + (err.detail || r.statusText));
return;
}
const data = await r.json();
showDraftFromData(data);
loadDrafts();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function showDraftFromData(d) {
const backdrop = document.getElementById('at-modal-backdrop');
document.getElementById('at-modal-title').textContent = d.titel;
const isExisting = d._was_existing === true;
const newsUrlEnc = encodeURIComponent(d.news_url);
const dsEnc = d.drucksache.replace(/'/g, "\\'");
// Versionen abfragen — falls >1, Dropdown anzeigen
let versionsHtml = '';
try {
const vr = await fetch(`/api/aktuelle-themen/drafts-versions?drucksache=${encodeURIComponent(d.drucksache)}&news_url=${newsUrlEnc}`);
const vd = await vr.json();
if (vd.versions && vd.versions.length > 1) {
versionsHtml = '<select onchange="loadVersion(this.value)" style="margin-left:8px;font-family:var(--font-mono);font-size:10px;padding:2px 6px;">';
for (const v of vd.versions) {
const sel = (v.id === d.id) ? ' selected' : '';
versionsHtml += `<option value="${v.id}"${sel}>v${v.id}${(v.created_at || '').slice(0,16)} (${v.model})</option>`;
}
versionsHtml += '</select>';
}
} catch (e) { /* silent */ }
const banner = isExisting
? `<div style="font-family:var(--font-mono);font-size:10px;opacity:0.85;background:rgba(247,148,29,0.18);padding:6px 8px;border-radius:3px;margin-bottom:8px;">
Bestehender Entwurf vom ${(d.created_at || '').slice(0,10)} · Modell: ${d.model || '—'} · kein LLM-Call
<button type="button" onclick="regeneratePresse('${dsEnc}', '${newsUrlEnc}')" style="margin-left:8px;font-family:var(--font-mono);font-size:10px;padding:2px 8px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);cursor:pointer;">Neu generieren</button>
${versionsHtml}
</div>`
: `<div style="font-family:var(--font-mono);font-size:10px;opacity:0.85;background:rgba(136,158,51,0.18);padding:6px 8px;border-radius:3px;margin-bottom:8px;">
Neu generiert · Modell: ${d.model || '—'} ${versionsHtml}
</div>`;
// Action-Buttons: Mail + Clipboard
const mailtoBody = encodeURIComponent(d.body + '\n\n— Bezug: ' + d.news_titel + ' (' + d.news_url + ')');
const mailtoSubject = encodeURIComponent(d.titel);
const mailto = `mailto:?subject=${mailtoSubject}&body=${mailtoBody}`;
const isMailtoTooLong = mailto.length > 1900;
const actionRow = `<div style="display:flex;gap:8px;margin:10px 0 12px;">
${isMailtoTooLong ? '<span style="font-family:var(--font-mono);font-size:10px;opacity:0.6;">PM zu lang für Mail-Link — Clipboard nutzen.</span>'
: `<a href="${mailto}" style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);color:var(--ecg-dark);text-decoration:none;">📧 Per Mail versenden</a>`}
<button type="button" onclick="copyDraftToClipboard(this, ${JSON.stringify(d.titel).replace(/"/g, '&quot;')}, ${JSON.stringify(d.body).replace(/"/g, '&quot;')})" style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);color:var(--ecg-dark);cursor:pointer;">📋 In Zwischenablage kopieren</button>
</div>`;
document.getElementById('at-modal-body').innerHTML =
banner +
`<div style="font-family:var(--font-mono);font-size:11px;opacity:0.6;margin-bottom:10px;">
DS ${d.drucksache} (${d.bundesland}) · Bezug zu: <a href="${d.news_url}" target="_blank" rel="noopener" style="color:var(--ecg-teal);">${d.news_titel}</a>
</div>` +
actionRow +
`<div style="white-space:pre-wrap;font-size:13px;line-height:1.5;">${d.body.replace(/</g, '&lt;')}</div>`;
backdrop.style.display = 'flex';
}
async function loadVersion(draftId) {
try {
const r = await fetch(`/api/aktuelle-themen/drafts/${draftId}`);
const d = await r.json();
showDraftFromData(d);
} catch (e) { alert('Fehler: ' + e); }
}
async function copyDraftToClipboard(btn, titel, body) {
const text = titel + '\n\n' + body;
try {
await navigator.clipboard.writeText(text);
const orig = btn.textContent;
btn.textContent = '✓ kopiert';
setTimeout(() => { btn.textContent = orig; }, 1800);
} catch (e) {
alert('Clipboard-Fehler: ' + e + '\n\nText:\n' + text);
}
}
async function showDraft(id) {
try {
const r = await fetch(`/api/aktuelle-themen/drafts/${id}`);
const d = await r.json();
showDraftFromData(d);
} catch (e) {
alert('Fehler: ' + e);
}
}
function atCloseModal(ev) {
if (!ev || ev.target.id === 'at-modal-backdrop') {
document.getElementById('at-modal-backdrop').style.display = 'none';
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') document.getElementById('at-modal-backdrop').style.display = 'none';
});
// Init: News-Tab als Default
loadActiveTab();
</script>
{% endblock %}