gwoe-antragspruefer/app/templates/v2/screens/aktuelle-themen.html
Dotty Dotter a2b8f8c6fe feat(#178 Phase 4.2): PM-Variante 'thread' fuer Mastodon/Twitter-Threads
- Schema additiv: presse_drafts.style TEXT NOT NULL DEFAULT 'pm' via
  ALTER TABLE (idempotent in init_db).
- presse_generator.generate_draft(style='pm'|'thread') nutzt eigenen
  SYSTEM_PROMPT_THREAD (3-5 Posts à ≤280 Zeichen, Hook + Lebenslagen +
  Forderung, Hashtags am Schluss; keine **fett**-Markdown).
- _find_existing_draft, list_drafts, list_drafts_for, get_draft liefern
  jetzt auch das style-Feld zurueck.
- Endpoint /api/aktuelle-themen/generate-presse?style=thread baut den
  Switch ein. Ohne Param weiterhin 'pm'.
- Frontend: PM-Modal zeigt den style-Tag (📰 PM / 🐦 Thread) im Banner
  und bietet einen Knopf "Auch als Thread / Auch als PM" generieren.
  Idempotenz pro (drucksache, news_url, style)-Tripel.

Refs: #170, #178

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:11:16 +02:00

784 lines
36 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-tab { white-space: nowrap; }
@media (max-width: 600px) {
.at-tab { padding: 6px 10px !important; font-size: 10px !important; }
}
.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="50" min="3" max="200" 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" selected>0.40 (default)</option>
<option value="0.50">0.50 (streng)</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;overflow-x:auto;-webkit-overflow-scrolling:touch;">
<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-news-banner" style="font-family:var(--font-mono);font-size:11px;opacity:0.65;margin-bottom:1rem;"></div>
<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';
let _atSingleDate = null; // YYYY-MM-DD wenn aus Chart geklickt
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;
}
}
function clearDateFilter() {
_atSingleDate = null;
loadActiveTab();
}
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');
const banner = document.getElementById('at-news-banner');
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>';
try {
let url = `/api/aktuelle-themen/top?days=${days}&top_k=${topk}&min_similarity=${minsim}&matches_per_news=3&only_relevant=${onlyRel}`;
if (_atSingleDate) url += `&date=${encodeURIComponent(_atSingleDate)}`;
const r = await fetch(url);
const data = await r.json();
// Banner mit Anzeige-Info
let bannerHtml = '';
if (_atSingleDate) {
bannerHtml += `<span style="display:inline-block;padding:3px 10px;background:rgba(0,157,165,0.18);color:var(--ecg-teal);border-radius:11px;font-weight:700;margin-right:8px;">Tag: ${_atSingleDate}</span>`;
bannerHtml += `<button onclick="clearDateFilter()" style="font-family:var(--font-mono);font-size:11px;padding:2px 8px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);cursor:pointer;">× Tag-Filter entfernen</button>`;
bannerHtml += '<br>';
}
bannerHtml += `<strong>${data.n_shown || 0}</strong> News angezeigt · ${data.n_in_window || 0} News im Zeitraum (mit Embedding) · ${data.n_total_news || 0} insgesamt embedded`;
if (onlyRel === '1' && data.n_in_window > data.n_shown) {
bannerHtml += ` · <em>Filter "nur GWÖ-relevant" aktiv</em>`;
}
if (data.n_in_window > parseInt(topk)) {
bannerHtml += ` · top_k=${topk} ist limitierend, mehr News verfügbar`;
}
banner.innerHTML = bannerHtml;
if (!data.buckets || !data.buckets.length) {
list.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">' + (
_atSingleDate ? `Keine News am ${_atSingleDate}` :
(onlyRel === '1' ? 'Keine News mit GWÖ-Relevanz im Zeitfenster — versuch Filter aus' : 'Keine News im Zeitfenster 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,
onClick: (event, elements) => {
// Aus Chart.getElementsAtEventForMode: nehme das X-Achsen-Element
const points = _atZeitreiheChart.getElementsAtEventForMode(
event, 'index', { intersect: false }, false,
);
if (points.length > 0) {
const dayLabel = data.buckets[points[0].index];
if (dayLabel) {
_atSingleDate = dayLabel;
// Auf den News-Tab wechseln
const newsTabBtn = document.querySelector('.at-tab[data-tab="news"]');
if (newsTabBtn) newsTabBtn.click();
}
}
},
onHover: (event, elements) => {
event.native.target.style.cursor = elements.length > 0 ? 'pointer' : 'default';
},
scales: {
y: { beginAtZero: true, stacked: true, title: { display: true, text: 'Artikel/Tag' } },
x: { title: { display: true, text: 'Datum (klick öffnet den News-Tab gefiltert)' } },
},
plugins: {
legend: { position: 'bottom' }
}
}
});
const total = Object.values(data.series).reduce((s, arr) => s + arr.reduce((a, b) => a + b, 0), 0);
meta.innerHTML = `${total} News-Artikel über ${data.buckets.length} Tage, ${data.sources.length} Quellen. <strong>Klick auf einen Tag</strong> wechselt zum News-Tab gefiltert auf diesen Tag.`;
} 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 generateThread(drucksache, newsUrlEnc) {
if (!confirm(`Social-Thread (3-5 Posts) generieren?\n\nFalls bereits ein Thread für diese Drucksache+News existiert, wird dieser ohne LLM-Call zurückgegeben.\nSonst wird mit qwen-max generiert (~6 Cent, ~30 s).`)) return;
try {
const r = await fetch(`/api/aktuelle-themen/generate-presse?drucksache=${encodeURIComponent(drucksache)}&news_url=${newsUrlEnc}&style=thread`, {
method: 'POST',
});
if (!r.ok) {
const err = await r.json();
alert('Fehler: ' + (err.detail || r.statusText));
return;
}
const data = await r.json();
showDraftFromData(data);
if (!data._was_existing) loadDrafts();
} catch (e) {
alert('Fehler: ' + e);
}
}
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 styleLabel = (d.style === 'thread') ? '🐦 Thread (3-5 Posts)' : '📰 Klassische PM';
const otherStyle = (d.style === 'thread') ? 'pm' : 'thread';
const otherLabel = (otherStyle === 'thread') ? 'Auch als Thread' : 'Auch als PM';
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)} · ${styleLabel} · 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>
<button type="button" onclick="generate${otherStyle === 'thread' ? 'Thread' : 'Presse'}('${dsEnc}', '${newsUrlEnc}', this)" style="margin-left:4px;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;">${otherLabel}</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 · ${styleLabel} · 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;flex-wrap:wrap;">
${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>
<a href="/api/aktuelle-themen/drafts/pdf/${d.id}" target="_blank" rel="noopener" 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;">📄 PDF</a>
</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="font-size:13px;line-height:1.6;">${renderPmBody(d.body)}</div>`;
backdrop.style.display = 'flex';
}
// Mini-Markdown-Renderer fuer PM-Body — interpretiert **bold**, *italic*,
// __bold__, _italic_ + Listen + Paragraphen-Breaks. KEIN externer Markdown-
// Parser noetig (kein dependency, schnell, isoliert testbar).
function renderPmBody(body) {
if (!body) return '';
// 1. HTML escapen
let s = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 2. **bold** + __bold__
s = s.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__([^_\n]+?)__/g, '<strong>$1</strong>');
// 3. *italic* + _italic_ (vorsichtig — nur wenn nicht zw. Ziffern)
s = s.replace(/(?<![*\w])\*([^*\n]+?)\*(?![*\w])/g, '<em>$1</em>');
s = s.replace(/(?<![_\w])_([^_\n]+?)_(?![_\w])/g, '<em>$1</em>');
// 4. Listen: "- " oder "* " am Zeilenanfang → Bullet-Punkte
// (vorsichtig: Bindestrich mitten im Wort soll bleiben)
const lines = s.split('\n');
const out = [];
let inList = false;
for (const line of lines) {
if (/^\s*[-*]\s+/.test(line)) {
if (!inList) { out.push('<ul style="margin:0.5em 0;padding-left:1.4em;">'); inList = true; }
out.push('<li>' + line.replace(/^\s*[-*]\s+/, '') + '</li>');
} else {
if (inList) { out.push('</ul>'); inList = false; }
out.push(line);
}
}
if (inList) out.push('</ul>');
s = out.join('\n');
// 5. Doppel-Newlines → </p><p>
const paras = s.split(/\n\s*\n/);
return paras.map(p => {
const trimmed = p.trim();
if (!trimmed) return '';
// Wenn der Block schon mit Tag startet (z.B. <ul>), kein <p>-Wrap
if (trimmed.startsWith('<')) return trimmed;
return '<p style="margin:0 0 0.9em;">' + trimmed.replace(/\n/g, '<br>') + '</p>';
}).filter(Boolean).join('\n');
}
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 %}