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:
parent
4bb267aace
commit
a2b8f8c6fe
@ -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 (
|
||||
|
||||
12
app/main.py
12
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))
|
||||
|
||||
@ -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": "<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",
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user