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)"
|
"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
|
# auto_rate_runs (#173 Phase 3) — Tracking der Vote-Orphans-Auto-Bewertung
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS auto_rate_runs (
|
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,
|
drucksache: str,
|
||||||
news_url: str,
|
news_url: str,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
style: str = "pm",
|
||||||
current_user: dict = Depends(require_auth),
|
current_user: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Generiert einen LLM-Pressemitteilungs-Vorschlag.
|
"""Generiert einen LLM-Pressemitteilungs-Vorschlag.
|
||||||
|
|
||||||
Auth-only + rate-limited (5/min) wegen LLM-Kosten.
|
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
|
``force=True`` ueberschreibt den Idempotenz-Check und macht einen
|
||||||
neuen LLM-Call, auch wenn fuer (drucksache, news_url) schon ein
|
neuen LLM-Call, auch wenn fuer (drucksache, news_url, style) schon
|
||||||
Draft existiert.
|
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
|
from .presse_generator import generate_draft
|
||||||
try:
|
try:
|
||||||
return await generate_draft(
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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(
|
def _build_user_prompt(
|
||||||
drucksache: str,
|
drucksache: str,
|
||||||
bundesland: str,
|
bundesland: str,
|
||||||
@ -207,8 +245,9 @@ kritisch — die PM kann auch eine Ablehnung des Antrags begründen.
|
|||||||
|
|
||||||
def _find_existing_draft(
|
def _find_existing_draft(
|
||||||
drucksache: str, news_url: str, db_path: Path,
|
drucksache: str, news_url: str, db_path: Path,
|
||||||
|
style: str = "pm",
|
||||||
) -> Optional[dict]:
|
) -> 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-
|
Bei mehreren Treffern wird der NEUESTE zurueckgegeben. Idempotenz-
|
||||||
Schutz vor doppelter LLM-Generierung (#170 Followup).
|
Schutz vor doppelter LLM-Generierung (#170 Followup).
|
||||||
@ -219,11 +258,11 @@ def _find_existing_draft(
|
|||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
||||||
titel, body, model, created_at
|
titel, body, model, created_at, style
|
||||||
FROM presse_drafts
|
FROM presse_drafts
|
||||||
WHERE drucksache=? AND news_url=?
|
WHERE drucksache=? AND news_url=? AND style=?
|
||||||
ORDER BY id DESC LIMIT 1""",
|
ORDER BY id DESC LIMIT 1""",
|
||||||
(drucksache, news_url),
|
(drucksache, news_url, style),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@ -233,7 +272,7 @@ def _find_existing_draft(
|
|||||||
"id": row[0], "drucksache": row[1], "bundesland": row[2],
|
"id": row[0], "drucksache": row[1], "bundesland": row[2],
|
||||||
"news_url": row[3], "news_titel": row[4],
|
"news_url": row[3], "news_titel": row[4],
|
||||||
"titel": row[5], "body": row[6], "model": row[7],
|
"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,
|
db_path: Optional[Path] = None,
|
||||||
bewerter=None,
|
bewerter=None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
style: str = "pm",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Erzeugt einen Pressemitteilungs-Draft und persistiert ihn.
|
"""Erzeugt einen Pressemitteilungs-Draft und persistiert ihn.
|
||||||
|
|
||||||
@ -272,10 +312,12 @@ async def generate_draft(
|
|||||||
from .adapters.qwen_bewerter import LlmRequest
|
from .adapters.qwen_bewerter import LlmRequest
|
||||||
|
|
||||||
path = db_path or settings.db_path
|
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:
|
if not force:
|
||||||
existing = _find_existing_draft(drucksache, news_url, path)
|
existing = _find_existing_draft(drucksache, news_url, path, style=style)
|
||||||
if existing:
|
if existing:
|
||||||
existing["_was_existing"] = True
|
existing["_was_existing"] = True
|
||||||
return existing
|
return existing
|
||||||
@ -322,8 +364,9 @@ async def generate_draft(
|
|||||||
# (~6 Cent statt 2 Cent), ~2× langsamer (~30 s statt 15 s).
|
# (~6 Cent statt 2 Cent), ~2× langsamer (~30 s statt 15 s).
|
||||||
model = settings.llm_model_premium
|
model = settings.llm_model_premium
|
||||||
|
|
||||||
|
system_prompt_active = SYSTEM_PROMPT_THREAD if style == "thread" else SYSTEM_PROMPT
|
||||||
req = LlmRequest(
|
req = LlmRequest(
|
||||||
system_prompt=SYSTEM_PROMPT,
|
system_prompt=system_prompt_active,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model=model,
|
model=model,
|
||||||
base_temperature=0.3,
|
base_temperature=0.3,
|
||||||
@ -347,19 +390,19 @@ async def generate_draft(
|
|||||||
if not titel or not body:
|
if not titel or not body:
|
||||||
raise ValueError("LLM-Response unvollständig (titel oder body leer)")
|
raise ValueError("LLM-Response unvollständig (titel oder body leer)")
|
||||||
|
|
||||||
# Persist
|
# Persist (style additiv im Insert)
|
||||||
conn = sqlite3.connect(str(path))
|
conn = sqlite3.connect(str(path))
|
||||||
try:
|
try:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO presse_drafts
|
"""INSERT INTO presse_drafts
|
||||||
(drucksache, bundesland, news_url, news_titel, titel, body, model)
|
(drucksache, bundesland, news_url, news_titel, titel, body, model, style)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(drucksache, antrag[0], news_url, news[0], titel, body, model),
|
(drucksache, antrag[0], news_url, news[0], titel, body, model, style),
|
||||||
)
|
)
|
||||||
draft_id = cur.lastrowid
|
draft_id = cur.lastrowid
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
||||||
titel, body, model, created_at
|
titel, body, model, created_at, style
|
||||||
FROM presse_drafts WHERE id=?""",
|
FROM presse_drafts WHERE id=?""",
|
||||||
(draft_id,),
|
(draft_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@ -372,7 +415,7 @@ async def generate_draft(
|
|||||||
"id": row[0], "drucksache": row[1], "bundesland": row[2],
|
"id": row[0], "drucksache": row[1], "bundesland": row[2],
|
||||||
"news_url": row[3], "news_titel": row[4],
|
"news_url": row[3], "news_titel": row[4],
|
||||||
"titel": row[5], "body": row[6], "model": row[7],
|
"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:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
||||||
titel, body, model, created_at
|
titel, body, model, created_at, style
|
||||||
FROM presse_drafts
|
FROM presse_drafts
|
||||||
ORDER BY id DESC LIMIT ?""",
|
ORDER BY id DESC LIMIT ?""",
|
||||||
(limit,),
|
(limit,),
|
||||||
@ -402,7 +445,7 @@ def list_drafts(
|
|||||||
"id": r[0], "drucksache": r[1], "bundesland": r[2],
|
"id": r[0], "drucksache": r[1], "bundesland": r[2],
|
||||||
"news_url": r[3], "news_titel": r[4],
|
"news_url": r[3], "news_titel": r[4],
|
||||||
"titel": r[5], "body": r[6], "model": r[7],
|
"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
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -423,7 +466,7 @@ def list_drafts_for(
|
|||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
||||||
titel, body, model, created_at
|
titel, body, model, created_at, style
|
||||||
FROM presse_drafts
|
FROM presse_drafts
|
||||||
WHERE drucksache=? AND news_url=?
|
WHERE drucksache=? AND news_url=?
|
||||||
ORDER BY id DESC""",
|
ORDER BY id DESC""",
|
||||||
@ -436,7 +479,7 @@ def list_drafts_for(
|
|||||||
"id": r[0], "drucksache": r[1], "bundesland": r[2],
|
"id": r[0], "drucksache": r[1], "bundesland": r[2],
|
||||||
"news_url": r[3], "news_titel": r[4],
|
"news_url": r[3], "news_titel": r[4],
|
||||||
"titel": r[5], "body": r[6], "model": r[7],
|
"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
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -456,7 +499,7 @@ def get_draft(
|
|||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
"""SELECT id, drucksache, bundesland, news_url, news_titel,
|
||||||
titel, body, model, created_at
|
titel, body, model, created_at, style
|
||||||
FROM presse_drafts WHERE id=?""",
|
FROM presse_drafts WHERE id=?""",
|
||||||
(draft_id,),
|
(draft_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@ -468,5 +511,5 @@ def get_draft(
|
|||||||
"id": row[0], "drucksache": row[1], "bundesland": row[2],
|
"id": row[0], "drucksache": row[1], "bundesland": row[2],
|
||||||
"news_url": row[3], "news_titel": row[4],
|
"news_url": row[3], "news_titel": row[4],
|
||||||
"titel": row[5], "body": row[6], "model": row[7],
|
"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) {
|
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;
|
if (!confirm(`Wirklich neu generieren?\n\nDas macht einen NEUEN LLM-Call (~6 Cent, ~30 s) und legt einen weiteren Draft an.`)) return;
|
||||||
try {
|
try {
|
||||||
@ -642,14 +661,18 @@ async function showDraftFromData(d) {
|
|||||||
}
|
}
|
||||||
} catch (e) { /* silent */ }
|
} 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
|
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;">
|
? `<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="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}
|
${versionsHtml}
|
||||||
</div>`
|
</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;">
|
: `<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>`;
|
</div>`;
|
||||||
|
|
||||||
// Action-Buttons: Mail + Clipboard
|
// Action-Buttons: Mail + Clipboard
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user