feat(public-mood): Haiku-Moderationspass fuer Foren-Beitraege
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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren