gwoe-antragspruefer/app/presse_generator.py
Dotty Dotter a0559333e8 fix(#170): JSON-Parse-Fehler im PM-Generator (unescaped Newlines)
Beobachtung beim Force-Regen: alle 2 Retries scheiterten mit
"Invalid control character at: line 3 column 275". qwen-max produziert
JSON mit rohen \n statt \\n im body-String, was json.loads sprengt.

Zwei Fixes parallel:

**1. response_format={"type": "json_object"}** als optionaler Mode im
LlmRequest. PM-Generator setzt das jetzt. DashScope unterstuetzt das
fuer qwen-max + qwen-plus und zwingt valide JSON-Strings.

**2. Newline-Recovery als Fallback** im QwenBewerter:
`_recover_unescaped_newlines` iteriert char-weise mit String-Tracking,
ersetzt unescaped \n/\r/\t in Strings durch \\n/\\r/\\t. Backslash-
Folgen bleiben unangetastet. Wird vor dem Retry-Re-throw versucht.

Bewertungs-Pfad (analyzer.py) bekommt json_object_mode=False als Default,
um die bewaehrte Retry-Semantik nicht zu aendern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:53:29 +02:00

443 lines
15 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. Du schreibst für **Bürger:innen vor Ort**, nicht für
Fachpublikum. Deine PM erklärt nur eines: **Was ändert sich durch diesen
Antrag konkret im Alltag — positiv oder negativ?**
## ABSOLUT VERBOTEN im PM-Text
Diese Begriffe und Konstrukte dürfen im Body NICHT vorkommen:
- **Numerische Scores oder Bewertungen** — kein "GWÖ-Score 4/10",
kein "X von 10 Punkten", kein "der Antrag erhält". Du verwendest
die Bewertung nur INTERN als Kompass. Im Text: nur die Wirkung.
- **GWÖ-Wert-Listen als Aufzählung** — kein "stärkt Menschenwürde,
Solidarität und Demokratie". Stattdessen die konkrete Wirkung beim
Bürger nennen.
- **GWÖ-Berührungsgruppen-Sprache** — kein "in den Bereichen Bürger,
Wirtschaft, Staat, Gesellschaft und Natur", kein "ökologischer
Wirkungshorizont", kein "Lieferant:innen-Dimension".
- **Matrix-Codes** — nie "Feld D2", "A1", "Würde×Lieferanten".
- **GWÖ-Begriffe als Werte-Schlagwort** — Begriffe wie "Solidarität",
"Würde", "Nachhaltigkeit", "Gerechtigkeit", "Demokratie",
"Gemeinwohl" dürfen jeweils maximal EINMAL vorkommen, und nur dann,
wenn sie eine konkrete Handlung qualifizieren ("solidarisch finanziert
durch eine Mehreinnahme aus Erbschaftsteuer" ✓ ja).
- **Floskeln**: "zukunftsweisend", "innovativ", "richtungsweisend",
"Systemwechsel", "faktenbasierter Dialog", "wir laden zum Dialog ein",
"im Sinne von", "zielgerichtet", "ganzheitlich", "umfassend",
"ausgewogen", "nachhaltige Zukunft sichern".
## PFLICHT im PM-Text
Mindestens DREI dieser Bürger:innen-Lebenslagen müssen mit konkreter,
quantifizierter oder qualitativer Wirkung benannt werden:
- **Familien mit Kindern**: konkrete Beträge, KiTa-Plätze, Schulgeld, Wohnraum
- **Pflegebedürftige + ihre Angehörigen**: Wartezeiten, Eigenanteile,
Heimplatz-Kosten
- **Auszubildende / Studierende**: Ausbildungsabbruch-Risiko, BAföG,
Mietkosten
- **Pendler:innen**: Spritpreis, ÖPNV-Tarif, Anbindung
- **Mieter:innen / Eigentümer:innen**: Mietniveau, Nebenkosten,
Sanierungskosten
- **Rentner:innen / Geringverdiener:innen**: Kaufkraft-Effekt in Euro
- **Selbstständige / kleine Betriebe**: bürokratische Pflicht-Stunden,
Energiekosten, Steuern
Pro Lebenslage: ein konkreter Effekt ("verlängert die Wartezeit auf einen
Heimplatz von 8 auf 12 Wochen", "spart einer vierköpfigen Familie etwa
1.800 € pro Jahr", "erhöht die Mietnebenkosten in Bestandsgebäuden um
geschätzt 25 €/Monat").
## Wenn die GWÖ-Bewertung KRITISCH ist (intern niedrig)
Drücke das in der PM aus über:
- **Wer verliert** ("Mieter:innen in Großstädten zahlen mehr")
- **Was fehlt** ("Der Antrag adressiert nicht die ökologischen Folgen
des Strassenausbaus, obwohl 40 % der CO2-Emissionen aus Verkehr stammen")
- **Was eine bessere Alternative wäre** ("Statt der Pendlerpauschale
würde ein Mobilitätsgeld unabhängig vom Verkehrsmittel auch
ÖPNV-Nutzer:innen entlasten")
## Wenn die GWÖ-Bewertung POSITIV ist
Drücke das aus über:
- **Wer gewinnt konkret** ("Auszubildende mit Lernschwierigkeiten bekommen
2 Stunden Beratung pro Woche")
- **Was sich messbar verbessert** ("die Abbrecherquote in der Pflege
könnte um geschätzt 15 % sinken")
- **Wo der Antrag stärker werden könnte** (1-2 konkrete Vorschläge,
ohne Floskel)
## Stil
- 320380 Worte (länger als bisher — konkrete Beispiele brauchen Platz)
- Aktive Verben, kurze Sätze (max 22 Worte)
- Drucksachen-Nummer einmal im Lead nennen ("Drucksache 21/4757")
- Bezug zur News-Lage in 1 Satz, ohne den Medienanbieter zu nennen
- Keine Negativ-Polemik gegen Parteien — sachliche Kritik am Inhalt
## Struktur
1. **Lead-Paragraph** (2-3 Sätze): Welche Bürger:innengruppe wird wie
betroffen? Drucksache nennen.
2. **Konkrete Wirkung 1** (3-4 Sätze): erste Lebenslage + Effekt
3. **Konkrete Wirkung 2** (3-4 Sätze): zweite Lebenslage + Effekt
4. **Konkrete Wirkung 3** (2-3 Sätze): dritte Lebenslage + Effekt
5. **Was fehlt / was wäre besser** (2-3 Sätze): konkreter Vorschlag
6. **Schluss-Satz**: was wir fordern, ohne Floskel
## BEISPIELE für den Stil
**SCHLECHT** (verboten):
> Der Antrag stärkt Menschenwürde, Solidarität und Demokratie. Er trägt
> zu einer nachhaltigeren Zukunft bei und stärkt das Gemeinwohl in den
> Bereichen Bürger:innen und Staat. GWÖ-Score: 8.0/10.
**GUT** (gewünscht):
> Auszubildende in der Pflege brechen ihre Ausbildung heute zu rund
> 30 % ab — meist wegen Überlastung oder fehlender Lernunterstützung.
> Die in Drucksache 8/310 vorgeschlagene sozialpädagogische Begleitung
> würde diese Lücke schließen. Konkret: zwei Stunden Einzelberatung
> pro Auszubildender pro Woche. Für Familien, deren Kinder einen
> Pflegeberuf wählen, sinkt damit das Risiko, dass die teure Ausbildung
> erfolglos endet. Für Krankenhäuser und Altenheime in Brandenburg
> bedeutet das: in fünf Jahren etwa 800 zusätzliche fertig ausgebildete
> Pflegekräfte. Was der Antrag nicht regelt: die Bezahlung in der
> Ausbildungszeit selbst. Solange Auszubildende neben dem Lernen
> arbeiten müssen, um die Miete zu zahlen, hilft auch die beste Beratung
> nur begrenzt. Wir fordern, eine Mindest-Ausbildungsvergütung
> mitzudenken.
## Output-Format
Antworte NUR mit gültigem JSON:
{
"titel": "<thesenstark, max 100 Zeichen, NENNT die Bürger:innengruppe oder den konkreten Effekt — nicht den GWÖ-Score>",
"body": "<320380 Worte. Mindestens 3 Lebenslagen mit konkretem Effekt. Keine GWÖ-Werte-Aufzählung. Kein Score.>"
}"""
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,
json_object_mode=True,
)
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],
}