diff --git a/app/database.py b/app/database.py index b01fbe5..3397523 100644 --- a/app/database.py +++ b/app/database.py @@ -334,6 +334,15 @@ async def init_db(): "ON presse_drafts(created_at DESC)" ) + # Phase 4.2: style-Feld additiv ergänzen ('pm'|'thread') + try: + await db.execute( + "ALTER TABLE presse_drafts ADD COLUMN style TEXT NOT NULL DEFAULT 'pm'" + ) + except Exception: + # Spalte existiert bereits + pass + # auto_rate_runs (#173 Phase 3) — Tracking der Vote-Orphans-Auto-Bewertung await db.execute(""" CREATE TABLE IF NOT EXISTS auto_rate_runs ( diff --git a/app/main.py b/app/main.py index e0e2b58..321c1fd 100644 --- a/app/main.py +++ b/app/main.py @@ -2212,20 +2212,26 @@ async def api_generate_presse( drucksache: str, news_url: str, force: bool = False, + style: str = "pm", current_user: dict = Depends(require_auth), ): """Generiert einen LLM-Pressemitteilungs-Vorschlag. Auth-only + rate-limited (5/min) wegen LLM-Kosten. + ``style='pm'`` (Default) → klassische PM mit ~320–380 Worten. + ``style='thread'`` → Mastodon/Twitter-Thread (3–5 Posts à ≤280 Zeichen). + ``force=True`` ueberschreibt den Idempotenz-Check und macht einen - neuen LLM-Call, auch wenn fuer (drucksache, news_url) schon ein - Draft existiert. + neuen LLM-Call, auch wenn fuer (drucksache, news_url, style) schon + ein Draft existiert. """ + if style not in ("pm", "thread"): + raise HTTPException(status_code=400, detail="style muss 'pm' oder 'thread' sein") from .presse_generator import generate_draft try: return await generate_draft( - drucksache=drucksache, news_url=news_url, force=force, + drucksache=drucksache, news_url=news_url, force=force, style=style, ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/app/presse_generator.py b/app/presse_generator.py index 3353271..629a61b 100644 --- a/app/presse_generator.py +++ b/app/presse_generator.py @@ -163,6 +163,44 @@ Antworte NUR mit gültigem JSON: }""" +SYSTEM_PROMPT_THREAD = """Du bist Social-Media-Redakteur:in einer +Gemeinwohl-Ökonomie-Initiative. Erzeuge einen knappen Thread (3–5 Posts) +für Mastodon/Twitter, der einen GWÖ-bewerteten Antrag im Kontext einer +aktuellen Nachricht erklärt — für Bürger:innen, nicht für Fachpublikum. + +## ABSOLUT VERBOTEN + +- Numerische GWÖ-Scores oder Bewertungs-Zahlen. +- GWÖ-Werte-Listen ("Würde, Solidarität, …") als Schlagwortkette. +- Matrix-Codes (D2, A1, …) und GWÖ-Berührungsgruppen-Sprache. +- Lobbyfloskeln ("zukunftsweisend", "innovativ", "richtungsweisend"). +- Reine Schlagworte ohne konkrete Folge im Alltag. + +## Stil + +- Pro Post **maximal 280 Zeichen** (inkl. Hashtags). Kein Post länger. +- Erster Post ist der **Hook**: konkrete Bürger:innengruppe + sichtbare + Folge. Drucksache nennen. +- Mittlere Posts: je eine konkrete Lebenslage + Effekt in Zahlen oder + Personen. Aktive Verben. +- Letzter Post: was wir fordern, klar, ohne Floskel. +- 1–2 thematische Hashtags am Schluss (z.B. #GWO #Pflege). + +## Hervorhebungen + +Keine Markdown-Formatierung im Body — Mastodon/Twitter rendert das nicht. +Ausnahme: Zahlen können dezent in Klammern ergänzt werden („30 %", „800 +Pflegekräfte"). Kein **fett**, kein _kursiv_. + +## Output-Format + +Antworte NUR mit gültigem JSON: +{ + "titel": "", + "body": "<3-5 Posts, getrennt durch \\n\\n. Jeder Post ≤280 Zeichen.>" +}""" + + def _build_user_prompt( drucksache: str, bundesland: str, @@ -207,8 +245,9 @@ kritisch — die PM kann auch eine Ablehnung des Antrags begründen. def _find_existing_draft( drucksache: str, news_url: str, db_path: Path, + style: str = "pm", ) -> Optional[dict]: - """Sucht einen bereits generierten Draft fuer (drucksache, news_url). + """Sucht einen bereits generierten Draft fuer (drucksache, news_url, style). Bei mehreren Treffern wird der NEUESTE zurueckgegeben. Idempotenz- Schutz vor doppelter LLM-Generierung (#170 Followup). @@ -219,11 +258,11 @@ def _find_existing_draft( try: row = conn.execute( """SELECT id, drucksache, bundesland, news_url, news_titel, - titel, body, model, created_at + titel, body, model, created_at, style FROM presse_drafts - WHERE drucksache=? AND news_url=? + WHERE drucksache=? AND news_url=? AND style=? ORDER BY id DESC LIMIT 1""", - (drucksache, news_url), + (drucksache, news_url, style), ).fetchone() finally: conn.close() @@ -233,7 +272,7 @@ def _find_existing_draft( "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], + "created_at": row[8], "style": row[9], } @@ -243,6 +282,7 @@ async def generate_draft( db_path: Optional[Path] = None, bewerter=None, force: bool = False, + style: str = "pm", ) -> dict: """Erzeugt einen Pressemitteilungs-Draft und persistiert ihn. @@ -272,10 +312,12 @@ async def generate_draft( from .adapters.qwen_bewerter import LlmRequest path = db_path or settings.db_path + if style not in ("pm", "thread"): + raise ValueError(f"unbekannter style: {style}") - # Idempotenz-Check: hat es schon einen Draft fuer das Paar? + # Idempotenz-Check: hat es schon einen Draft fuer das (Paar, style)? if not force: - existing = _find_existing_draft(drucksache, news_url, path) + existing = _find_existing_draft(drucksache, news_url, path, style=style) if existing: existing["_was_existing"] = True return existing @@ -322,8 +364,9 @@ async def generate_draft( # (~6 Cent statt 2 Cent), ~2× langsamer (~30 s statt 15 s). model = settings.llm_model_premium + system_prompt_active = SYSTEM_PROMPT_THREAD if style == "thread" else SYSTEM_PROMPT req = LlmRequest( - system_prompt=SYSTEM_PROMPT, + system_prompt=system_prompt_active, user_prompt=user_prompt, model=model, base_temperature=0.3, @@ -347,19 +390,19 @@ async def generate_draft( if not titel or not body: raise ValueError("LLM-Response unvollständig (titel oder body leer)") - # Persist + # Persist (style additiv im Insert) 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), + (drucksache, bundesland, news_url, news_titel, titel, body, model, style) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (drucksache, antrag[0], news_url, news[0], titel, body, model, style), ) draft_id = cur.lastrowid row = conn.execute( """SELECT id, drucksache, bundesland, news_url, news_titel, - titel, body, model, created_at + titel, body, model, created_at, style FROM presse_drafts WHERE id=?""", (draft_id,), ).fetchone() @@ -372,7 +415,7 @@ async def generate_draft( "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], + "created_at": row[8], "style": row[9], } @@ -390,7 +433,7 @@ def list_drafts( try: rows = conn.execute( """SELECT id, drucksache, bundesland, news_url, news_titel, - titel, body, model, created_at + titel, body, model, created_at, style FROM presse_drafts ORDER BY id DESC LIMIT ?""", (limit,), @@ -402,7 +445,7 @@ def list_drafts( "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], + "created_at": r[8], "style": r[9] if len(r) > 9 else "pm", } for r in rows ] @@ -423,7 +466,7 @@ def list_drafts_for( try: rows = conn.execute( """SELECT id, drucksache, bundesland, news_url, news_titel, - titel, body, model, created_at + titel, body, model, created_at, style FROM presse_drafts WHERE drucksache=? AND news_url=? ORDER BY id DESC""", @@ -436,7 +479,7 @@ def list_drafts_for( "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], + "created_at": r[8], "style": r[9] if len(r) > 9 else "pm", } for r in rows ] @@ -456,7 +499,7 @@ def get_draft( try: row = conn.execute( """SELECT id, drucksache, bundesland, news_url, news_titel, - titel, body, model, created_at + titel, body, model, created_at, style FROM presse_drafts WHERE id=?""", (draft_id,), ).fetchone() @@ -468,5 +511,5 @@ def get_draft( "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], + "created_at": row[8], "style": row[9] if len(row) > 9 else "pm", } diff --git a/app/templates/v2/screens/aktuelle-themen.html b/app/templates/v2/screens/aktuelle-themen.html index 6983de9..3e9a553 100644 --- a/app/templates/v2/screens/aktuelle-themen.html +++ b/app/templates/v2/screens/aktuelle-themen.html @@ -601,6 +601,25 @@ async function generatePresse(drucksache, newsUrlEnc, btn) { } } +async function generateThread(drucksache, newsUrlEnc) { + if (!confirm(`Social-Thread (3-5 Posts) generieren?\n\nFalls bereits ein Thread für diese Drucksache+News existiert, wird dieser ohne LLM-Call zurückgegeben.\nSonst wird mit qwen-max generiert (~6 Cent, ~30 s).`)) return; + try { + const r = await fetch(`/api/aktuelle-themen/generate-presse?drucksache=${encodeURIComponent(drucksache)}&news_url=${newsUrlEnc}&style=thread`, { + method: 'POST', + }); + if (!r.ok) { + const err = await r.json(); + alert('Fehler: ' + (err.detail || r.statusText)); + return; + } + const data = await r.json(); + showDraftFromData(data); + if (!data._was_existing) loadDrafts(); + } catch (e) { + alert('Fehler: ' + e); + } +} + 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 { @@ -642,14 +661,18 @@ async function showDraftFromData(d) { } } catch (e) { /* silent */ } + const styleLabel = (d.style === 'thread') ? '🐦 Thread (3-5 Posts)' : '📰 Klassische PM'; + const otherStyle = (d.style === 'thread') ? 'pm' : 'thread'; + const otherLabel = (otherStyle === 'thread') ? 'Auch als Thread' : 'Auch als PM'; const banner = isExisting ? `
- Bestehender Entwurf vom ${(d.created_at || '').slice(0,10)} · Modell: ${d.model || '—'} · kein LLM-Call + Bestehender Entwurf vom ${(d.created_at || '').slice(0,10)} · ${styleLabel} · Modell: ${d.model || '—'} · kein LLM-Call + ${versionsHtml}
` : `
- Neu generiert · Modell: ${d.model || '—'} ${versionsHtml} + Neu generiert · ${styleLabel} · Modell: ${d.model || '—'} ${versionsHtml}
`; // Action-Buttons: Mail + Clipboard