LLM-Moderation mit DeepSeek V4 Flash + Admin-Kommentarverwaltung + BSN Assistent Umbenennung

- moderate_comment(): LLM-Filter prüft jeden neuen Kommentar auf deutsches Recht & Toxizität
- /api/comment: Moderation vor Benachrichtigung, geblockte Kommentare stillschweigend verworfen
- /admin/comments: Neue Admin-Ansicht für alle Kommentare mit Sperren/Freischalten
- /admin/comment/<id>/<action>: API zum Blocken/Entsperren
- comments.status + moderation_reason Felder in DB
- 'Chat-Assistent' → 'BSN Assistent' überall umbenannt
- System-Prompt: Beitrags-Links im Chat mit echten /beitrag/ID Verweisen
This commit is contained in:
Hermes Agent
2026-06-18 01:10:02 +02:00
parent 90bce41178
commit 77d4ec426c
+198 -17
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
BSN Chatbot Multi-Channel Intake System BSN Assistent Multi-Channel Intake System
========================================== ==========================================
WhatsApp webhook receiver + admin dashboard + knowledge query API. WhatsApp webhook receiver + admin dashboard + knowledge query API.
@@ -147,6 +147,67 @@ def get_db() -> sqlite3.Connection:
return g.db return g.db
def moderate_comment(content: str) -> tuple[str, str | None]:
"""Run LLM moderation on a comment. Returns (status, reason)."""
import yaml as _yaml
import openai
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("[moderate] Cannot read API config", file=sys.stderr)
return "published", None
client = openai.OpenAI(base_url=base_url, api_key=api_key)
prompt = f"""Prüfe folgenden Kommentar auf einer Brettspiel-Community-Seite auf Verstöße gegen deutsches Recht und unsere Community-Regeln.
KOMMENTAR:
\"{content}\"
PRÜFE AUF:
1. Beleidigungen, persönliche Angriffe, Hassrede
2. Volksverhetzung, extremistische Inhalte
3. Gewaltandrohungen, Bedrohungen
4. Diskriminierung (Rasse, Religion, Geschlecht, sexuelle Orientierung, Behinderung)
5. Schwere Obszönitäten oder pornografische Inhalte
6. Illegale Inhalte nach deutschem Strafrecht
7. Spam oder offensichtlicher Vandalismus
Normale Kritik an Spielen, kontroverse Meinungen zu Brettspielen oder lebhafte Diskussionen sind ERLAUBT.
Nur klare Rechtsverstöße oder extreme Toxizität sollen geblockt werden.
Antworte NUR mit einem JSON-Objekt:
{{"status": "published", "reason": null}} wenn der Kommentar in Ordnung ist
{{"status": "blocked", "reason": "Kurze Begründung auf Deutsch"}} wenn der Kommentar gegen Regeln verstößt"""
try:
r = client.chat.completions.create(
model="deepseek-v4-flash:cloud",
messages=[{"role": "user", "content": prompt}],
max_tokens=150,
temperature=0.0,
)
raw = r.choices[0].message.content.strip()
# Extract JSON from potential markdown fences
if "```" in raw:
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
data = json.loads(raw.strip())
status = data.get("status", "published")
reason = data.get("reason") or None
if status not in ("published", "blocked"):
status = "published"
print(f"[moderate] status={status} reason={reason}", file=sys.stderr)
return status, reason
except Exception as e:
print(f"[moderate] error: {e}", file=sys.stderr)
return "published", None # Fail open — don't block on API errors
@app.teardown_appcontext @app.teardown_appcontext
def close_db(_exception=None): def close_db(_exception=None):
db = g.pop("db", None) db = g.pop("db", None)
@@ -347,7 +408,7 @@ def auto_process_bg(db_path: str, submission_id: int):
return return
prompt = ( prompt = (
"Du bist Redaktionsassistent für brettspiel-news.de. Analysiere folgende Community-Nachricht " "Du bist BSN Assistent für brettspiel-news.de. Analysiere folgende Community-Nachricht "
"und antworte NUR mit diesem JSON-Format, kein weiterer Text:\n" "und antworte NUR mit diesem JSON-Format, kein weiterer Text:\n"
'{"summary": "Kurze Zusammenfassung in 1-2 Sätzen, max 250 Zeichen, journalistisch, Präsens", ' '{"summary": "Kurze Zusammenfassung in 1-2 Sätzen, max 250 Zeichen, journalistisch, Präsens", '
'"halle": "Halle und Stand im Format z.B. H3 B123, oder null wenn nichts erkannt", ' '"halle": "Halle und Stand im Format z.B. H3 B123, oder null wenn nichts erkannt", '
@@ -1025,6 +1086,110 @@ function toggleTheme(){{document.body.classList.toggle('light');localStorage.set
</html>""".replace("{LOGO_B64}", LOGO_B64) </html>""".replace("{LOGO_B64}", LOGO_B64)
@app.route("/admin/comments")
def admin_comments():
"""Admin comment moderation — view all comments, filter by status."""
db = get_db()
status_filter = request.args.get("status", "")
query = """SELECT c.*, s.spiel_titel, s.sender_name AS sub_author
FROM comments c
LEFT JOIN submissions s ON c.submission_id = s.id
WHERE 1=1"""
params: list = []
if status_filter:
query += " AND c.status = ?"
params.append(status_filter)
query += " ORDER BY c.created_at DESC LIMIT 300"
rows = db.execute(query, params).fetchall()
status_counts = {
"all": db.execute("SELECT COUNT(*) FROM comments").fetchone()[0],
"published": db.execute("SELECT COUNT(*) FROM comments WHERE status='published'").fetchone()[0],
"blocked": db.execute("SELECT COUNT(*) FROM comments WHERE status='blocked'").fetchone()[0],
}
tabs_html = '<div class="comment-tabs" style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;">'
for key, label in [("", "Alle"), ("published", "Freigegeben"), ("blocked", "Gesperrt")]:
active = " style='background:#20228a;color:#fff;'" if status_filter == key else ""
count = status_counts.get(key or "all", 0)
tabs_html += f'<a href="/admin/comments?status={key}"{active} class="comment-tab-btn" style="padding:0.4rem 0.9rem;border:1.5px solid #ddd;border-radius:8px;text-decoration:none;font-size:0.8rem;color:#333;">{label} ({count})</a>'
tabs_html += '</div>'
comment_rows = ""
for c in rows:
c_content = (c["content"] or "")[:150]
c_author = (c["author_name"] or "Anonym")
c_date = c["created_at"] or ""
sub_label = c["spiel_titel"] or c["sub_author"] or f"#{c['submission_id']}"
status_badge = '<span style="color:#2e7d32;font-weight:600;">✅ Frei</span>' if c["status"] == "published" else '<span style="color:#c62828;font-weight:600;">🚫 Gesperrt</span>'
reason_html = f'<br><small style="color:#c62828;">Grund: {c["moderation_reason"]}</small>' if (c["moderation_reason"] if c["moderation_reason"] else None) else ""
action = f'<a href="#" onclick="toggleComment({c["id"]},\'unblock\')" style="color:#2e7d32;">Freischalten</a>' if c["status"] == "blocked" else f'<a href="#" onclick="toggleComment({c["id"]},\'block\')" style="color:#c62828;">Sperren</a>'
comment_rows += f"""<tr>
<td>{c['id']}</td>
<td><a href="/beitrag/{c['submission_id']}" target="_blank" style="color:#20228a;">{sub_label}</a></td>
<td>{c_author}</td>
<td style="max-width:300px;overflow-wrap:break-word;">{c_content}{reason_html}</td>
<td>{status_badge}</td>
<td style="font-size:0.7rem;color:#999;">{c_date}</td>
<td>{action}</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Kommentar-Moderation BSN Admin</title>
<style>
* {{margin:0;padding:0;box-sizing:border-box;}}
body {{font-family:'Inter',sans-serif;background:#f5f3ee;color:#1a1a2e;padding:1rem;}}
h1 {{color:#20228a;margin-bottom:0.5rem;font-size:1.3rem;}}
table {{width:100%;border-collapse:collapse;font-size:0.85rem;}}
th {{background:#20228a;color:#fff;padding:0.6rem 0.5rem;text-align:left;font-size:0.75rem;}}
td {{padding:0.5rem;border-bottom:1px solid #eee;vertical-align:top;}}
tr:hover {{background:#f8f6f0;}}
.back-link {{display:inline-block;color:#20228a;text-decoration:none;font-weight:600;margin-bottom:1rem;}}
.admin-header {{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;}}
.admin-header img {{height:34px;width:auto;}}
@media(max-width:700px){{table,thead,tbody,th,td,tr{{display:block;}}th{{display:none;}}tr{{margin-bottom:1rem;border:1px solid #eee;border-radius:8px;padding:0.5rem;}}td{{border:none;padding:0.2rem 0;}}}}
</style></head>
<body>
<div class="admin-header">
<img src="/static/logo.png" alt="BSN">
<div><h1>💬 Kommentar-Moderation</h1><a href="/admin" class="back-link"> Zurück zum Admin</a></div>
</div>
{tabs_html}
<script>
function toggleComment(id, action) {{
if (!confirm('Kommentar #' + id + ' ' + (action === 'block' ? 'sperren' : 'freischalten') + '?')) return;
fetch('/admin/comment/' + id + '/' + action, {{method:'POST'}})
.then(function(r){{return r.json();}})
.then(function(d){{if(d.ok)location.reload();else alert(d.error);}});
}}
</script>
<table>
<thead><tr><th>ID</th><th>Beitrag</th><th>Autor</th><th>Inhalt</th><th>Status</th><th>Datum</th><th>Aktion</th></tr></thead>
<tbody>{comment_rows}</tbody>
</table>
</body>
</html>"""
@app.route("/admin/comment/<int:comment_id>/<action>", methods=["POST"])
def admin_comment_action(comment_id: int, action: str):
"""Block or unblock a comment."""
if action not in ("block", "unblock"):
return jsonify({"error": "Ungültige Aktion"}), 400
db = get_db()
row = db.execute("SELECT id, status FROM comments WHERE id = ?", (comment_id,)).fetchone()
if not row:
return jsonify({"error": "Kommentar nicht gefunden"}), 404
new_status = "blocked" if action == "block" else "published"
db.execute("UPDATE comments SET status = ?, moderation_reason = ? WHERE id = ?",
(new_status, "Admin-Manuell" if action == "block" else None, comment_id))
db.commit()
return jsonify({"ok": True, "status": new_status})
@app.route("/test") @app.route("/test")
def test_page(): def test_page():
"""Page with a curl snippet for simulated testing.""" """Page with a curl snippet for simulated testing."""
@@ -1576,7 +1741,7 @@ def generate_llm_summary(sub_id: int):
return jsonify({"error": "API-Key nicht konfiguriert"}), 500 return jsonify({"error": "API-Key nicht konfiguriert"}), 500
prompt = ( prompt = (
"Du bist Redaktionsassistent für brettspiel-news.de. " "Du bist BSN Assistent für brettspiel-news.de. "
"Fasse folgende Community-Nachricht in 2-3 Sätzen zusammen. " "Fasse folgende Community-Nachricht in 2-3 Sätzen zusammen. "
"Schreibe im Präsens, journalistisch, ohne Floskeln. " "Schreibe im Präsens, journalistisch, ohne Floskeln. "
"Kein 'Der Nutzer schreibt...' oder 'In dieser Nachricht...'. " "Kein 'Der Nutzer schreibt...' oder 'In dieser Nachricht...'. "
@@ -1651,7 +1816,7 @@ def public_frontend():
cat_filter = request.args.get("cat", "").strip() cat_filter = request.args.get("cat", "").strip()
sort = request.args.get("sort", "neu").strip() sort = request.args.get("sort", "neu").strip()
query = ("SELECT s.*, (SELECT COUNT(*) FROM comments c WHERE c.submission_id = s.id) AS comment_count " 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")
params = [] params = []
if halle_filter: if halle_filter:
@@ -2179,7 +2344,7 @@ def public_frontend():
</script> </script>
<!-- Chat Bubble --> <!-- Chat Bubble -->
<button class="chat-bubble" id="chatBubble" title="BSN Community Assistant" aria-label="Chat öffnen"> <button class="chat-bubble" id="chatBubble" title="BSN Assistent" aria-label="Chat öffnen">
💬<span class="bubble-dot"></span> 💬<span class="bubble-dot"></span>
</button> </button>
@@ -2197,7 +2362,7 @@ def public_frontend():
</div> </div>
<div class="chat-body" id="chatBody"> <div class="chat-body" id="chatBody">
<div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;"> <div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;">
<div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div> <div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Assistent. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
<button id="greetingSpeakBtn" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</button> <button id="greetingSpeakBtn" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</button>
</div> </div>
</div> </div>
@@ -2486,7 +2651,7 @@ def public_frontend():
const greetBtn = document.getElementById('greetingSpeakBtn'); const greetBtn = document.getElementById('greetingSpeakBtn');
if (greetBtn) { if (greetBtn) {
greetBtn.addEventListener('click', () => { greetBtn.addEventListener('click', () => {
speak('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!'); speak('Hey! Ich bin der BSN Assistent. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!');
}); });
} }
@@ -2587,7 +2752,7 @@ def public_frontend():
if (history.length > 20) history = history.slice(-20); if (history.length > 20) history = history.slice(-20);
} catch(e) { } catch(e) {
removeTyping(); removeTyping();
addMsg('assistant', 'Der Chat-Assistent ist gerade nicht erreichbar. Versuch es später nochmal.'); addMsg('assistant', 'Der BSN Assistent ist gerade nicht erreichbar. Versuch es später nochmal.');
} }
sendBtn.disabled = false; sendBtn.disabled = false;
input.focus(); input.focus();
@@ -2781,7 +2946,7 @@ def legal_datenschutz():
<h2>5. Veröffentlichung von Beiträgen</h2> <h2>5. Veröffentlichung von Beiträgen</h2>
<p>Bei Veröffentlichung werden sichtbar: Ihr Name (oder "Anonyme Quelle"), Beitragsinhalt, Medien, KI-Zusammenfassung, Halle/Kategorie. <strong>Ohne Namensangabe erfolgt die Veröffentlichung als "Anonyme Quelle".</strong></p> <p>Bei Veröffentlichung werden sichtbar: Ihr Name (oder "Anonyme Quelle"), Beitragsinhalt, Medien, KI-Zusammenfassung, Halle/Kategorie. <strong>Ohne Namensangabe erfolgt die Veröffentlichung als "Anonyme Quelle".</strong></p>
<h2>6. Chat-Assistant (KI-Chat)</h2> <h2>6. BSN Assistent (KI-Chat)</h2>
<p>Kein Zugriff auf nicht-veröffentlichte Daten. Keine Speicherung von Chat-Verläufen. Keine Verarbeitung personenbezogener Daten der Fragesteller.</p> <p>Kein Zugriff auf nicht-veröffentlichte Daten. Keine Speicherung von Chat-Verläufen. Keine Verarbeitung personenbezogener Daten der Fragesteller.</p>
<h2>7. Cookies und Tracking</h2> <h2>7. Cookies und Tracking</h2>
@@ -2834,7 +2999,7 @@ def legal_agb():
<h2>8. Haftung</h2> <h2>8. Haftung</h2>
<p>Unbeschränkt bei Vorsatz/grober Fahrlässigkeit. Bei leichter Fahrlässigkeit nur bei Kardinalpflichtverletzung, begrenzt auf vorhersehbaren Schaden. Keine Haftung für Nutzerinhalte. §§ 810 DDG: Keine Überwachungspflicht, aber Entfernung bei Kenntnis.</p> <p>Unbeschränkt bei Vorsatz/grober Fahrlässigkeit. Bei leichter Fahrlässigkeit nur bei Kardinalpflichtverletzung, begrenzt auf vorhersehbaren Schaden. Keine Haftung für Nutzerinhalte. §§ 810 DDG: Keine Überwachungspflicht, aber Entfernung bei Kenntnis.</p>
<h2>9. KI-Chat-Assistant</h2> <h2>9. KI-BSN Assistent</h2>
<p>Beantwortet Fragen auf Basis veröffentlichter Beiträge (DeepSeek V4 Flash). Antworten unverbindlich, können Fehler enthalten. Keine Speicherung von Chat-Verläufen.</p> <p>Beantwortet Fragen auf Basis veröffentlichter Beiträge (DeepSeek V4 Flash). Antworten unverbindlich, können Fehler enthalten. Keine Speicherung von Chat-Verläufen.</p>
<h2>10. Änderungen der AGB</h2> <h2>10. Änderungen der AGB</h2>
@@ -2932,7 +3097,7 @@ def api_chat():
cat = r['category'] or '' cat = r['category'] or ''
name = (r['sender_name'] or 'Anonym').strip() name = (r['sender_name'] or 'Anonym').strip()
context_parts.append( context_parts.append(
f"[{i}] {text}" f"[Beitrag #{r['id']}] {text}"
+ (f" | 📍{halle}" if halle else '') + (f" | 📍{halle}" if halle else '')
+ (f" | 🎲{spiel}" if spiel else '') + (f" | 🎲{spiel}" if spiel else '')
+ (f" | #{cat}" if cat else '') + (f" | #{cat}" if cat else '')
@@ -2942,7 +3107,7 @@ def api_chat():
# System prompt # System prompt
system = ( system = (
"Du bist der BSN Community Assistant von brettspiel-news.de. " "Du bist der BSN Assistent von boardgame.network / brettspiel-news.de. "
"Du hilfst Besuchern, Informationen über die Brettspiel-Community auf der SPIEL Essen zu finden.\n\n" "Du hilfst Besuchern, Informationen über die Brettspiel-Community auf der SPIEL Essen zu finden.\n\n"
"WICHTIGE REGELN:\n" "WICHTIGE REGELN:\n"
"- Beantworte Fragen NUR mit Informationen aus dem Kontext unten.\n" "- Beantworte Fragen NUR mit Informationen aus dem Kontext unten.\n"
@@ -2951,7 +3116,10 @@ def api_chat():
"- Antworte immer auf Deutsch, freundlich und hilfsbereit.\n" "- Antworte immer auf Deutsch, freundlich und hilfsbereit.\n"
"- Halte Antworten kurz und präzise (2-4 Sätze).\n" "- Halte Antworten kurz und präzise (2-4 Sätze).\n"
"- Bei Standortfragen nenne immer die Hallen-Nummer.\n" "- Bei Standortfragen nenne immer die Hallen-Nummer.\n"
"- Der Kontext enthält nummerierte Beiträge [1], [2] usw. — du kannst darauf verweisen.\n\n" "- Wenn du einen Beitrag erwähnst, verlinke ihn IMMER mit der Beitrags-ID aus dem Kontext.\n"
" Format: [Kurzbeschreibung](/beitrag/ID)\n"
" Beispiel: 'Schau dir [diesen Erfahrungsbericht](/beitrag/53) an!'\n"
" Nutze die Nummer aus 'Beitrag #ID' im Kontext als Linkziel.\n\n"
f"KONTEXT (veröffentlichte Community-Beiträge):\n{context[:8000]}" f"KONTEXT (veröffentlichte Community-Beiträge):\n{context[:8000]}"
) )
@@ -2966,7 +3134,7 @@ def api_chat():
except Exception: except Exception:
pass pass
if not api_key: if not api_key:
return jsonify({'reply': 'Chat-Assistent ist aktuell nicht verfügbar (API-Key fehlt).'}) return jsonify({'reply': 'BSN Assistent ist aktuell nicht verfügbar (API-Key fehlt).'})
# Build messages with conversation history # Build messages with conversation history
messages = [{"role": "system", "content": system}] messages = [{"role": "system", "content": system}]
@@ -2985,10 +3153,10 @@ def api_chat():
timeout=15, timeout=15,
) )
if r.status_code != 200: if r.status_code != 200:
return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'}) return jsonify({'reply': 'Der BSN Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'})
reply = r.json()["choices"][0]["message"]["content"].strip() reply = r.json()["choices"][0]["message"]["content"].strip()
except Exception: except Exception:
return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'}) return jsonify({'reply': 'Der BSN Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'})
return jsonify({'reply': reply}) return jsonify({'reply': reply})
@@ -3234,7 +3402,7 @@ def public_detail(sub_id: int):
# Comments # Comments
comment_rows = db.execute( comment_rows = db.execute(
"SELECT id, author_name, content, created_at, parent_id FROM comments WHERE submission_id = ? ORDER BY created_at ASC", "SELECT id, author_name, content, created_at, parent_id FROM comments WHERE submission_id = ? AND status='published' ORDER BY created_at ASC",
(sub_id,), (sub_id,),
).fetchall() ).fetchall()
@@ -3580,6 +3748,19 @@ def api_comment(sub_id: int):
db.commit() db.commit()
comment_id = db.execute("SELECT last_insert_rowid()").fetchone()[0] comment_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
# LLM Moderation — run after insert, update status
mod_status, mod_reason = moderate_comment(content)
db.execute(
"UPDATE comments SET status=?, moderation_reason=? WHERE id=?",
(mod_status, mod_reason, comment_id),
)
db.commit()
# If blocked, skip notifications and return success silently
if mod_status == "blocked":
print(f"[moderate] Comment #{comment_id} BLOCKED: {mod_reason}", file=sys.stderr)
return jsonify({"ok": True, "comment_id": comment_id}), 201
# Build notification link # Build notification link
base = request.host_url.rstrip("/") base = request.host_url.rstrip("/")
link = f"{base}/beitrag/{sub_id}" link = f"{base}/beitrag/{sub_id}"