From 379d14518cc6e9c0d8d69b76be998bd042cdcbbc Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Fri, 22 May 2026 00:12:56 +0200 Subject: [PATCH 1/3] feat(multitenancy): Sprach-Whitelist + Translator-Override + Forum-Quellenklasse Vorbereitung fuer jp_demo-Organisation: drei separate Sprach-Settings statt einer einzigen output_language. org_settings.py: - get_source_language_whitelist: Liste erlaubter Quellsprachen als JSON-Array (z.B. ["ja"] beschraenkt RSS/Telegram auf japanische Quellen). - get_research_language: Sprache fuer WebSearch-Prompts (Default: output_language). - get_translator_enabled: Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags. - LANGUAGE_DISPLAY_NAMES um ja/zh/ko/ru/ar/fa/he/fr/es erweitert. source_rules.py: - get_feeds_with_metadata filtert nach source_language_whitelist, wenn gesetzt. - Feeds ohne primary_language fallen bei aktiver Whitelist raus (gewollt). - SELECT um media_type erweitert, damit es im Feed-Dict ankommt. orchestrator.py: - Laedt research_language, source_language_whitelist, translator_enabled aus den Org-Settings. - Wenn Whitelist gesetzt: international_sources-Flag wird ignoriert. - research_language_iso wird an researcher.search() weitergegeben. - translate_articles bekommt enabled-Parameter aus Org-Setting. - Geoparsing ueberspringt media_type='forum' Artikel. - SELECT * FROM articles wird zu JOIN sources, damit media_type beim Reload am Article-Dict haengt. researcher.py: - search() akzeptiert research_language_iso. Asymmetrische Sprach-Auswahl (Recherche != Output) erzeugt eigene Prompt-Anweisung "primaer in Quell- sprache, englische Region-Outlets erlaubt". translator.py: - translate_articles akzeptiert enabled-Parameter. Ueberschreibt die globale TRANSLATOR_ENABLED-Konstante pro Aufruf. factchecker.py: - _format_articles_text filtert Artikel mit media_type='forum' aus. Anonyme Foren-Posts gelten nicht als Faktenbeleg. rss_parser.py: - _fetch_feed traegt media_type aus feed_config ins Article-Dict ein, damit downstream Pipeline-Schritte Foren-Quellen erkennen. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/factchecker.py | 22 +++++++++- src/agents/orchestrator.py | 58 ++++++++++++++++++++++--- src/agents/researcher.py | 42 ++++++++++++++++-- src/agents/translator.py | 13 ++++-- src/feeds/rss_parser.py | 4 ++ src/services/org_settings.py | 82 ++++++++++++++++++++++++++++++++++-- src/source_rules.py | 25 +++++++++-- 7 files changed, 226 insertions(+), 20 deletions(-) diff --git a/src/agents/factchecker.py b/src/agents/factchecker.py index ef15113..a33ff0e 100644 --- a/src/agents/factchecker.py +++ b/src/agents/factchecker.py @@ -431,9 +431,27 @@ class FactCheckerAgent: """Prüft Fakten über Claude CLI gegen unabhängige Quellen.""" def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str: - """Formatiert Artikel als Text für den Prompt.""" + """Formatiert Artikel als Text für den Prompt. + + Foren-Quellen (media_type='forum', z.B. 5ch/Hatena/Note) werden hier + ausgeschlossen — sie sind Stimmungsmaterial, kein Faktenbeleg. Ein + anonymer Forenpost darf nicht als "Quelle bestaetigt Behauptung X" + gelten. + """ + # Falls media_type am Dict vorhanden ist, Foren-Quellen ausfiltern. + # Bei Article-Dicts aus dem RSS-/Pre-Topic-Pfad ist das Feld gesetzt; + # bei Reload aus der DB muss der Orchestrator das per JOIN annotieren. + non_forum = [a for a in articles if (a.get("media_type") or "").lower() != "forum"] + skipped = len(articles) - len(non_forum) + if skipped > 0: + logger.info( + "Faktencheck: %d Foren-Quellen (media_type='forum') ausgeschlossen, " + "%d Artikel als Faktenbeleg-Kandidaten", + skipped, len(non_forum), + ) + articles_text = "" - for i, article in enumerate(articles[:max_articles]): + for i, article in enumerate(non_forum[:max_articles]): articles_text += f"\n--- Meldung {i+1} ---\n" articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n" source_url = article.get('source_url', '') diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index d251211..63b4770 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -744,14 +744,42 @@ class AgentOrchestrator: description = incident["description"] or "" incident_type = incident["type"] or "adhoc" international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True + # Wenn die Org eine Sprach-Whitelist gesetzt hat, ist 'international' bedeutungslos — + # die Whitelist gewinnt. Wir setzen 'international' auf True, damit der nachgelagerte + # Code alle (durch Whitelist gefilterten) Feeds in Betracht zieht. Tatsaechliche + # Einschraenkung passiert in get_feeds_with_metadata. + # Hinweis: source_lang_whitelist wird weiter unten geladen. include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False visibility = incident["visibility"] if "visibility" in incident.keys() else "public" created_by = incident["created_by"] if "created_by" in incident.keys() else None tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None # Org-Sprache fuer alle KI-Agenten (Lagebild, Faktencheck, Recherche) - from services.org_settings import get_org_language, language_display + from services.org_settings import ( + get_org_language, language_display, get_research_language, + get_source_language_whitelist, get_translator_enabled, + ) output_language_iso = await get_org_language(db, tenant_id) if tenant_id else "de" output_language = language_display(output_language_iso) + # research_language steuert nur den WebSearch-Prompt ("suche in Sprache X"). + # Default = output_language_iso. Bei jp_demo wird das auf 'ja' gesetzt, waehrend + # output_language_iso 'de' bleibt (Lagebild auf Deutsch, Recherche auf Japanisch). + research_language_iso = await get_research_language(db, tenant_id) if tenant_id else output_language_iso + # source_language_whitelist schraenkt RSS-/Telegram-Quellenpool ein (z.B. ['ja']). + # Wenn gesetzt, wird das incident-level Flag international_sources ignoriert + # (Whitelist ist explizit, das Flag ist Default-Verhalten). + source_lang_whitelist = await get_source_language_whitelist(db, tenant_id) if tenant_id else None + # Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags. + translator_enabled = await get_translator_enabled(db, tenant_id) + # Whitelist gewinnt ueber das incident-Flag international_sources: + # wenn die Org eine Sprach-Whitelist hat, sind alle gewaehlten Feeds + # ohnehin "Wunsch-Sprache" — kein Splitting in primary/international noetig. + if source_lang_whitelist: + international = True + logger.info( + "Org %s hat source_language_whitelist=%s gesetzt; " + "incident.international_sources wird ignoriert", + tenant_id, source_lang_whitelist, + ) previous_summary = incident["summary"] or "" previous_sources_json = incident["sources_json"] if "sources_json" in incident.keys() else None previous_developments = incident["latest_developments"] if "latest_developments" in incident.keys() else None @@ -936,6 +964,7 @@ class AgentOrchestrator: preferred_sources=preferred_sources, output_language=output_language, output_language_iso=output_language_iso, + research_language_iso=research_language_iso, ) logger.info( f"Claude-Recherche: {len(results)} Ergebnisse" @@ -1209,14 +1238,25 @@ class AgentOrchestrator: await db.commit() # Geoparsing: Orte aus neuen Artikeln extrahieren und speichern - if new_articles_for_analysis: + # Foren-Quellen (media_type='forum') ausschliessen: 5ch/Hatena/Note-Posts haben + # keinen eigenen, fuer das Lagebild interessanten geographischen Bezug; spart Haiku-Calls. + articles_for_geoparsing = [ + a for a in new_articles_for_analysis + if (a.get("media_type") or "").lower() != "forum" + ] + if new_articles_for_analysis and not articles_for_geoparsing: + logger.info( + "Geoparsing uebersprungen: alle %d neuen Artikel sind Forum-Quellen", + len(new_articles_for_analysis), + ) + if articles_for_geoparsing: # Pipeline-Schritt 5: Orte erkennen (Start) await _pipe_start("geoparsing") try: from agents.geoparsing import geoparse_articles incident_context = f"{title} - {description}" - logger.info(f"Geoparsing fuer {len(new_articles_for_analysis)} neue Artikel...") - geo_results, category_labels = await geoparse_articles(new_articles_for_analysis, incident_context) + logger.info(f"Geoparsing fuer {len(articles_for_geoparsing)} neue Artikel (Foren ausgeschlossen)...") + geo_results, category_labels = await geoparse_articles(articles_for_geoparsing, incident_context) geo_count = 0 for art_id, locations in geo_results.items(): for loc in locations: @@ -1294,7 +1334,12 @@ class AgentOrchestrator: all_articles_preloaded = None if not previous_summary or new_count == 0 or not existing_facts: cursor = await db.execute( - "SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC", + # JOIN auf sources, damit media_type pro Artikel verfuegbar ist + # (Faktencheck schliesst Foren-Quellen aus, das Stimmungs-Modul nimmt + # nur diese). Bei Quellen ohne Match in sources bleibt media_type NULL. + "SELECT a.*, s.media_type AS media_type FROM articles a " + "LEFT JOIN sources s ON s.name = a.source " + "WHERE a.incident_id = ? ORDER BY a.collected_at DESC", (incident_id,), ) all_articles_preloaded = [dict(row) for row in await cursor.fetchall()] @@ -1582,8 +1627,9 @@ class AgentOrchestrator: from services.post_refresh_qc import normalize_german_umlauts as _norm_de2 translations = await translate_articles( pending_translations, - output_lang="de", + output_lang=output_language_iso, usage_accumulator=usage_acc, + enabled=translator_enabled, ) for t in translations: hd = t.get("headline_de") diff --git a/src/agents/researcher.py b/src/agents/researcher.py index bf7f046..1b42e29 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -562,14 +562,27 @@ class ResearcherAgent: logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}") return None, None - async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de") -> tuple[list[dict], ClaudeUsage | None, bool]: + async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de", research_language_iso: str | None = None) -> tuple[list[dict], ClaudeUsage | None, bool]: """Sucht nach Informationen zu einem Vorfall. + Args: + output_language / output_language_iso: Ausgabesprache (Lagebild-Sprache). + research_language_iso: optionaler Override fuer die Sprache, in der gesucht + werden soll. Default = output_language_iso. Bei jp_demo z.B. 'ja', + waehrend output_language_iso 'de' bleibt (Lagebild deutsch, Recherche japanisch). + Returns: (artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat, das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen "echt keine Treffer" und "kaputte Antwort" unterscheiden. """ + # research_language defaultet auf output_language. Wenn das aber abweicht + # (z.B. jp_demo: research='ja', output='de'), ueberschreiben wir die + # Sprach-Anweisung im Prompt mit einer eigenen, dual-sprachigen Variante. + research_language_iso = (research_language_iso or output_language_iso or "de").lower() + # Display-Name der Recherche-Sprache fuer Prompts ("Japanese", "Russian", ...) + from services.org_settings import language_display as _lang_display + research_language_display = _lang_display(research_language_iso) # Bevorzugte Web-Quellen als Prompt-Block (optional) preferred_sources_block = "" if preferred_sources: @@ -589,8 +602,31 @@ class ResearcherAgent: "aber nicht deine sonstige Recherche.\n" ) + # Asymmetrische Sprach-Auswahl: research_language weicht von output_language ab + # -> eigene Anweisung "primaer in research-language, englische Quellen aus der + # Region auch erlaubt". Sonst die bisherige Logik (primary_only vs international). + asymmetric_lang = research_language_iso != output_language_iso + + def _build_lang_instruction(deep: bool) -> str: + if asymmetric_lang: + # jp_demo & Co.: Recherche in Quellsprache + lokale Englisch-Outlets. + return ( + f"- Fokus liegt auf {research_language_display}-sprachigen Quellen " + f"(Behoerden, Qualitaetszeitungen, oeffentlich-rechtliche Medien dieser Sprache).\n" + f"- Englischsprachige Outlets mit Fokus auf demselben Sprachraum/Region sind " + f"ebenfalls willkommen (z.B. Japan Times, Nikkei Asia, Kyodo English fuer Japan; " + f"Moscow Times English fuer Russland).\n" + f"- Quellen ausserhalb des Sprachraums NUR, wenn sie exklusive Informationen " + f"ueber die Region liefern (z.B. Reuters/AFP/AP-Berichte aus der Region).\n" + f"- Antworte in der Ausgabesprache {output_language} (das Lagebild wird in " + f"{output_language} angezeigt), aber zitiere die Original-Headlines/Quellen unveraendert." + ) + if deep: + return lang_deep_international(output_language) if international else lang_deep_primary_only(output_language) + return lang_international(output_language) if international else lang_primary_only(output_language) + if incident_type == "research": - lang_instruction = lang_deep_international(output_language) if international else lang_deep_primary_only(output_language) + lang_instruction = _build_lang_instruction(deep=True) # Bestehende Artikel als Kontext für den Prompt aufbereiten existing_context = "" if existing_articles: @@ -611,7 +647,7 @@ class ResearcherAgent: preferred_sources_block=preferred_sources_block, ) else: - lang_instruction = lang_international(output_language) if international else lang_primary_only(output_language) + lang_instruction = _build_lang_instruction(deep=False) # Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen existing_context = "" if existing_articles: diff --git a/src/agents/translator.py b/src/agents/translator.py index 9ecfe58..20fc8c8 100644 --- a/src/agents/translator.py +++ b/src/agents/translator.py @@ -373,20 +373,27 @@ async def translate_articles( output_lang: str = "de", batch_size: int = DEFAULT_BATCH_SIZE, usage_accumulator: UsageAccumulator | None = None, + enabled: bool | None = None, ) -> list[dict]: """Uebersetzt eine beliebige Anzahl Artikel in Batches. Bringt die Batches durch Logik in `translate_articles_batch` und gibt EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt, wird er uebersprungen (anderer Batches laufen weiter). + + enabled: Pro-Aufruf-Override des globalen TRANSLATOR_ENABLED-Flags. Wenn None, + greift das Modul-Default (config.TRANSLATOR_ENABLED, abgeleitet aus .env). + Der Orchestrator setzt das aus dem Org-Setting 'translator_enabled', damit + jp_demo (Translator zwingend an) trotz global deaktiviertem Flag funktioniert. """ if not articles: return [] - if not TRANSLATOR_ENABLED: + is_enabled = TRANSLATOR_ENABLED if enabled is None else bool(enabled) + if not is_enabled: logger.info( - "Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen", - len(articles), + "Translator deaktiviert (enabled=%s, global TRANSLATOR_ENABLED=%s), %d Artikel uebersprungen", + enabled, TRANSLATOR_ENABLED, len(articles), ) return [] diff --git a/src/feeds/rss_parser.py b/src/feeds/rss_parser.py index 7e3d1d6..58335b7 100644 --- a/src/feeds/rss_parser.py +++ b/src/feeds/rss_parser.py @@ -227,6 +227,10 @@ class RSSParser: # alle "news.google.com" sind, obwohl sie für 14 verschiedene # Behörden/Zeitungen stehen. Wird vom Domain-Cap genutzt. "source_domain": feed_config.get("domain") or "", + # media_type aus dem Feed-Eintrag (z.B. "forum" fuer 5ch/Hatena/Note) + # damit downstream Pipeline-Schritte (Faktencheck, Geoparsing, + # Topic-Filter, Stimmungs-Kachel) Foren-Quellen erkennen koennen. + "media_type": feed_config.get("media_type") or "", "content_original": summary[:1000] if summary else None, "content_de": summary[:1000] if summary and self._is_german(summary) else None, # Sprache primär aus der Quell-Konfiguration übernehmen diff --git a/src/services/org_settings.py b/src/services/org_settings.py index d152b5d..cc8ac1d 100644 --- a/src/services/org_settings.py +++ b/src/services/org_settings.py @@ -1,12 +1,17 @@ """Organization-Settings-Helper. -KV-Store pro Organisation. Aktuell genutzt fuer output_language ('de'|'en'). -Spaeter erweiterbar (Default-Modell, Telegram-Toggle, Theme, ...). +KV-Store pro Organisation. Aktuell genutzt fuer: + - output_language ('de'|'en'|...) - Anzeige-/Lagebild-Sprache + - source_language_whitelist (JSON-Liste, z.B. ["ja"]) - schraenkt RSS/Telegram-Quellen ein + - research_language (ISO-Code) - steuert WebSearch-Prompts (default = output_language) + - translator_enabled ('true'|'false') - override fuer das globale TRANSLATOR_ENABLED-Flag Cache: TTL 60s in-memory pro (tenant_id, key). Wird bei set_org_setting() invalidiert. """ +import json import logging +import os import time from typing import Optional @@ -84,6 +89,15 @@ async def set_org_setting( LANGUAGE_DISPLAY_NAMES = { "de": "Deutsch", "en": "English", + "ja": "Japanese", + "zh": "Chinese", + "ko": "Korean", + "ru": "Russian", + "ar": "Arabic", + "fa": "Persian", + "he": "Hebrew", + "fr": "French", + "es": "Spanish", } @@ -91,7 +105,10 @@ async def get_org_language( db: aiosqlite.Connection, tenant_id: int, ) -> str: - """Liefert ISO-2-Sprachcode der Org (default 'de').""" + """Liefert ISO-2-Sprachcode der Org (default 'de'). + + Steuert die Lagebild-/Anzeige-Sprache. + """ value = await get_org_setting(db, tenant_id, "output_language", default="de") if value not in LANGUAGE_DISPLAY_NAMES: logger.warning("Unbekannte output_language '%s' fuer Org %s -- fallback 'de'", value, tenant_id) @@ -99,6 +116,65 @@ async def get_org_language( return value +async def get_source_language_whitelist( + db: aiosqlite.Connection, + tenant_id: int, +) -> Optional[list[str]]: + """Liefert Liste erlaubter Quellsprachen oder None (= keine Einschränkung). + + Gespeichert als JSON-Array unter dem Key 'source_language_whitelist'. + Beispiel-Wert: '["ja"]' -> nur japanischsprachige Quellen. + """ + raw = await get_org_setting(db, tenant_id, "source_language_whitelist", default=None) + if not raw: + return None + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, TypeError) as e: + logger.warning( + "source_language_whitelist fuer Org %s ist kein JSON ('%s'): %s", + tenant_id, raw, e, + ) + return None + if not isinstance(parsed, list): + logger.warning("source_language_whitelist fuer Org %s ist keine Liste: %r", tenant_id, parsed) + return None + cleaned = [str(x).strip().lower() for x in parsed if str(x).strip()] + return cleaned or None + + +async def get_research_language( + db: aiosqlite.Connection, + tenant_id: int, +) -> str: + """Liefert die Sprache, in der der WebSearch-Researcher primär sucht. + + Default = output_language. Bei jp_demo z.B. 'ja', während output_language='de' bleibt. + """ + value = await get_org_setting(db, tenant_id, "research_language", default=None) + if value and value in LANGUAGE_DISPLAY_NAMES: + return value + return await get_org_language(db, tenant_id) + + +async def get_translator_enabled( + db: aiosqlite.Connection, + tenant_id: Optional[int], +) -> bool: + """Liefert true wenn der (volle) Translator-Schritt fuer diese Org laufen soll. + + Hierarchie: + 1. Org-Setting 'translator_enabled' ('true'/'false') gewinnt, wenn gesetzt. + 2. Sonst: globales ENV-Flag TRANSLATOR_ENABLED (Default true im config.py). + """ + if tenant_id is not None: + raw = await get_org_setting(db, tenant_id, "translator_enabled", default=None) + if raw is not None: + return str(raw).strip().lower() in ("true", "1", "yes", "on") + env_value = os.environ.get("TRANSLATOR_ENABLED", "true").strip().lower() + return env_value in ("true", "1", "yes", "on") + + def language_display(lang_iso: str) -> str: """ISO-Code -> Anzeigename fuer Prompts ('de' -> 'Deutsch').""" return LANGUAGE_DISPLAY_NAMES.get(lang_iso, lang_iso) diff --git a/src/source_rules.py b/src/source_rules.py index a817d49..1b5aaed 100644 --- a/src/source_rules.py +++ b/src/source_rules.py @@ -642,14 +642,20 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss source_type: "rss_feed" (Default) oder "podcast_feed" — trennt RSS- und Podcast-Quellen in getrennten Pipelines, damit der RSS-Heisspfad unveraendert bleibt. + + Wenn die Org eine source_language_whitelist gesetzt hat (z.B. jp_demo: ['ja']), + werden nur Feeds geliefert, deren primary_language darauf passt. Feeds ohne + gesetztes primary_language fallen in dem Fall raus — das ist gewollt, weil + eine Whitelist gerade die strenge Beschraenkung ist. """ from database import get_db + from services.org_settings import get_source_language_whitelist db = await get_db() try: if tenant_id: cursor = await db.execute( - "SELECT name, url, domain, category, notes, primary_language, " + "SELECT name, url, domain, category, notes, primary_language, media_type, " "COALESCE(article_count, 0) AS article_count FROM sources " "WHERE source_type = ? AND status = 'active' " "AND (tenant_id IS NULL OR tenant_id = ?)", @@ -657,12 +663,25 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss ) else: cursor = await db.execute( - "SELECT name, url, domain, category, notes, primary_language, " + "SELECT name, url, domain, category, notes, primary_language, media_type, " "COALESCE(article_count, 0) AS article_count FROM sources " "WHERE source_type = ? AND status = 'active'", (source_type,), ) - return [dict(row) for row in await cursor.fetchall()] + feeds = [dict(row) for row in await cursor.fetchall()] + + # Whitelist-Filter (nur wenn die Org eine gesetzt hat) + if tenant_id: + whitelist = await get_source_language_whitelist(db, tenant_id) + if whitelist: + before = len(feeds) + feeds = [f for f in feeds if (f.get("primary_language") or "").lower() in whitelist] + logger.info( + "source_language_whitelist=%s fuer Org %s: %d/%d Feeds passieren", + whitelist, tenant_id, len(feeds), before, + ) + + return feeds except Exception as e: logger.error(f"Fehler beim Laden der Feed-Metadaten ({source_type}): {e}") return [] -- 2.49.1 From d65f0180d9ac3ee8b34932cd9d212a31e9d2067e Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Fri, 22 May 2026 00:20:17 +0200 Subject: [PATCH 2/3] feat(public-mood): Stimmungs-Kachel aus Foren-Quellen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigene Pipeline-Stufe nach factcheck, vor summary, die Foren-Artikel (media_type='forum') zu einer Themen-Zusammenfassung verarbeitet. Wird als separate Dashboard-Kachel "Öffentliche Stimmung" angezeigt — getrennt von Lagebild und Faktencheck, damit anonyme Forenposts nicht mit belegter Faktenlage verwechselt werden. - DB-Migration: incidents.public_mood (TEXT) + public_mood_updated_at (TS). - pipeline_tracker: neuer Pipeline-Step "public_mood" (DE/EN-Labels). - analyzer.generate_public_mood: Haiku-Call der Foren-Beitraege pro Quelle gruppiert und 3-6 thematische Bullets erzeugt, mit expliziter Quellen- Herkunft pro Bullet. Bei zu duennem Material gibt's keinen Output. - orchestrator: neuer Schritt zwischen Factcheck und Summary. Laedt alle Foren-Artikel der Lage (via JOIN auf sources), uebergibt sie an den Stimmungs-Agent, speichert den Markdown-Text in incidents.public_mood. - Topic-Filter (analyzer.filter_relevant_articles) markiert Foren-Quellen mit [FORUM]-Tag und bekommt im Prompt die Regel, Foren-Artikel weicher zu bewerten (Lage-Keyword im Titel reicht). Sie sollen in der Stimmungs- Kachel landen, nicht voreilig verworfen werden. - IncidentResponse-Modell: public_mood/public_mood_updated_at ergaenzt. - Frontend: neuer Tab "Öffentliche Stimmung" (nur sichtbar wenn Inhalt da), eigene Kachel mit Warn-Hinweis "keine Faktenlage". UI.renderPublicMood als einfacher Bullet-Renderer. - dashboard.html Cache-Buster fuer components.js + app.js gebumpt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/analyzer.py | 102 ++++++++++++++++++++++++++++++- src/agents/orchestrator.py | 65 ++++++++++++++++++++ src/database.py | 10 +++ src/models.py | 2 + src/services/pipeline_tracker.py | 4 ++ src/static/dashboard.html | 23 ++++++- src/static/js/app.js | 20 ++++++ src/static/js/components.js | 20 ++++++ 8 files changed, 243 insertions(+), 3 deletions(-) diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index f7bd80a..b37c3a4 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -260,6 +260,7 @@ REGELN: - Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung. - FREMDSPRACHIGE QUELLEN (CJK, Arabisch, Hebräisch, Kyrillisch): Wo verfügbar steht eine "Übersetzung:"-Zeile unter der Originalüberschrift. NUTZE die Übersetzung für deine Bewertung. Verwirf einen fremdsprachigen Artikel NICHT pauschal aus Sicherheit, wenn die Übersetzung das Lagethema sichtbar berührt — wende dieselben Maßstäbe an wie auf englische Artikel. - Im Zweifel bei lateinisch geschriebenen Quellen: NICHT relevant. Im Zweifel bei nicht-lateinischen Quellen mit übersetzter, thematisch passender Überschrift: relevant. +- FOREN-QUELLEN ([FORUM]-Tag hinter dem Quellennamen, z.B. 5ch, Hatena, Note): WEICHER bewerten. Sie liefern keine Faktenlage, sondern Stimmungsmaterial fuer eine separate Kachel. Wenn das Lage-Keyword im Thread-Titel oder in der ersten Zeile des Inhalts vorkommt UND der Beitrag nicht offensichtlich off-topic ist (Hobby, Sport ohne Bezug, reine Werbung), DURCHLASSEN. Im Zweifel bei Foren-Quellen: relevant. Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung: {{"relevant_ids": [1, 3, 7]}}""" @@ -530,7 +531,11 @@ class AnalyzerAgent: # Pre-Topic-Translation für fremdsprachige Headlines (gesetzt vom Orchestrator) headline_en = article.get("headline_en_for_topic") content_en = article.get("content_en_for_topic") - lines.append(f"[{i}] Quelle: {source}") + # Foren-Quellen explizit markieren, damit Haiku sie weicher bewertet + # (Stimmungs-Material, nicht Faktenlage — eigener Filter-Modus im Prompt) + is_forum = (article.get("media_type") or "").lower() == "forum" + source_label = f"{source} [FORUM]" if is_forum else source + lines.append(f"[{i}] Quelle: {source_label}") lines.append(f" Überschrift: {headline}") if headline_en and headline_en.strip().lower() != (headline or "").strip().lower(): lines.append(f" Übersetzung: {headline_en}") @@ -667,6 +672,101 @@ class AnalyzerAgent: logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert") return output, usage + async def generate_public_mood( + self, + title: str, + description: str, + forum_articles: list[dict], + output_language: str = "Deutsch", + ) -> tuple[str | None, ClaudeUsage | None]: + """Generiert die Kachel 'Öffentliche Stimmung' aus Foren-Quellen. + + Eingabe: Artikel mit media_type='forum' (5ch-Threads, Hatena-Bookmarks, + Note-Trending-Posts etc.). Ausgabe: 3-6 Markdown-Bullets, jeder Bullet + fasst ein dominantes Thema/eine Bruchlinie der Diskussion zusammen und + nennt explizit die Quellen-Herkunft (z.B. "Auf 5ch /seiji/ ueberwiegen + ablehnende Stimmen ..."). + + WICHTIG: Das ist Stimmungsmaterial, NICHT Faktenlage. Der Prompt weist + Claude explizit an, Eigenaussagen aus Foren nicht als Fakt zu zitieren. + + Returns: (markdown_text, usage) oder (None, usage) bei leerer/kaputter + Antwort. Bei keinen Foren-Artikeln: (None, None). + """ + if not forum_articles: + return None, None + + from config import CLAUDE_MODEL_FAST + + # Pro Quelle gruppieren, damit Claude die Herkunft kennt + by_source: dict[str, list[dict]] = {} + for a in forum_articles: + src = (a.get("source") or "Forum (unbekannt)").strip() + by_source.setdefault(src, []).append(a) + + # Artikel-Block bauen, kompakt aber mit Herkunft + lines: list[str] = [] + for src, items in by_source.items(): + lines.append(f"\n=== Quelle: {src} ({len(items)} Beitrag/-e) ===") + for it in items[:15]: # max 15 pro Quelle, sonst sprengt das den Prompt + headline = it.get("headline_de") or it.get("headline_en_for_topic") or it.get("headline", "") + content = ( + it.get("content_de") + or it.get("content_en_for_topic") + or it.get("content_original") + or "" + ) + lines.append(f"- {headline[:200]}") + if content: + lines.append(f" {content[:300]}") + articles_block = "\n".join(lines) + + prompt = f"""Du bist ein OSINT-Analyst. Aus den folgenden ANONYMEN FOREN-/COMMUNITY-BEITRAEGEN sollst du das Stimmungsbild der oeffentlichen Online-Diskussion fuer eine Lage extrahieren. + +LAGE: {title} +KONTEXT: {description} + +FOREN-BEITRAEGE (gruppiert nach Quelle): +{articles_block} + +AUFGABE: +Erstelle eine kompakte Themen-Zusammenfassung in {output_language}: 3-6 Markdown-Bullet-Points, jeder Bullet fasst ein dominantes Thema, eine Forderung oder eine Bruchlinie der Diskussion zusammen. Pro Bullet 1-3 Saetze. + +REGELN: +- DIES IST KEINE FAKTENLAGE. Du fasst zusammen, wie online diskutiert wird, nicht was wahr ist. +- Quellen-Herkunft je Bullet EXPLIZIT nennen ("auf 5ch /seiji/ ueberwiegen ablehnende Reaktionen...", "Hatena-Kommentare betonen ueberwiegend ...", "Note-Autoren schreiben ueberwiegend ..."). +- KEINE Eigenaussagen aus Forenposts als Faktenbehauptung uebernehmen. +- KEINE Klarnamen, persoenliche Daten oder Beleidigungen Dritter zitieren. +- Bei klaren Pro-/Contra-Lagern beide Seiten beschreiben. +- Wenn das Material zu duenn oder off-topic ist, gib explizit "Material zu duenn fuer Stimmungsbild" zurueck statt zu spekulieren. +- Markdown: nur "- " Bullets, keine Ueberschriften, kein Fettdruck, keine Inline-Quellenverweise [1]. +- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze. +- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss). + +Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohne Erklaerung.""" + + try: + result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST) + except Exception as e: + logger.warning(f"Public-Mood Claude-Call fehlgeschlagen: {e}") + return None, None + + text = (result or "").strip() + if not text or "zu duenn" in text.lower() or "too thin" in text.lower(): + logger.info("Public-Mood: Material zu duenn, kein Stimmungsbild generiert") + return None, usage + + # Sanity-Check: mindestens 1 Bullet (- am Zeilenanfang) + if not any(line.lstrip().startswith("-") for line in text.split("\n")): + logger.warning("Public-Mood: Claude-Antwort enthaelt keine Bullets, Sample: %r", text[:200]) + return None, usage + + logger.info( + "Public-Mood: %d Forum-Beitraege aus %d Quellen zu Stimmungsbild zusammengefasst", + len(forum_articles), len(by_source), + ) + return text, usage + @staticmethod def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]: """Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort. diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index 63b4770..6419c09 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -1492,6 +1492,71 @@ class AgentOrchestrator: logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True) fact_context_block = "" + # Pipeline-Schritt 6b: Öffentliche Stimmung aus Foren-Quellen + # (nur Artikel mit media_type='forum'). Eigene Kachel, kein Faktencheck. + # Wird vor dem Lagebild-Schritt ausgefuehrt, damit das Lagebild bei + # Bedarf darauf verweisen kann (z.B. Demo-Lagen mit Bezug zur Stimmung). + try: + # Bestand aller Foren-Artikel der Lage laden (inkl. media_type via JOIN) + cursor_fm = await db.execute( + "SELECT a.*, s.media_type AS media_type FROM articles a " + "LEFT JOIN sources s ON s.name = a.source " + "WHERE a.incident_id = ?", + (incident_id,), + ) + all_articles_with_mt = [dict(r) for r in await cursor_fm.fetchall()] + forum_articles_in_db = [ + a for a in all_articles_with_mt + if (a.get("media_type") or "").lower() == "forum" + ] + # Aus dem aktuellen Refresh-Lauf zusaetzliche Foren-Artikel ergaenzen + # (haben media_type aus feed_config, sind aber evtl. noch nicht in DB, + # wenn die Persistierung anders laeuft — Robustheit). + for art in new_articles_for_analysis: + if (art.get("media_type") or "").lower() != "forum": + continue + # Duplikate vermeiden ueber source_url + if any(a.get("source_url") == art.get("source_url") for a in forum_articles_in_db): + continue + forum_articles_in_db.append(art) + + if forum_articles_in_db: + await _pipe_start("public_mood") + try: + mood_agent = AnalyzerAgent() + mood_text, mood_usage = await mood_agent.generate_public_mood( + title, description, forum_articles_in_db, + output_language=output_language, + ) + if mood_usage: + usage_acc.add(mood_usage) + if mood_text: + await db.execute( + "UPDATE incidents SET public_mood = ?, public_mood_updated_at = ? WHERE id = ?", + (mood_text, now, incident_id), + ) + await db.commit() + logger.info( + "Public-Mood gespeichert fuer Incident %d (%d Foren-Artikel)", + incident_id, len(forum_articles_in_db), + ) + await _pipe_done( + "public_mood", + count_value=len(forum_articles_in_db), + count_secondary=(1 if mood_text else 0), + ) + except Exception as mood_err: + logger.warning("Public-Mood fehlgeschlagen: %s", mood_err, exc_info=True) + await _pipe_done("public_mood", count_value=0, count_secondary=0) + else: + await _pipe_skip("public_mood") + except Exception as mood_outer_err: + logger.warning("Public-Mood-Block uebersprungen: %s", mood_outer_err) + try: + await _pipe_skip("public_mood") + except Exception: + pass + # Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext) await _pipe_start("summary") logger.info( diff --git a/src/database.py b/src/database.py index 8f6c4c1..b4cb242 100644 --- a/src/database.py +++ b/src/database.py @@ -429,6 +429,16 @@ async def init_db(): await db.commit() logger.info("Migration: latest_developments zu incidents hinzugefuegt") + if "public_mood" not in columns: + await db.execute("ALTER TABLE incidents ADD COLUMN public_mood TEXT") + await db.commit() + logger.info("Migration: public_mood zu incidents hinzugefuegt") + + if "public_mood_updated_at" not in columns: + await db.execute("ALTER TABLE incidents ADD COLUMN public_mood_updated_at TIMESTAMP") + await db.commit() + logger.info("Migration: public_mood_updated_at zu incidents hinzugefuegt") + # Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte) cursor = await db.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'" diff --git a/src/models.py b/src/models.py index 1ac356d..b6486fd 100644 --- a/src/models.py +++ b/src/models.py @@ -98,6 +98,8 @@ class IncidentResponse(BaseModel): visibility: str = "public" summary: Optional[str] latest_developments: Optional[str] = None + public_mood: Optional[str] = None + public_mood_updated_at: Optional[str] = None international_sources: bool = True include_telegram: bool = False created_by: int diff --git a/src/services/pipeline_tracker.py b/src/services/pipeline_tracker.py index c17d7e5..86cc43a 100644 --- a/src/services/pipeline_tracker.py +++ b/src/services/pipeline_tracker.py @@ -32,6 +32,8 @@ _PIPELINE_STEPS_DE = [ "tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet."}, {"key": "factcheck", "label": "Fakten prüfen", "icon": "shield", "tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?"}, + {"key": "public_mood", "label": "Stimmung erfassen", "icon": "message-circle", + "tooltip": "Aus Foren-Quellen (z.B. 5ch, Hatena, Note) wird ein Stimmungsbild der öffentlichen Diskussion extrahiert. Keine Faktenlage, sondern dominante Themen und Bruchlinien."}, {"key": "summary", "label": "Lagebild verfassen", "icon": "file-text", "tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text."}, {"key": "qc", "label": "Qualitätscheck", "icon": "check-circle", @@ -53,6 +55,8 @@ _PIPELINE_STEPS_EN = [ "tooltip": "Locations are extracted from the articles and placed on the map."}, {"key": "factcheck", "label": "Checking facts", "icon": "shield", "tooltip": "Claims from the articles are cross-checked: Confirmed? Disputed? Still unclear?"}, + {"key": "public_mood", "label": "Reading the mood", "icon": "message-circle", + "tooltip": "Forum sources (5ch, Hatena, Note, etc.) are summarised into a public-mood overview. Not factual, but dominant themes and fault lines."}, {"key": "summary", "label": "Writing the briefing", "icon": "file-text", "tooltip": "All verified articles are combined into a coherent briefing with inline citations."}, {"key": "qc", "label": "Quality check", "icon": "check-circle", diff --git a/src/static/dashboard.html b/src/static/dashboard.html index b5ee2fc..7d90dc3 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -209,6 +209,7 @@ + @@ -293,6 +294,24 @@ +
+
+
+
+ Öffentliche Stimmung + +
+ +
+
+
+
+ Hinweis: Forenbeiträge sind anonyme Online-Stimmungen, keine Faktenlage. Sie fließen nicht in den Faktencheck ein. +
+
+
+
+
@@ -778,10 +797,10 @@ - + - + diff --git a/src/static/js/app.js b/src/static/js/app.js index 65e8252..e19e7cd 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -1131,6 +1131,26 @@ const App = { : ''; } + // Öffentliche Stimmung (Foren-Kachel): Tab + Inhalt nur einblenden, + // wenn fuer diese Lage tatsaechlich Stimmungs-Text vorhanden ist. + const stimmungTabBtn = document.getElementById('tab-btn-stimmung'); + const stimmungText = document.getElementById('stimmung-text'); + const stimmungTs = document.getElementById('stimmung-timestamp'); + const moodText = (incident.public_mood || '').trim(); + if (moodText && stimmungTabBtn) { + stimmungTabBtn.style.display = ''; + if (stimmungText) stimmungText.innerHTML = UI.renderPublicMood(moodText); + if (stimmungTs && incident.public_mood_updated_at) { + const mUpd = parseUTC(incident.public_mood_updated_at); + if (mUpd) { + stimmungTs.textContent = `Stand: ${mUpd.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${mUpd.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`; + } + } + } else if (stimmungTabBtn) { + stimmungTabBtn.style.display = 'none'; + if (stimmungText) stimmungText.innerHTML = ''; + } + { const _e = document.getElementById('meta-refresh-mode'); if (_e) { if (incident.refresh_mode === 'auto' && incident.refresh_start_time) { const intervalText = App._formatInterval(incident.refresh_interval); diff --git a/src/static/js/components.js b/src/static/js/components.js index 4e7ee6b..ba73725 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -813,6 +813,26 @@ const UI = { return html; }, + /** + * Rendert die "Öffentliche Stimmung"-Kachel. + * Eingabe ist Markdown mit "- "-Bullets (vom AnalyzerAgent.generate_public_mood). + * Quellen-Pills brauchen wir hier nicht — die Bullet-Texte nennen die Foren-Herkunft + * explizit ("auf 5ch /seiji/ ...", "Hatena-Kommentare betonen ..."). + */ + renderPublicMood(text) { + if (!text) return 'Noch kein Stimmungsbild erfasst.'; + const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l.startsWith("- ")); + if (bulletLines.length === 0) { + // Fliesstext-Fallback: HTML-escapen + Zeilenumbrueche + return this.escape(text).replace(/\n/g, '
'); + } + const items = bulletLines.map(l => { + const body = l.replace(/^-\s+/, ''); + return `
  • ${this.escape(body)}
  • `; + }).join(''); + return `
      ${items}
    `; + }, + /** * Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc). * Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}". -- 2.49.1 From 16d1133442b03318321efb67a304146a74ed732a Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Fri, 22 May 2026 00:28:30 +0200 Subject: [PATCH 3/3] feat(public-mood): Haiku-Moderationspass fuer Foren-Beitraege MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vor der Stimmungs-Zusammenfassung laeuft ein separater Haiku-Call, der pro Forum-Beitrag entscheidet: - publishable: unveraendert uebernehmen - redact: thematisch wertvoll, aber PII/Beleidigungen — Haiku liefert eine bereinigte Kurzfassung - discard: Hassrede gegen Gruppen, NSFW, glaubhafte Drohungen, reines Trolling — entfernen Damit liefert die jp_demo-Org keine ungefilterten 5ch/Hatena/Note-Posts in die Lagen-Anzeige. Fail-open: Bei API-/Parse-Fehler wird die Original- liste durchgereicht (Pipeline bricht nicht ab). - analyzer.moderate_forum_articles: Batch (max 25/Call), JSON-Output, Logging pro Entscheidungs-Klasse. - orchestrator: Moderation laeuft vor generate_public_mood, gefilterte Liste geht in die Stimmungs-Zusammenfassung. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/analyzer.py | 145 +++++++++++++++++++++++++++++++++++++ src/agents/orchestrator.py | 15 +++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index b37c3a4..ee92bc1 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -672,6 +672,151 @@ class AnalyzerAgent: logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert") return output, usage + async def moderate_forum_articles( + self, + forum_articles: list[dict], + ) -> tuple[list[dict], ClaudeUsage | None]: + """Vorab-Moderation fuer Foren-Beitraege (5ch, Hatena, Note ...). + + Schickt eine Batch von bis zu 25 Foren-Beitraegen an Haiku, der pro + Beitrag entscheidet: + - "publishable" -> Beitrag wird unveraendert in die Stimmungs-Kachel uebernommen. + - "redact" -> der Beitrag bleibt, aber sein Content wird auf eine kurze, + entschaerfte Version reduziert (Klarnamen, persoenliche Daten, persoenliche + Beleidigungen entfernt). Die Headline darf bleiben, wenn sie selbst clean ist. + - "discard" -> Beitrag wird aus der Liste entfernt (Hassrede gegen Gruppen, + NSFW, glaubhafte Drohungen, doxxing). + + Returns: + (gefilterte_liste, usage) — die Liste enthaelt publishable + redacted + Artikel (in Original-Reihenfolge). Discarded werden weggeworfen. Bei + API-/Parse-Fehler wird die Originalliste unveraendert zurueckgegeben + (Fail-Open, damit die Pipeline nicht hartfaellt — Haiku im Prompt + erinnert nochmal an Moderation). + """ + if not forum_articles: + return forum_articles, None + + from config import CLAUDE_MODEL_FAST + + # Pro Aufruf nicht mehr als 25 Beitraege (Token-Budget) + if len(forum_articles) > 25: + # In Batches verarbeiten, akkumulieren + kept: list[dict] = [] + total_usage: ClaudeUsage | None = None + for i in range(0, len(forum_articles), 25): + batch = forum_articles[i:i + 25] + batch_kept, batch_usage = await self.moderate_forum_articles(batch) + kept.extend(batch_kept) + if batch_usage: + if total_usage is None: + total_usage = batch_usage + else: + try: + total_usage.add(batch_usage) # type: ignore[attr-defined] + except Exception: + pass + return kept, total_usage + + items = [] + for i, a in enumerate(forum_articles): + headline = (a.get("headline_de") or a.get("headline_en_for_topic") or a.get("headline") or "").strip() + content = (a.get("content_de") or a.get("content_en_for_topic") or a.get("content_original") or "").strip() + items.append({ + "i": i, + "source": (a.get("source") or "Forum").strip(), + "headline": headline[:200], + "content": content[:600], + }) + + prompt = f"""Du bist ein Moderations-Agent fuer ANONYME FOREN-/COMMUNITY-BEITRAEGE (5ch, Hatena, Note). +Diese Beitraege gehen in eine Stimmungs-Kachel eines OSINT-Lagemonitorings ein, das auch von Behoerden gelesen werden kann. + +Pro Beitrag entscheide: +- "publishable": Beitrag ist sachlich-bezogen, ohne Hassrede gegen Gruppen, ohne Klarnamen Dritter, ohne sexuelle Inhalte, ohne Drohungen. Keine Aenderung noetig. +- "redact": Beitrag ist im Kern thematisch wertvoll, enthaelt aber persoenliche Daten, persoenliche Beleidigungen oder Klarnamen Dritter. Gib eine bereinigte Kurzfassung des Inhalts (1-3 Saetze) zurueck, die das thematische Argument behaelt aber alle PII/Beleidigungen entfernt. +- "discard": Beitrag ist Hassrede gegen ethnische/religioese/sexuelle Gruppen, NSFW, glaubhafte Drohung, oder reines Trolling ohne Themenbezug. + +EINGABE: +{json.dumps(items, ensure_ascii=False)} + +Antworte AUSSCHLIESSLICH mit einem JSON-Array. Pro Beitrag genau ein Objekt: +[ + {{"i": 0, "decision": "publishable"}}, + {{"i": 1, "decision": "redact", "clean_content": "Kurzfassung ohne PII."}}, + {{"i": 2, "decision": "discard"}} +] + +Keine Erklaerung, keine Einleitung, kein Markdown, nur das Array.""" + + try: + result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST) + except Exception as e: + logger.warning("Forum-Moderation Claude-Call fehlgeschlagen, fail-open: %s", e) + return forum_articles, None + + # Robustes JSON-Parsing + text = (result or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```\s*$", "", text) + text = text.strip() + try: + decisions = json.loads(text) + except json.JSONDecodeError: + m = re.search(r"\[.*\]", text, re.DOTALL) + if m: + try: + decisions = json.loads(m.group(0)) + except json.JSONDecodeError: + decisions = None + else: + decisions = None + if not isinstance(decisions, list): + logger.warning("Forum-Moderation: kein JSON-Array, fail-open. Sample: %r", text[:200]) + return forum_articles, usage + + decision_map: dict[int, dict] = {} + for d in decisions: + if isinstance(d, dict) and isinstance(d.get("i"), int): + decision_map[d["i"]] = d + + kept: list[dict] = [] + stats = {"publishable": 0, "redact": 0, "discard": 0, "unknown": 0} + for i, art in enumerate(forum_articles): + d = decision_map.get(i) + if not d: + # Keine Entscheidung fuer diesen Beitrag -> als publishable behandeln (fail-open) + kept.append(art) + stats["unknown"] += 1 + continue + decision = (d.get("decision") or "").strip().lower() + if decision == "discard": + stats["discard"] += 1 + continue + if decision == "redact": + clean = (d.get("clean_content") or "").strip() + if clean: + new_art = dict(art) + new_art["content_original"] = clean + new_art["content_de"] = clean if (art.get("content_de") or "") else None + new_art["_moderation"] = "redacted" + kept.append(new_art) + stats["redact"] += 1 + continue + # Redact ohne clean_content -> sicherheitshalber discard + stats["discard"] += 1 + continue + # Default / "publishable" + kept.append(art) + stats["publishable"] += 1 + + logger.info( + "Forum-Moderation: %d publishable, %d redacted, %d discarded, %d ohne Entscheidung", + stats["publishable"], stats["redact"], stats["discard"], stats["unknown"], + ) + return kept, usage + async def generate_public_mood( self, title: str, diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index 6419c09..5d6d67e 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -1524,8 +1524,15 @@ class AgentOrchestrator: await _pipe_start("public_mood") try: mood_agent = AnalyzerAgent() + # 1. Moderationspass: Hassrede/PII/NSFW vorab filtern. + moderated_articles, mod_usage = await mood_agent.moderate_forum_articles( + forum_articles_in_db, + ) + if mod_usage: + usage_acc.add(mod_usage) + # 2. Stimmungs-Zusammenfassung aus gefilterten Beitraegen. mood_text, mood_usage = await mood_agent.generate_public_mood( - title, description, forum_articles_in_db, + title, description, moderated_articles, output_language=output_language, ) if mood_usage: @@ -1537,12 +1544,12 @@ class AgentOrchestrator: ) await db.commit() logger.info( - "Public-Mood gespeichert fuer Incident %d (%d Foren-Artikel)", - incident_id, len(forum_articles_in_db), + "Public-Mood gespeichert fuer Incident %d (%d -> %d Foren-Artikel nach Moderation)", + incident_id, len(forum_articles_in_db), len(moderated_articles), ) await _pipe_done( "public_mood", - count_value=len(forum_articles_in_db), + count_value=len(moderated_articles), count_secondary=(1 if mood_text else 0), ) except Exception as mood_err: -- 2.49.1