gwoe-antragspruefer/app/presse_generator.py
Dotty Dotter d54ce23e42 feat(#170): Aktuelle-Themen-Dashboard — News × Anträge × Pressemitteilungen
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>
2026-05-03 12:39:36 +02:00

257 lines
7.8 KiB
Python

"""Pressemitteilungs-Generator fuer #170 Phase 4.
Erzeugt einen LLM-generierten Pressemitteilungs-Vorschlag, der einen
GWÖ-bewerteten Antrag in den Kontext eines aktuellen News-Artikels stellt.
Manueller Trigger via UI-Button — kein Auto-Versand. Drafts werden in
``presse_drafts`` persistiert und in der UI als Liste sichtbar.
Tonalitaet:
- GWÖ-Sicht (Gemeinwohl-orientiert, nicht parteipolitisch)
- Faktenbasiert, keine Lobbying-Sprache
- 200-250 Worte, presseaehnlicher Aufbau (Lead-Paragraph + Begruendung)
"""
from __future__ import annotations
import json
import logging
import sqlite3
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """Du bist ein politischer Redakteur, der für eine
Gemeinwohl-Ökonomie-Initiative Pressemitteilungen schreibt. Deine Stil-
Richtlinien:
- 200-250 Worte
- Sachlicher, präziser Stil — keine Werbesprache, keine Polemik
- Faktenbasiert: Daten aus dem Antrag und dem News-Kontext explizit nennen
- GWÖ-Werte (Würde, Solidarität, Nachhaltigkeit, Gerechtigkeit, Demokratie)
als Bewertungsmaßstab — nicht parteipolitische Linie
- Klare Struktur: Titel, Lead-Paragraph (Wer? Was? Wann? Warum jetzt?),
Begründung mit Bezug auf GWÖ-Bewertung, Schluss mit Forderung oder
Einladung zum Dialog
- Niemals den Anbieter der News-Quelle (Tagesschau, Bundestag) zitieren —
nur den Sachverhalt aufgreifen, der dort beschrieben ist
Antworte NUR mit gültigem JSON in dieser Struktur:
{
"titel": "<knackiger Titel, max 100 Zeichen>",
"body": "<Pressemitteilungs-Volltext, 200-250 Wörter>"
}"""
def _build_user_prompt(
drucksache: str,
bundesland: str,
antrag_titel: str,
antrag_zusammenfassung: str,
gwoe_score: float,
gwoe_begruendung: str,
empfehlung: str,
news_titel: str,
news_summary: str,
news_url: str,
) -> str:
"""Konstruiert den User-Prompt aus Antrags- und News-Daten."""
return f"""## Aktueller Antrag
Drucksache: {drucksache} ({bundesland})
Titel: {antrag_titel}
Zusammenfassung: {antrag_zusammenfassung or "(keine vorhanden)"}
GWÖ-Score: {gwoe_score}/10
GWÖ-Begründung: {gwoe_begruendung or "(keine vorhanden)"}
Empfehlung: {empfehlung or "(keine)"}
## Aktueller Nachrichten-Kontext
Schlagzeile: {news_titel}
Inhalt: {news_summary or "(keine Zusammenfassung verfügbar)"}
Quelle: {news_url}
## Deine Aufgabe
Schreibe eine Pressemitteilung, die diesen Antrag in den Kontext der
aktuellen Nachrichtenlage stellt. Begründe aus GWÖ-Sicht, warum der
Antrag gerade jetzt relevant ist (oder warum er die aktuelle Debatte
ergänzt/korrigiert). Wenn der GWÖ-Score niedrig ist (< 5), sei dabei
kritisch — die PM kann auch eine Ablehnung des Antrags begründen.
"""
async def generate_draft(
drucksache: str,
news_url: str,
db_path: Optional[Path] = None,
bewerter=None,
) -> dict:
"""Erzeugt einen Pressemitteilungs-Draft und persistiert ihn.
Args:
drucksache: ID des Antrags (mit Bundesland-Kontext aus DB).
news_url: URL des News-Artikels (Lookup in news_articles).
db_path: optional override fuer Tests.
bewerter: optional injected QwenBewerter (fuer Tests). Wenn None,
wird der Default mit settings instanziiert.
Returns:
``{"id": int, "drucksache": ..., "bundesland": ...,
"news_url": ..., "news_titel": ...,
"titel": str, "body": str, "model": str, "created_at": ISO}``
Raises:
ValueError: wenn drucksache oder news_url nicht gefunden.
"""
from .config import settings
from .adapters.qwen_bewerter import LlmRequest
path = db_path or settings.db_path
conn = sqlite3.connect(str(path))
try:
antrag = conn.execute(
"""SELECT bundesland, title, antrag_zusammenfassung, gwoe_score,
gwoe_begruendung, empfehlung
FROM assessments WHERE drucksache=?""",
(drucksache,),
).fetchone()
news = conn.execute(
"SELECT titel, summary FROM news_articles WHERE url=?",
(news_url,),
).fetchone()
finally:
conn.close()
if not antrag:
raise ValueError(f"Drucksache {drucksache} nicht in assessments")
if not news:
raise ValueError(f"News-URL {news_url} nicht in news_articles")
user_prompt = _build_user_prompt(
drucksache=drucksache,
bundesland=antrag[0],
antrag_titel=antrag[1] or "",
antrag_zusammenfassung=antrag[2] or "",
gwoe_score=antrag[3] or 0.0,
gwoe_begruendung=antrag[4] or "",
empfehlung=antrag[5] or "",
news_titel=news[0],
news_summary=news[1] or "",
news_url=news_url,
)
if bewerter is None:
from .adapters.qwen_bewerter import QwenBewerter
bewerter = QwenBewerter()
req = LlmRequest(
system_prompt=SYSTEM_PROMPT,
user_prompt=user_prompt,
model=settings.llm_model_default,
base_temperature=0.3,
max_tokens=1500,
max_retries=2,
)
result = await bewerter.bewerte(req)
titel = (result.get("titel") or "").strip()[:200]
body = (result.get("body") or "").strip()
if not titel or not body:
raise ValueError("LLM-Response unvollständig (titel oder body leer)")
# Persist
conn = sqlite3.connect(str(path))
try:
cur = conn.execute(
"""INSERT INTO presse_drafts
(drucksache, bundesland, news_url, news_titel, titel, body, model)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(drucksache, antrag[0], news_url, news[0], titel, body,
settings.llm_model_default),
)
draft_id = cur.lastrowid
row = conn.execute(
"""SELECT id, drucksache, bundesland, news_url, news_titel,
titel, body, model, created_at
FROM presse_drafts WHERE id=?""",
(draft_id,),
).fetchone()
conn.commit()
finally:
conn.close()
return {
"id": row[0], "drucksache": row[1], "bundesland": row[2],
"news_url": row[3], "news_titel": row[4],
"titel": row[5], "body": row[6], "model": row[7],
"created_at": row[8],
}
def list_drafts(
limit: int = 20,
db_path: Optional[Path] = None,
) -> list[dict]:
"""Liste der zuletzt generierten Drafts. Default-Limit 20."""
from .config import settings
path = db_path or settings.db_path
if not Path(path).exists():
return []
conn = sqlite3.connect(str(path))
try:
rows = conn.execute(
"""SELECT id, drucksache, bundesland, news_url, news_titel,
titel, body, model, created_at
FROM presse_drafts
ORDER BY id DESC LIMIT ?""",
(limit,),
).fetchall()
finally:
conn.close()
return [
{
"id": r[0], "drucksache": r[1], "bundesland": r[2],
"news_url": r[3], "news_titel": r[4],
"titel": r[5], "body": r[6], "model": r[7],
"created_at": r[8],
}
for r in rows
]
def get_draft(
draft_id: int,
db_path: Optional[Path] = None,
) -> Optional[dict]:
"""Einen Draft per ID abrufen."""
from .config import settings
path = db_path or settings.db_path
if not Path(path).exists():
return None
conn = sqlite3.connect(str(path))
try:
row = conn.execute(
"""SELECT id, drucksache, bundesland, news_url, news_titel,
titel, body, model, created_at
FROM presse_drafts WHERE id=?""",
(draft_id,),
).fetchone()
finally:
conn.close()
if not row:
return None
return {
"id": row[0], "drucksache": row[1], "bundesland": row[2],
"news_url": row[3], "news_titel": row[4],
"titel": row[5], "body": row[6], "model": row[7],
"created_at": row[8],
}