From d65f0180d9ac3ee8b34932cd9d212a31e9d2067e Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Fri, 22 May 2026 00:20:17 +0200 Subject: [PATCH] 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}".