From bc8e5990182aedda997e6e492731161a372770f0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 12:56:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20LLM-Intake-Triage=20=E2=80=94=203-Stufe?= =?UTF-8?q?n-KI-Pr=C3=BCfung=20aller=20Chatbot-Einreichungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 345 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 340 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index c158ca3..e842da8 100644 --- a/app.py +++ b/app.py @@ -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": , + "language": "", + "has_spam": , + "display_text": "", + "summary_de": "" +}} + +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'{emoji}' + table_rows += f""" {r['id']} @@ -978,7 +1300,7 @@ def admin_dashboard(): {media_icon} {r['type']} {r['halle'] or '-'} {r['spiel_titel'] or '-'} - {content_snippet} + {triage_badge}{content_snippet} {st_label} {r['category'] or '-'} {r['created_at']} @@ -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 🆕 Neu ✅ Publiziert 🚩 Markiert + 🟢 Ready + 🟡 Prüfen + 🔴 Blockiert 🧪 Test 🌐 Frontend @@ -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 = """ @@ -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)