gwoe-antragspruefer/app/templates/v2/screens/aktuelle-themen.html
Dotty Dotter 2bff943e8a feat(#170 followup): PM-Generator Idempotenz + qwen-max + Wrapper-Verbesserungen
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>
2026-05-03 13:10:20 +02:00

449 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "v2/base.html" %}
{% block title %}Aktuelle Themen — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "aktuelle-themen" %}
{% block head_extra %}
<script src="/static/chart.umd.min.js"></script>
<style>
.at-controls {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
font-family: var(--font-mono);
font-size: 11px;
}
.at-controls select, .at-controls input[type="number"] {
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 8px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
background: var(--ecg-card-bg);
color: var(--ecg-dark);
}
.at-controls button {
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 12px;
border: 1px solid var(--ecg-border);
border-radius: 3px;
cursor: pointer;
background: var(--ecg-teal);
color: #fff;
}
.at-news-card {
background: var(--ecg-card-bg);
border: 1px solid var(--ecg-border);
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 14px;
}
.at-news-head {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
margin-bottom: 4px;
}
.at-news-title {
font-family: var(--font-display);
font-size: 15px;
color: var(--ecg-teal);
margin: 0 0 6px;
line-height: 1.3;
}
.at-news-title a { color: inherit; text-decoration: none; }
.at-news-title a:hover { text-decoration: underline; }
.at-news-summary {
font-size: 12px;
line-height: 1.5;
margin: 0 0 10px;
opacity: 0.85;
}
.at-news-tags {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.55;
margin-bottom: 8px;
}
.at-tag {
display: inline-block;
padding: 1px 6px;
background: var(--ecg-bg-subtle);
border-radius: 3px;
margin-right: 4px;
}
.at-matches {
border-top: 1px solid var(--ecg-border);
margin-top: 10px;
padding-top: 10px;
}
.at-matches-label {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
margin-bottom: 6px;
}
.at-match {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 0;
font-size: 12px;
border-bottom: 1px dotted var(--ecg-border);
}
.at-match:last-child { border-bottom: none; }
.at-score-pill {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
background: var(--ecg-bg-subtle);
min-width: 28px;
text-align: center;
}
.at-score-pill.s-high { background: rgba(136,158,51,0.25); color: #44570a; }
.at-score-pill.s-mid { background: rgba(247,148,29,0.18); color: #875e10; }
.at-score-pill.s-low { background: rgba(200,0,0,0.15); color: #931515; }
.at-sim {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.5;
}
.at-presse-btn {
background: var(--ecg-card-bg);
color: var(--ecg-teal);
border: 1px solid var(--ecg-teal);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
margin-left: auto;
}
.at-presse-btn:hover { background: var(--ecg-teal); color: #fff; }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1.5rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Aktuelle Themen</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Tagesschau + Bundestag-RSS · gematcht mit deinen Anträgen ·
Pressemitteilungs-Vorschläge
</p>
</div>
<div class="v2-kasten outline-blue" style="margin-bottom:1rem;">
<p style="font-size:12px;line-height:1.5;margin:0 0 0.5rem;">
Die täglich aktuellen politischen Top-Themen aus
<strong>öffentlich-rechtlichen + parlamentarischen Quellen</strong>
(Tagesschau-API + Bundestag-RSS) werden semantisch mit den von dir
bewerteten Anträgen verschnitten. Pro News-Artikel siehst du die
GWÖ-Bewertung der dazu passendsten Anträge — und kannst per Klick
eine Pressemitteilung generieren lassen.
</p>
<p style="font-size:11px;line-height:1.5;opacity:0.75;margin:0;">
Bewusst <strong>nicht</strong> verwendet: Quellen mit AI-Bann in
robots.txt (z.B. RND.de). Die UI zeigt nur Titel + URL + erste Sätze
— Volltexte werden nicht persistiert.
</p>
</div>
<div class="at-controls">
<label for="at-days">Zeitfenster:</label>
<select id="at-days" onchange="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;">&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;
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, '&lt;')}</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 %}