diff --git a/app.py b/app.py index 7c0e3c3..19eec7c 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -BSN Chatbot — Multi-Channel Intake System +BSN Assistent — Multi-Channel Intake System ========================================== WhatsApp webhook receiver + admin dashboard + knowledge query API. @@ -147,6 +147,67 @@ def get_db() -> sqlite3.Connection: 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 def close_db(_exception=None): db = g.pop("db", None) @@ -347,7 +408,7 @@ def auto_process_bg(db_path: str, submission_id: int): return 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" '{"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", ' @@ -1025,6 +1086,110 @@ function toggleTheme(){{document.body.classList.toggle('light');localStorage.set """.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 = '
' + 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'{label} ({count})' + tabs_html += '
' + + 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 = '✅ Frei' if c["status"] == "published" else '🚫 Gesperrt' + reason_html = f'
Grund: {c["moderation_reason"]}' if (c["moderation_reason"] if c["moderation_reason"] else None) else "" + action = f'Freischalten' if c["status"] == "blocked" else f'Sperren' + comment_rows += f""" + {c['id']} + {sub_label} + {c_author} + {c_content}{reason_html} + {status_badge} + {c_date} + {action} +""" + + return f""" + + +Kommentar-Moderation — BSN Admin + + +
+ BSN +

💬 Kommentar-Moderation

← Zurück zum Admin
+
+{tabs_html} + + + +{comment_rows} +
IDBeitragAutorInhaltStatusDatumAktion
+ +""" + + +@app.route("/admin/comment//", 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") def test_page(): """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 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. " "Schreibe im Präsens, journalistisch, ohne Floskeln. " "Kein 'Der Nutzer schreibt...' oder 'In dieser Nachricht...'. " @@ -1651,7 +1816,7 @@ def public_frontend(): cat_filter = request.args.get("cat", "").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") params = [] if halle_filter: @@ -2179,7 +2344,7 @@ def public_frontend(): - @@ -2197,7 +2362,7 @@ def public_frontend():
-
Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!
+
Hey! 👋 Ich bin der BSN Assistent. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!
@@ -2486,7 +2651,7 @@ def public_frontend(): const greetBtn = document.getElementById('greetingSpeakBtn'); if (greetBtn) { 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); } catch(e) { 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; input.focus(); @@ -2781,7 +2946,7 @@ def legal_datenschutz():

5. Veröffentlichung von Beiträgen

Bei Veröffentlichung werden sichtbar: Ihr Name (oder "Anonyme Quelle"), Beitragsinhalt, Medien, KI-Zusammenfassung, Halle/Kategorie. Ohne Namensangabe erfolgt die Veröffentlichung als "Anonyme Quelle".

-

6. Chat-Assistant (KI-Chat)

+

6. BSN Assistent (KI-Chat)

Kein Zugriff auf nicht-veröffentlichte Daten. Keine Speicherung von Chat-Verläufen. Keine Verarbeitung personenbezogener Daten der Fragesteller.

7. Cookies und Tracking

@@ -2834,7 +2999,7 @@ def legal_agb():

8. Haftung

Unbeschränkt bei Vorsatz/grober Fahrlässigkeit. Bei leichter Fahrlässigkeit nur bei Kardinalpflichtverletzung, begrenzt auf vorhersehbaren Schaden. Keine Haftung für Nutzerinhalte. §§ 8–10 DDG: Keine Überwachungspflicht, aber Entfernung bei Kenntnis.

-

9. KI-Chat-Assistant

+

9. KI-BSN Assistent

Beantwortet Fragen auf Basis veröffentlichter Beiträge (DeepSeek V4 Flash). Antworten unverbindlich, können Fehler enthalten. Keine Speicherung von Chat-Verläufen.

10. Änderungen der AGB

@@ -2932,7 +3097,7 @@ def api_chat(): cat = r['category'] or '' name = (r['sender_name'] or 'Anonym').strip() context_parts.append( - f"[{i}] {text}" + f"[Beitrag #{r['id']}] {text}" + (f" | 📍{halle}" if halle else '') + (f" | 🎲{spiel}" if spiel else '') + (f" | #{cat}" if cat else '') @@ -2942,7 +3107,7 @@ def api_chat(): # System prompt 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" "WICHTIGE REGELN:\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" "- Halte Antworten kurz und präzise (2-4 Sätze).\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]}" ) @@ -2966,7 +3134,7 @@ def api_chat(): except Exception: pass 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 messages = [{"role": "system", "content": system}] @@ -2985,10 +3153,10 @@ def api_chat(): timeout=15, ) 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() 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}) @@ -3234,7 +3402,7 @@ def public_detail(sub_id: int): # Comments 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,), ).fetchall() @@ -3580,6 +3748,19 @@ def api_comment(sub_id: int): db.commit() 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 base = request.host_url.rstrip("/") link = f"{base}/beitrag/{sub_id}"