"""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 - 320–380 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 ## Paragraphen-Formatierung WICHTIG: trenne die 6 Abschnitte mit **doppeltem Newline** (`\\n\\n`) im JSON-String. NIEMALS Anführungszeichen oder andere Sonderzeichen als Paragraph-Trenner verwenden. Beispiel: ```json {"body": "Lead-Satz.\\n\\nWirkung 1.\\n\\nWirkung 2.\\n\\nWirkung 3.\\n\\nWas fehlt.\\n\\nForderung."} ``` Im JSON: `\\n` als Escape-Sequenz (zwei Zeichen: Backslash + n). NICHT: rohe Newline-Bytes im String, NICHT: `"`-Zeichen als Trenner. ## Hervorhebungen (sparsam) Du darfst pro Absatz **maximal eine** Schlüssel-Zahl oder den zentralen Effekt mit Markdown-`**fett**` markieren — z.B. die Abbrecherquote, eine Mehrkostensumme, eine Anzahl betroffener Personen. Mehr als eine Markierung pro Absatz wirkt unruhig. Niemals ganze Sätze fett, niemals Zwischenüberschriften. ## 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": "", "body": "<320–380 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() # Post-Process Step 1: literal-escapte Sequenzen → echte Whitespaces. # qwen-max liefert manchmal '\\n' als 2 chars statt echtem Newline. body = body.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t") # Post-Process Step 2: einsame Anführungszeichen mitten im Text als # Paragraph-Trenner — qwen tut das gelegentlich trotz Prompt-Anweisung. # Heuristik: ein " zwischen "Punkt-Whitespace" und "Großbuchstabe" ist # wahrscheinlich ein Trenn-Klumpen, kein semantischer Anfuehrer. import re as _re body = _re.sub(r'([.!?])"([A-ZÄÖÜ])', r'\1\n\n\2', body) 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], }