Vorher: `onclick="copyDraftToClipboard(this, ${JSON.stringify(...).replace(/"/g, '"')}, ...)"`
— funktional korrekt, aber Pattern-anfaellig (gleiche Klasse wie der
merkliste-bug aus Phase 17). Plus < und > waren nicht escaped.
Nachher: Button traegt nur eine numerische data-pm-id; der Handler
fetched den Draft per API und kopiert den Body. Robuster, weniger
Quote-Escaping, einheitlicher mit dem versionsHtml-Pattern oben in
derselben Datei.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
796 lines
36 KiB
HTML
796 lines
36 KiB
HTML
{% 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;">×</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" data-pm-id="${d.id}" onclick="copyDraftToClipboard(this)" 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
// 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) {
|
||
// Daten aus dem Modal-State holen — vermeidet HTML-Attribut-Quoting-
|
||
// Probleme mit JSON.stringify (siehe ADR 0011 Folge-Erkenntnis).
|
||
const draftId = btn.dataset.pmId;
|
||
let titel = '', body = '';
|
||
try {
|
||
const r = await fetch(`/api/aktuelle-themen/drafts/${draftId}`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
titel = d.titel || '';
|
||
body = d.body || '';
|
||
}
|
||
} catch (_) {}
|
||
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 %}
|