feat: LLM-Intake-Triage — 3-Stufen-KI-Prüfung aller Chatbot-Einreichungen

- Neue DB-Spalten: triage_tier, triage_reasons, triage_at, public, display_text
- triage_submission() via DeepSeek V4 Flash: klassifiziert in 🟢/🟡/🔴
- display_text: gute Texte 1:1, kurze/noisy Texte werden aufbereitet
- Bild-Sicherheitsprüfung via Gemini Vision (Nazi-Symbole, Gewalt etc.)
- Async nach WhatsApp/Telegram-Webhook — nicht blockierend
- Admin-Dashboard: Triage-Badges + Filter (🟢/🟡/🔴)
- Öffentliches Frontend: Nur public=1 + triage_tier=1 wird angezeigt
- Auto-Antworten: 🟢=Danke, 🟡=Prüfung, 🔴=keine Antwort
This commit is contained in:
Hermes Agent
2026-06-19 12:56:34 +02:00
parent 55a2de04ca
commit bc8e599018
+340 -5
View File
@@ -215,6 +215,304 @@ Antworte NUR mit einem JSON-Objekt:
return "published", None # Fail open — don't block on API errors
# ── LLM Intake Triage ────────────────────────────────────────────────
TRIAGE_PROMPT = """Du bist der automatisierte Eingangs-Prüfer für brettspiel-news.de, ein deutsches Nachrichtenportal für Brettspiele, Tabletop und Trading Cards.
Ein Nutzer hat folgende Nachricht eingereicht:
---
{content}
---
Bewerte diese Einreichung und antworte NUR mit diesem JSON (kein Markdown, kein ```json):
{{
"tier": <1, 2 oder 3>,
"reasons": ["Grund 1", "Grund 2"],
"is_boardgame_related": <true|false>,
"language": "<german|english|mixed|other>",
"has_spam": <true|false>,
"display_text": "<Der Text, der auf der öffentlichen Pinnwand angezeigt werden soll>",
"summary_de": "<Ein-Satz-Zusammenfassung auf Deutsch, max 20 Wörter>"
}}
WICHTIG zu display_text:
- Wenn der Original-Text GUT und AUSSAGEKRÄFTIG ist (100 Zeichen, klarer Inhalt, deutsch, Brettspiel-Bezug):
kopiere ihn 1:1 als display_text.
- Wenn der Text kurz, unstrukturiert oder noisy ist:
formuliere einen sauberen, lesbaren Display-Text (2-4 Sätze), der die Kernaussage wiedergibt.
- Entferne dabei: Füllwörter, Emoji-Overload, unhöfliche Formulierungen, irrelevante Anhängsel.
- Der display_text MUSS auf Deutsch sein (englische Eigennamen/Fachbegriffe sind OK).
- Maximal 500 Zeichen.
TIER 1 (🟢 publish_ready) ALLE Bedingungen erfüllt:
- Brettspiel/Tabletop-Bezug ist EINDEUTIG (Spielname, Verlag, Messe, Mechanik, Event, Community)
- Text ist 80% deutsch (Eigennamen und Fachbegriffe ausgenommen)
- Mindestens 150 Zeichen nutzbarer, substanzieller Text
- Nachricht ist klar und verständlich (vollständige Sätze oder klare Stichpunkte)
- Keine Beleidigungen, Spam, Werbung, Hassrede
- News-würdig oder community-relevant: Neuerscheinung, Event, Angebot, Frage, Erfahrungsbericht, Tipp
TIER 2 (🟡 needs_review) MINDESTENS ein Mangel, aber KEIN kritisches Problem:
- MÖGLICHER Brettspiel-Bezug, aber nicht eindeutig ("heute Abend zocken", "mein neues Spiel")
- Text zwischen 30 und 149 Zeichen (kurz, aber nicht leer)
- 50-80% deutsch mit englischen Anteilen
- Unklare Struktur, Telegrammstil, einzelne Wörter
- #out-Meldung ohne Quelle
- Reine Frage ("Welches Spiel ist das?") community-relevant, aber nicht publizierbar
- Bild/Video ohne oder mit nichtssagendem Text ("schaut mal")
- Sanfte Eigenwerbung oder Crowdfunding-Link (könnte legitim sein)
TIER 3 (🔴 blocked) MINDESTENS ein kritisches Problem:
- Beleidigungen, Hassrede, Diskriminierung, Volksverhetzung
- Harter Spam ("Kauft!", Casino, MLM, Scam, "5000€/Tag")
- KEIN Brettspiel-Bezug (Fußball, Politik, Wetter, völlig fremdes Thema)
- < 50% deutsch UND kein Brettspiel-Bezug erkennbar
- < 30 Zeichen Nutztext (nur "hallo", "test", "👍", Emojis)
- Illegale Inhalte, Gewalt, Pornografie
- Private Daten Dritter (Telefonnummern, Adressen)
- Offensichtlicher Urheberrechtsverstoß (Volltext-Kopie)
- Verschwörungstheorien, Desinformation
WICHTIG:
- Im Zweifel TIER 2 (lieber redaktionell prüfen als falsch publizieren)
- Bei TIER 3: Nenne den KRITISCHEN Grund an erster Stelle in reasons
- is_boardgame_related: true NUR wenn Brettspiel-Bezug eindeutig ist
- summary_de: Max 20 Wörter, deutsch
- display_text: Immer liefern, auch bei TIER 3 (dann leerer String "")
"""
def _check_image_safety(media_path: str) -> list[str]:
"""Prüft ein Bild via Gemini Flash Vision auf problematische Inhalte.
Returns: Liste von Problemen (leer = ok)"""
if not media_path:
return []
import yaml as _yaml
import openai as _openai
for path in media_path.split("||"):
path = path.strip()
if not path or not os.path.exists(path):
continue
try:
import base64 as _b64
with open(path, "rb") as f:
img_b64 = _b64.b64encode(f.read()).decode()
config_path = os.path.expanduser("~/.hermes/config.yaml")
with open(config_path) as f:
cfg = _yaml.safe_load(f)
api_key = cfg["providers"]["datenhimmel"]["api_key"]
base_url = cfg["providers"]["datenhimmel"]["base_url"]
client = _openai.OpenAI(base_url=base_url, api_key=api_key)
resp = client.chat.completions.create(
model="gemini-3-flash-preview:cloud",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": (
"Prüfe dieses Bild auf PROBLEMATISCHE Inhalte. "
"Antworte NUR mit 'OK' oder einer Liste von Problemen (deutsch, kommagetrennt):\n"
"- Gewalt, Blut, Waffen, Verletzungen\n"
"- Pornografie, Nacktheit, sexuelle Darstellungen\n"
"- Nazi-Symbole, Hakenkreuze, SS-Runen, Reichsadler, verfassungsfeindliche Kennzeichen\n"
"- Illegale Drogen, Drogenkonsum\n"
"- Persönliche Dokumente (Ausweise, Kreditkarten, Pässe)\n"
"- Kindeswohlgefährdende Inhalte\n"
"- Extreme Obszönitäten\n"
"Wenn das Bild ein normales Brettspiel-Foto, Cover, Messe-Schnappschuss, "
"Spieletisch oder harmloses Alltagsbild ist, antworte 'OK'.\n"
"Bei Nazi-Symbolen: Nenne EXPLIZIT, welches Symbol du siehst."
)},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
]
}],
temperature=0,
max_tokens=100,
)
result = resp.choices[0].message.content.strip()
if result.upper() != "OK":
return [f"🚨 Bild-Problem: {result}"]
except Exception as e:
print(f"[image_safety] Gemini check failed for {path}: {e}", file=sys.stderr)
return ["⚠️ Bild-Sicherheitsprüfung fehlgeschlagen — manuelle Prüfung nötig"]
return []
def triage_submission(db, submission_id: int) -> dict:
"""Führt die 3-Stufen-Triage für eine Submission durch.
Returns: {"tier": int, "reasons": list, "action": str,
"display_text": str, "auto_response": str|None}
"""
import yaml as _yaml
import openai as _openai
row = db.execute(
"SELECT id, content, media_path, type, channel, sender_id FROM submissions WHERE id=?",
(submission_id,)
).fetchone()
if not row or not row["content"]:
return {"tier": 3, "reasons": ["Kein Inhalt"], "action": "block",
"display_text": "", "auto_response": None}
content = row["content"]
media_path = row["media_path"]
# ── Bild-Sicherheitsprüfung ──────────────────────────────────────
if media_path and row["type"] in ("image", "video"):
concerns = _check_image_safety(media_path)
if concerns:
db.execute(
"UPDATE submissions SET triage_tier=3, triage_reasons=?, triage_at=CURRENT_TIMESTAMP, public=0, status='blocked' WHERE id=?",
(json.dumps(concerns, ensure_ascii=False), submission_id),
)
db.commit()
return {"tier": 3, "reasons": concerns, "action": "block",
"display_text": "", "auto_response": None}
# ── API-Client ──────────────────────────────────────────────────
config_path = os.path.expanduser("~/.hermes/config.yaml")
try:
with open(config_path) as f:
cfg = _yaml.safe_load(f)
api_key = cfg["providers"]["datenhimmel"]["api_key"]
base_url = cfg["providers"]["datenhimmel"]["base_url"]
except Exception:
print("[triage] Cannot read API config", file=sys.stderr)
return {"tier": 2, "reasons": ["KI-Prüfung nicht verfügbar"],
"action": "review", "display_text": content[:500], "auto_response": None}
client = _openai.OpenAI(base_url=base_url, api_key=api_key)
# ── LLM-Aufruf ──────────────────────────────────────────────────
prompt = TRIAGE_PROMPT.format(content=content[:3000])
try:
resp = client.chat.completions.create(
model="deepseek-v4-flash:cloud",
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=800,
)
raw = resp.choices[0].message.content.strip()
except Exception as e:
print(f"[triage] LLM error: {e}", file=sys.stderr)
return {"tier": 2, "reasons": [f"KI-Fehler: {str(e)[:100]}"],
"action": "review", "display_text": content[:500], "auto_response": None}
# ── JSON parsen ─────────────────────────────────────────────────
if "```" in raw:
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
try:
data = json.loads(raw.strip())
except json.JSONDecodeError:
print(f"[triage] JSON parse error, raw: {raw[:200]}", file=sys.stderr)
return {"tier": 2, "reasons": ["KI-Antwort nicht parsebar"],
"action": "review", "display_text": content[:500], "auto_response": None}
# ── Validieren ──────────────────────────────────────────────────
tier = data.get("tier", 2)
if tier not in (1, 2, 3):
tier = 2
reasons = data.get("reasons", [])
display_text = data.get("display_text", "") or ""
summary_de = data.get("summary_de", "")
# display_text einkürzen falls zu lang
if len(display_text) > 1000:
display_text = display_text[:997] + "..."
# ── DB-Update ───────────────────────────────────────────────────
db.execute(
"""UPDATE submissions
SET triage_tier=?, triage_reasons=?, triage_at=CURRENT_TIMESTAMP,
public=?, status=?, display_text=?
WHERE id=?""",
(
tier,
json.dumps(reasons, ensure_ascii=False),
1 if tier == 1 else 0,
"published" if tier == 1 else ("blocked" if tier == 3 else "pending"),
display_text,
submission_id,
),
)
db.commit()
# ── Auto-Antwort ────────────────────────────────────────────────
auto_responses = {
1: "✅ Danke für deinen Beitrag! Er ist jetzt auf unserer Pinnwand sichtbar.",
2: "📝 Danke für deine Nachricht! Unser Redaktionsteam prüft sie und schaltet sie ggf. frei.",
3: None,
}
print(f"[triage] #{submission_id} tier={tier} reasons={reasons} display_len={len(display_text)}", file=sys.stderr)
return {
"tier": tier,
"reasons": reasons,
"summary_de": summary_de,
"display_text": display_text,
"action": {1: "publish", 2: "review", 3: "block"}[tier],
"auto_response": auto_responses[tier],
}
def _run_triage_async(submission_id: int):
"""Führt die Triage in einem separaten Thread aus (nicht blockierend für Webhooks)."""
import time as _time
_time.sleep(0.3)
db = get_db()
try:
result = triage_submission(db, submission_id)
# Auto-Antwort senden
if result.get("auto_response"):
row = db.execute(
"SELECT channel, sender_id FROM submissions WHERE id=?",
(submission_id,)
).fetchone()
if row:
if row["channel"] == "whatsapp" and META_TOKEN:
try:
send_whatsapp_message(row["sender_id"], result["auto_response"])
except Exception as e:
print(f"[triage] WhatsApp reply failed: {e}", file=sys.stderr)
elif row["channel"] == "telegram" and TELEGRAM_TOKEN:
try:
requests.post(
f"{TELEGRAM_API}/sendMessage",
json={"chat_id": int(row["sender_id"]),
"text": result["auto_response"]},
timeout=10,
)
except Exception as e:
print(f"[triage] Telegram reply failed: {e}", file=sys.stderr)
db.execute(
"UPDATE submissions SET auto_response_sent=1 WHERE id=?",
(submission_id,)
)
db.commit()
except Exception as e:
print(f"[triage] Async triage failed for {submission_id}: {e}", file=sys.stderr)
@app.teardown_appcontext
def close_db(_exception=None):
db = g.pop("db", None)
@@ -868,6 +1166,15 @@ def whatsapp_webhook():
daemon=True,
).start()
# LLM-Triage: 3-Stufen-Klassifikation (nur für neue Threads)
if not thread_found:
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
threading.Thread(
target=_run_triage_async,
args=(sub_id,),
daemon=True,
).start()
db.commit()
except Exception as e:
print(f"[webhook error] {e}", file=sys.stderr)
@@ -886,6 +1193,7 @@ def admin_dashboard():
category_filter = request.args.get("category", "")
search = request.args.get("q", "")
halle_filter = request.args.get("halle", "")
triage_filter = request.args.get("triage", "")
query = "SELECT * FROM submissions WHERE deleted_at IS NULL"
params: list = []
@@ -902,6 +1210,9 @@ def admin_dashboard():
if halle_filter:
query += " AND halle = ?"
params.append(halle_filter)
if triage_filter:
query += " AND triage_tier = ?"
params.append(int(triage_filter))
if search:
query += " AND (content LIKE ? OR sender_id LIKE ?)"
params.extend([f"%{search}%", f"%{search}%"])
@@ -965,11 +1276,22 @@ def admin_dashboard():
"new": "Neu", "processing": "In Bearbeitung", "done": "Erledigt",
"flagged": "Markiert", "gdpr_deleted": "DSGVO-Gelöscht", "published": "Veröffentlicht",
}
triage_emoji = {1: "🟢", 2: "🟡", 3: "🔴"}
table_rows = ""
for r in rows:
content_snippet = (r["content"] or "")[:120]
media_icon = {"image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(r["type"], "")
st_label = status_names.get(r["status"], r["status"])
# Triage badge
triage_badge = ""
if r["triage_tier"]:
emoji = triage_emoji.get(r["triage_tier"], "")
reasons = json.loads(r["triage_reasons"] or "[]")
tooltip = " | ".join(reasons) if reasons else "Keine Details"
triage_badge = f'<span class="triage-badge" title="{tooltip}">{emoji}</span>'
table_rows += f"""
<tr class="row-{r['status']}" style="cursor:pointer;" onclick="window.location='/submission/{r['id']}'">
<td>{r['id']}</td>
@@ -978,7 +1300,7 @@ def admin_dashboard():
<td>{media_icon} {r['type']}</td>
<td style="color:#4ecdc4;font-weight:600;white-space:nowrap;">{r['halle'] or '-'}</td>
<td style="color:#ff6b6b;font-weight:600;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{r['spiel_titel'] or '-'}</td>
<td style="max-width:240px;word-break:break-word;">{content_snippet}</td>
<td style="max-width:240px;word-break:break-word;">{triage_badge}{content_snippet}</td>
<td><span class="badge badge-{r['status']}">{st_label}</span></td>
<td>{r['category'] or '-'}</td>
<td style="font-size:0.75rem;white-space:nowrap;">{r['created_at']}</td>
@@ -1076,6 +1398,10 @@ def admin_dashboard():
.badge-gdpr_deleted {{ background:rgba(255,0,0,0.18); color:#ff453a; }}
.badge-published {{ background:rgba(32,34,138,0.2); color:#5e5ce6; }}
/* Triage badges */
.triage-badge {{ display:inline-block; font-size:1rem; cursor:help; margin-right:0.3rem; vertical-align:middle; transition:transform 0.15s; }}
.triage-badge:hover {{ transform:scale(1.2); }}
/* Theme toggle */
.theme-toggle {{ position:fixed; bottom:1.25rem; right:1.25rem; z-index:999; width:48px; height:48px; border-radius:50%; cursor:pointer; font-size:1.2rem; border:1px solid var(--border); background:var(--surface); color:var(--text); box-shadow:var(--shadow); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); display:flex; align-items:center; justify-content:center; }}
body.light .theme-toggle {{ background:rgba(255,255,255,0.85); }}
@@ -1097,6 +1423,9 @@ function toggleTheme(){{document.body.classList.toggle('light');localStorage.set
<a href="/admin?status=new" class="admin-btn{' active' if status_filter=='new' else ''}">🆕 Neu</a>
<a href="/admin?status=published" class="admin-btn{' active' if status_filter=='published' else ''}"> Publiziert</a>
<a href="/admin?status=flagged" class="admin-btn{' active' if status_filter=='flagged' else ''}">🚩 Markiert</a>
<a href="/admin?triage=1" class="admin-btn{' active' if triage_filter=='1' else ''}" style="background:#22c55e20;color:#22c55e;">🟢 Ready</a>
<a href="/admin?triage=2" class="admin-btn{' active' if triage_filter=='2' else ''}" style="background:#eab30820;color:#eab308;">🟡 Prüfen</a>
<a href="/admin?triage=3" class="admin-btn{' active' if triage_filter=='3' else ''}" style="background:#ef444420;color:#ef4444;">🔴 Blockiert</a>
<a href="/test" class="admin-btn secondary">🧪 Test</a>
<a href="/" class="admin-btn secondary">🌐 Frontend</a>
</div>
@@ -1847,7 +2176,7 @@ def public_frontend():
sort = request.args.get("sort", "neu").strip()
query = ("SELECT s.*, (SELECT COUNT(*) FROM comments c WHERE c.submission_id = s.id AND c.status='published') AS comment_count "
"FROM submissions s WHERE s.status='published' AND s.deleted_at IS NULL")
"FROM submissions s WHERE s.status='published' AND s.deleted_at IS NULL AND s.public=1 AND s.triage_tier=1")
params = []
if halle_filter:
query += " AND s.halle LIKE ?"
@@ -1866,7 +2195,7 @@ def public_frontend():
# Get available filter values — group by hall number (H1-H7)
hallen = []
hall_rows = db.execute(
"SELECT DISTINCT halle FROM submissions WHERE halle IS NOT NULL AND status='published' ORDER BY halle"
"SELECT DISTINCT halle FROM submissions WHERE halle IS NOT NULL AND status='published' AND public=1 ORDER BY halle"
).fetchall()
seen = set()
for r in hall_rows:
@@ -1875,7 +2204,7 @@ def public_frontend():
seen.add(hnum)
hallen.append(hnum)
categories = [r[0] for r in db.execute(
"SELECT DISTINCT category FROM submissions WHERE category IS NOT NULL AND status='published' ORDER BY category"
"SELECT DISTINCT category FROM submissions WHERE category IS NOT NULL AND status='published' AND public=1 ORDER BY category"
).fetchall()]
empty_state = """<!DOCTYPE html>
@@ -2015,7 +2344,7 @@ def public_frontend():
if not name or name.lower() in ("unknown", "anonym"):
name = "Anonym"
summary_text = (r["summary"] or r["content"] or "")
summary_text = (r["display_text"] or r["summary"] or r["content"] or "")
# Truncate for card preview
preview = summary_text[:200] + ("..." if len(summary_text) > 200 else "")
date_str = (r["published_at"] or r["created_at"] or "")
@@ -4393,6 +4722,12 @@ def telegram_webhook():
if not halle:
threading.Thread(target=auto_process_bg, args=(DB_PATH, sub_id), daemon=True).start()
# LLM-Triage: 3-Stufen-Klassifikation (nur für neue Threads)
if not thread_found:
import threading
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
threading.Thread(target=_run_triage_async, args=(sub_id,), daemon=True).start()
db.commit()
except Exception as e:
print("[telegram webhook error] {}".format(e), file=sys.stderr)