feat(#178 Phase 4.2): PM-Variante 'thread' fuer Mastodon/Twitter-Threads

- Schema additiv: presse_drafts.style TEXT NOT NULL DEFAULT 'pm' via
  ALTER TABLE (idempotent in init_db).
- presse_generator.generate_draft(style='pm'|'thread') nutzt eigenen
  SYSTEM_PROMPT_THREAD (3-5 Posts à ≤280 Zeichen, Hook + Lebenslagen +
  Forderung, Hashtags am Schluss; keine **fett**-Markdown).
- _find_existing_draft, list_drafts, list_drafts_for, get_draft liefern
  jetzt auch das style-Feld zurueck.
- Endpoint /api/aktuelle-themen/generate-presse?style=thread baut den
  Switch ein. Ohne Param weiterhin 'pm'.
- Frontend: PM-Modal zeigt den style-Tag (📰 PM / 🐦 Thread) im Banner
  und bietet einen Knopf "Auch als Thread / Auch als PM" generieren.
  Idempotenz pro (drucksache, news_url, style)-Tripel.

Refs: #170, #178

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-06 16:11:16 +02:00
parent 4bb267aace
commit a2b8f8c6fe
4 changed files with 106 additions and 25 deletions

View File

@ -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 (

View File

@ -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 ~320380 Worten.
``style='thread'`` Mastodon/Twitter-Thread (35 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))

View File

@ -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 (35 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.
- 12 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": "<Hook-Satz, max 100 Zeichen>",
"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",
}

View File

@ -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
? `<div style="font-family:var(--font-mono);font-size:10px;opacity:0.85;background:rgba(247,148,29,0.18);padding:6px 8px;border-radius:3px;margin-bottom:8px;">
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
<button type="button" onclick="regeneratePresse('${dsEnc}', '${newsUrlEnc}')" style="margin-left:8px;font-family:var(--font-mono);font-size:10px;padding:2px 8px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);cursor:pointer;">Neu generieren</button>
<button type="button" onclick="generate${otherStyle === 'thread' ? 'Thread' : 'Presse'}('${dsEnc}', '${newsUrlEnc}', this)" style="margin-left:4px;font-family:var(--font-mono);font-size:10px;padding:2px 8px;border:1px solid var(--ecg-border);border-radius:3px;background:var(--ecg-card-bg);cursor:pointer;">${otherLabel}</button>
${versionsHtml}
</div>`
: `<div style="font-family:var(--font-mono);font-size:10px;opacity:0.85;background:rgba(136,158,51,0.18);padding:6px 8px;border-radius:3px;margin-bottom:8px;">
Neu generiert · Modell: ${d.model || '—'} ${versionsHtml}
Neu generiert · ${styleLabel} · Modell: ${d.model || '—'} ${versionsHtml}
</div>`;
// Action-Buttons: Mail + Clipboard