User-Feedback nach Live-Test:
**1. Idempotenz** — Pressemitteilungen wurden ungespeichert generiert,
doppelter Klick erzeugte doppelten Draft + LLM-Kosten.
- Neuer Helper `_find_existing_draft(drucksache, news_url)` der den
neuesten Draft fuer das Paar zurueckgibt
- `generate_draft()` prueft per Default zuerst den Lookup, liefert
existing zurueck mit `_was_existing=True` (kein LLM-Call)
- `force=True` Parameter fuer bewusste Neu-Generierung
- Endpoint nimmt `?force=true` Query-Param entgegen
- UI: Modal zeigt klar "Bestehender Entwurf vs Neu generiert" Banner,
mit "Neu generieren"-Button im existing-Banner
**2. Premium-Modell statt Default** — User wollte hoehere Sprachqualitaet
("Opus oder sowas"). Da das Projekt Qwen via DashScope nutzt (kein
Anthropic), Wechsel auf `settings.llm_model_premium` (qwen-max).
- Tradeoff: ~3× teurer (~6 Cent statt 2 Cent) und ~2× langsamer
(~30 s statt 15 s) — aber spuerbare Qualitaetsverbesserung in
Pressemitteilungs-Diktion
- confirm-Dialog im Frontend nennt jetzt 6 Cent + 30 s
**3. Wrapper-Verbesserungen** — `auto-fetch-news.sh` aufgeraeumt:
- Container-Check (skip wenn down) analog zu run-digest.sh
- START/END-Timestamps
- Ausfuehrliche cron-install-Doku im Header
- Auto-Backfill: wenn erster Run >= 100 Embeddings (Limit gehit),
wird embed_pending_articles bis zu 500 weitere nachgeholt
Tests: 5 neue (idempotency, force, _find_existing_draft × 3). Suite
1053 gruen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
449 lines
16 KiB
HTML
449 lines
16 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 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);
|
||
}
|
||
}
|
||
|
||
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, "\\'");
|
||
const existingNote = isExisting
|
||
? `<div style="font-family:var(--font-mono);font-size:10px;opacity:0.7;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>
|
||
</div>`
|
||
: `<div style="font-family:var(--font-mono);font-size:10px;opacity:0.7;background:rgba(136,158,51,0.18);padding:6px 8px;border-radius:3px;margin-bottom:8px;">
|
||
Neu generiert · Modell: ${d.model || '—'}
|
||
</div>`;
|
||
document.getElementById('at-modal-body').innerHTML =
|
||
existingNote +
|
||
`<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 %}
|