feat(public-mood): Stimmungs-Kachel aus Foren-Quellen

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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
2026-05-22 00:20:17 +02:00
Ursprung 379d14518c
Commit d65f0180d9
8 geänderte Dateien mit 243 neuen und 3 gelöschten Zeilen

Datei anzeigen

@@ -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.

Datei anzeigen

@@ -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(