jp_demo Pipeline: Sprach-Whitelist + Stimmungs-Kachel + Moderation #32

Zusammengeführt
IntelSight_Admin hat 3 Commits von develop nach main 2026-05-22 00:29:01 +02:00 zusammengeführt
2 geänderte Dateien mit 156 neuen und 4 gelöschten Zeilen
Nur Änderungen aus Commit 16d1133442 werden angezeigt - Alle Commits anzeigen

Datei anzeigen

@@ -672,6 +672,151 @@ class AnalyzerAgent:
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert") logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
return output, usage 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( async def generate_public_mood(
self, self,
title: str, title: str,

Datei anzeigen

@@ -1524,8 +1524,15 @@ class AgentOrchestrator:
await _pipe_start("public_mood") await _pipe_start("public_mood")
try: try:
mood_agent = AnalyzerAgent() 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( mood_text, mood_usage = await mood_agent.generate_public_mood(
title, description, forum_articles_in_db, title, description, moderated_articles,
output_language=output_language, output_language=output_language,
) )
if mood_usage: if mood_usage:
@@ -1537,12 +1544,12 @@ class AgentOrchestrator:
) )
await db.commit() await db.commit()
logger.info( logger.info(
"Public-Mood gespeichert fuer Incident %d (%d Foren-Artikel)", "Public-Mood gespeichert fuer Incident %d (%d -> %d Foren-Artikel nach Moderation)",
incident_id, len(forum_articles_in_db), incident_id, len(forum_articles_in_db), len(moderated_articles),
) )
await _pipe_done( await _pipe_done(
"public_mood", "public_mood",
count_value=len(forum_articles_in_db), count_value=len(moderated_articles),
count_secondary=(1 if mood_text else 0), count_secondary=(1 if mood_text else 0),
) )
except Exception as mood_err: except Exception as mood_err: