Vollständiges 4-Phasen-Feature:
**Phase 1 — News-Aggregator** (`app/news_aggregator.py`)
- Tagesschau-API (`/api2u/news?ressort=...`) für inland/ausland/wirtschaft/wissen
- Bundestag-RSS für aktuellethemen / pressemitteilungen / hib
- DB-Tabelle `news_articles` (URL-PK, idempotent)
- Embeddings via existierender qwen-v4-Pipeline
- Cron-Script `scripts/auto-fetch-news.sh`
- Bewusst NICHT: RND.de (robots.txt bannt explizit ClaudeBot, GPTBot,
CCBot, ChatGPT-User, Google-Extended). Nur AI-erlaubende, öffentlich-
rechtliche/parlamentarische Quellen
- Volltexte werden NICHT persistiert (nur Titel + erster Satz)
**Phase 2 — Themen × Anträge Matching** (`app/themen_matching.py`)
- News-Embedding × Assessment-summary_embedding via Cosine-Similarity
- `find_anträge_for_news`: pro News die Top-K passenden Anträge
- `find_news_for_antrag`: pro Antrag Top-K News mit Datums-Fenster (90d)
- `aggregate_top_themen`: primärer Dashboard-Endpoint
- `aggregate_themen_zeitreihe`: News-Volumen pro Tag × Source
**Phase 3 — Dashboard-View** (`/aktuelle-themen`)
- Neuer linker Nav-Eintrag „Aktuelle Themen"
- Stacked-Area-Chart News-Volumen pro Quelle (30d)
- Pro News-Card: Titel + Summary + Tags + Top-3-Antrags-Match-Liste
mit GWÖ-Score-Pill, Drucksache-Link, PM-Vorschlag-Button
- Filter: Zeitfenster, Top-N, min_similarity
- Auth-protected (require_auth)
**Phase 4 — Pressemitteilungs-Generator** (`app/presse_generator.py`)
- LLM-Prompt-Template (200-250 Worte, GWÖ-Sicht, JSON-Output)
- Reuse von `QwenBewerter` aus app/adapters/qwen_bewerter.py
- DB-Tabelle `presse_drafts` (Persistenz)
- POST `/api/aktuelle-themen/generate-presse` rate-limited 5/min,
auth-only (LLM-Kosten)
- GET `/api/aktuelle-themen/drafts` + `/drafts/{id}` für Liste/Detail
- Manueller Trigger via UI-Button, kein Auto-Versand
- Modal-Anzeige des generierten Texts
**Compliance:**
- robots.txt-respektierend (ClaudeBot-Bann von RND vermieden, AI-
erlaubende Quellen verwendet)
- UI zeigt nur Titel+URL+Datum+erster Satz, keine Volltext-Reproduktion
- Pressemitteilungen sind explizit Drafts, nicht Auto-Versand
- LLM-Calls rate-limited, auth-only
**Tests:** 43 neue Tests (19 news_aggregator + 16 themen_matching +
8 presse_generator). Suite jetzt 1048 grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
418 lines
14 KiB
HTML
418 lines
14 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-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="loadThemen()">
|
||
<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="15" min="3" max="50" style="width:60px;" onchange="loadThemen()" />
|
||
<label for="at-minsim">Min. Similarity:</label>
|
||
<select id="at-minsim" onchange="loadThemen()">
|
||
<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>
|
||
<button onclick="loadThemen()">Aktualisieren</button>
|
||
</div>
|
||
|
||
<!-- News-Volumen-Chart -->
|
||
<h3 style="font-family:var(--font-display);font-size:14px;color:var(--ecg-teal);margin:1.5rem 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:280px;"></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>
|
||
|
||
<!-- Top-Themen + Matches -->
|
||
<h3 style="font-family:var(--font-display);font-size:14px;color:var(--ecg-teal);margin:1.5rem 0 0.5rem;">
|
||
Top-Themen × passende Anträge
|
||
</h3>
|
||
<div id="at-themen-list">
|
||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
|
||
</div>
|
||
|
||
<!-- Drafts-Liste -->
|
||
<h3 style="font-family:var(--font-display);font-size:14px;color:var(--ecg-teal);margin:2rem 0 0.5rem;">
|
||
Pressemitteilungs-Entwürfe (zuletzt generiert)
|
||
</h3>
|
||
<div id="at-drafts-list">
|
||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Entwürfe …</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;
|
||
|
||
function atScoreClass(score) {
|
||
if (score == null) return '';
|
||
if (score >= 7) return 's-high';
|
||
if (score >= 4) return 's-mid';
|
||
return 's-low';
|
||
}
|
||
|
||
function atFmtDatum(s) {
|
||
if (!s || s.length < 10) return '';
|
||
return s.slice(0, 10);
|
||
}
|
||
|
||
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 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`);
|
||
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 oder noch nicht embedded.</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const b of data.buckets) {
|
||
const n = b.news;
|
||
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">${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">Passende Anträge:</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 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 generieren für Drucksache ${drucksache}?\n\nDas erzeugt einen LLM-Call (~2 Cent).`)) 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);
|
||
loadDrafts();
|
||
} catch (e) {
|
||
alert('Fehler: ' + e);
|
||
} finally {
|
||
btn.textContent = 'PM-Vorschlag';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function showDraftFromData(d) {
|
||
const backdrop = document.getElementById('at-modal-backdrop');
|
||
document.getElementById('at-modal-title').textContent = d.titel;
|
||
document.getElementById('at-modal-body').innerHTML =
|
||
`<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>
|
||
<div style="white-space:pre-wrap;">${d.body.replace(/</g, '<')}</div>`;
|
||
backdrop.style.display = 'flex';
|
||
}
|
||
|
||
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
|
||
loadZeitreihe();
|
||
loadThemen();
|
||
loadDrafts();
|
||
</script>
|
||
{% endblock %}
|