gwoe-antragspruefer/app/presse_generator.py
Dotty Dotter a3d13e984b fix(#170): default min_similarity 0.40 + PM-Prompt als Pressereferent (Issue tba)
**1. Default min_similarity 0.40 statt 0.50.** Live-Test auf dev:
mit 0.50 zeigt only_relevant=true 0 buckets, weil zu strikt fuer die
aktuelle Sparse-Datenlage (77 Bewertungen × 30 News). Mit 0.40 bleiben
1 high + 2 mid News pro 7-Tage-Fenster — genau die kuratierte Sicht,
die wir wollen.

**2. PM-System-Prompt umgeschrieben** als Pressereferent statt
Redakteur. User-Wunsch: "Bürger:innen anschaulich machen, was sich
durch den Antrag konkret im Leben vor Ort aendert".

Pflicht-Elemente im neuen Prompt:
- Konkrete Alltagswirkung (mindestens 2 Beispiele aus Lebenslagen:
  Pflegekraefte, Familien, Mieter:innen, Pendler:innen, ...)
- GWÖ-Verbesserungspotential bei nicht voll ueberzeugenden Antraegen
  (was fehlt, wie ginge es besser aus GWÖ-Sicht)
- Bei negativen Antraegen: klar benennen was verschlechtert wird,
  konkret quantifiziert wo moeglich
- 220–280 Worte (vorher 200–250)
- Aktive Verben, kurze Saetze, keine Floskeln
- Strukturierter Aufbau: Lead → Beispiele + GWÖ-Bewertung →
  Verbesserungspotential → Forderung

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:45:40 +02:00

379 lines
12 KiB
Python
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.

"""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 Pressereferent:in einer Gemeinwohl-Ökonomie-
Initiative. Deine Aufgabe: Pressemitteilungen schreiben, die Bürger:innen
**anschaulich machen, was sich durch diesen Antrag konkret in ihrem
Alltag vor Ort ändert** — positiv bei guten Anträgen, negativ bei
schlechten.
## Pflicht-Elemente
1. **Konkrete Alltagswirkung** — keine Abstraktion. Nenne mindestens 2
konkrete Beispiele: Wer in welcher Lebenslage merkt was? (z.B.
"Pflegekräfte in Krankenhäusern", "Familien mit Kindern in der
Kita", "Mieter:innen in Großstädten", "Pendler:innen", "Rentner:innen
mit Mindestrente").
2. **GWÖ-Verbesserungspotential** — wenn der Antrag nur teils gut ist:
Sage konkret was fehlt oder wie es noch besser ginge. Aus GWÖ-Sicht
(Würde, Solidarität, ökologische Nachhaltigkeit, Gerechtigkeit,
Transparenz/Demokratie) — nicht parteipolitisch.
3. **Drucksache + Quelle nennen** — der Antrag muss klar identifiziert
sein (z.B. "Drucksache 21/4757 des Bundestages"). Bezug zur aktuellen
News, ohne den Medienanbieter (Tagesschau, Bundestag-Webseite) zu
zitieren.
## Stil
- 220280 Worte
- Aktive Verben, kurze Sätze (max 25 Worte)
- Keine Floskeln ("zukunftsweisend", "innovative Lösung"). Stattdessen
konkret: "Familien mit zwei Kindern und 2.800 € Netto-Einkommen
bekommen ..."
- Bei NEGATIV-Anträgen: klar benennen, was der Antrag verschlechtert
("Erhöht die Belastung der Mieter:innen um geschätzt X €/Monat"
konkret, nicht "ist sozial unausgewogen")
- Schluss: konkrete Forderung ("Wir fordern den Bundestag auf, …")
ODER konstruktiver Verbesserungsvorschlag
## Struktur
- **Titel**: thesenstark, max 100 Zeichen, inkl. der Alltagswirkung
(nicht nur Antragsname)
- **Lead-Paragraph** (1-2 Sätze): Wer? Was? Welche Auswirkung im
Alltag?
- **Begründung** (3-4 Sätze): konkrete Beispiele aus dem Leben +
GWÖ-Bewertung
- **Verbesserungspotential** (1-2 Sätze, falls Antrag nicht voll überzeugt)
- **Forderung/Schluss** (1 Satz)
## Output-Format
Antworte NUR mit gültigem JSON:
{
"titel": "<thesenstark, max 100 Zeichen, inkl. konkreter Wirkung>",
"body": "<220280 Worte mit den Pflicht-Elementen>"
}"""
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.
"""
def _find_existing_draft(
drucksache: str, news_url: str, db_path: Path,
) -> Optional[dict]:
"""Sucht einen bereits generierten Draft fuer (drucksache, news_url).
Bei mehreren Treffern wird der NEUESTE zurueckgegeben. Idempotenz-
Schutz vor doppelter LLM-Generierung (#170 Followup).
"""
if not Path(db_path).exists():
return None
conn = sqlite3.connect(str(db_path))
try:
row = conn.execute(
"""SELECT id, drucksache, bundesland, news_url, news_titel,
titel, body, model, created_at
FROM presse_drafts
WHERE drucksache=? AND news_url=?
ORDER BY id DESC LIMIT 1""",
(drucksache, news_url),
).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],
}
async def generate_draft(
drucksache: str,
news_url: str,
db_path: Optional[Path] = None,
bewerter=None,
force: bool = False,
) -> 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.
force: Wenn True, wird auch bei vorhandenem Draft fuer das gleiche
(drucksache, news_url)-Paar ein neuer LLM-Call gemacht.
Default False — Idempotenz-Schutz vor LLM-Kosten.
Returns:
``{"id": int, "drucksache": ..., "bundesland": ...,
"news_url": ..., "news_titel": ...,
"titel": str, "body": str, "model": str, "created_at": ISO,
"_was_existing": bool}``
``_was_existing=True`` zeigt an, dass kein neuer LLM-Call gemacht
wurde, sondern ein vorhandener Draft zurueckgegeben wurde.
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
# Idempotenz-Check: hat es schon einen Draft fuer das Paar?
if not force:
existing = _find_existing_draft(drucksache, news_url, path)
if existing:
existing["_was_existing"] = True
return existing
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()
# Premium-Modell (qwen-max) statt -plus, weil PM-Erzeugung hoehere
# Sprachqualitaet braucht als Antrags-Bewertung. Tradeoff: ~3× teurer
# (~6 Cent statt 2 Cent), ~2× langsamer (~30 s statt 15 s).
model = settings.llm_model_premium
req = LlmRequest(
system_prompt=SYSTEM_PROMPT,
user_prompt=user_prompt,
model=model,
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, model),
)
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 {
"_was_existing": False,
"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 list_drafts_for(
drucksache: str,
news_url: str,
db_path: Optional[Path] = None,
) -> list[dict]:
"""Alle Versions-Drafts fuer ein (drucksache, news_url)-Paar, neueste oben."""
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
WHERE drucksache=? AND news_url=?
ORDER BY id DESC""",
(drucksache, news_url),
).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],
}