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: