"""Analyzer-Agent: Analysiert, übersetzt und fasst Meldungen zusammen."""
import json
import logging
import re
from datetime import datetime
from config import TIMEZONE
from agents.claude_client import call_claude, ClaudeUsage

logger = logging.getLogger("osint.analyzer")

ANALYSIS_PROMPT_TEMPLATE = """Du bist ein OSINT-Analyse-Agent für ein Lagemonitoring-System.
HEUTIGES DATUM: {today}
AUSGABESPRACHE: {output_language}
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

VORFALL: {title}
KONTEXT: {description}

{fact_context_block}VORHANDENE MELDUNGEN:
{articles_text}

AUFTRAG:
1. Erstelle eine neutrale, faktenbasierte Zusammenfassung auf {output_language}. Sei so ausführlich wie nötig, um alle wesentlichen Aspekte und Themenstränge abzudecken
2. Verwende Inline-Quellenverweise [1], [2], [3] etc. im Zusammenfassungstext
3. Liste die bestätigten Kernfakten auf
4. Übersetze fremdsprachige Überschriften und Inhalte in die Ausgabesprache

STRUKTUR:
- Wenn die Meldungen thematisch klar einen einzelnen Strang behandeln: Fließtext ohne Überschriften
- Wenn verschiedene Aspekte oder Themenfelder aufkommen (z.B. Ereignis + Reaktionen + Hintergrund): Gliedere mit kurzen Markdown-Zwischenüberschriften (##)
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
- Die Entscheidung liegt bei dir — Überschriften und Tabellen nur wenn sie dem Leser helfen
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Die neuesten Entwicklungen werden separat als eigene Kachel aufbereitet und dürfen im Lagebild NICHT dupliziert werden. Steige direkt mit dem Fließtext oder der ersten inhaltlichen Zwischenüberschrift ein.

REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
- Nur gesicherte Informationen in die Zusammenfassung
- Bei widersprüchlichen Angaben beide Seiten erwähnen
- Wurde eine Zahl im Verlauf korrigiert, stelle sie als Verlauf dar und nicht als Spanne: "mindestens 67 Tote, zuvor war von 57 die Rede" statt "57 bis 67 Tote". Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben Zeitpunkt verschiedene Werte erheben, dann jeweils mit Zuordnung ("50.000 laut Innenministerium, 60.000 laut Regionalregierung")
- Wertende Wörter nur, wenn die Quellen genau diese Wertung tragen. Erfolgte eine Rückkehr nach behördlicher Aufforderung und unter Androhung von Abschiebung, heißt es "kehrten zurück", nicht "kehrten freiwillig zurück"
- Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..."
- Quellen immer mit [Nr] referenzieren
- Jede verwendete Quelle MUSS im sources-Array aufgelistet sein
- Nummeriere die Quellen fortlaufend ab [1]. Verwende NUR ganze Zahlen als Quellennummern (z.B. [389], [390]), KEINE Buchstaben-Suffixe wie [389a]
- Ältere Quellen zeitlich einordnen (z.B. "laut einem Bericht vom Januar", "Anfang Februar berichtete...")

Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Zusammenfassung auf {output_language} mit Quellenverweisen [1], [2] etc. im Text (Markdown-Überschriften ## erlaubt wenn sinnvoll, aber KEINE "## ZUSAMMENFASSUNG"/"## ÜBERBLICK"-Sektion)
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)

Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""

BRIEFING_PROMPT_TEMPLATE = """Du bist ein OSINT-Analyse-Agent für ein Lagemonitoring-System.
Du erstellst ein strukturiertes Briefing für eine Hintergrundrecherche.
HEUTIGES DATUM: {today}
AUSGABESPRACHE: {output_language}
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

THEMA: {title}
KONTEXT: {description}

{fact_context_block}VORLIEGENDE QUELLEN:
{articles_text}

AUFTRAG:
Erstelle ein strukturiertes Briefing auf {output_language} mit folgenden Abschnitten. Sei so ausführlich wie nötig, um alle Aspekte gründlich abzudecken.
Verwende durchgehend Inline-Quellenverweise [1], [2], [3] etc. im Text.

## ZUSAMMENFASSUNG
Kompakte Übersicht als Aufzählung (4-8 Bullet Points mit "- "). Jeder Punkt fasst einen Kernaspekt des Themas in 1-2 Sätzen zusammen. Der Leser soll nach dieser Sektion das Wesentliche erfasst haben, ohne den Rest lesen zu müssen. WICHTIG: Die ZUSAMMENFASSUNG besteht AUSSCHLIESSLICH aus Bullet Points. KEIN Fliesstext vor, zwischen oder nach den Bullet Points. Detaillierte Ausführungen gehören in die anderen Sektionen (HINTERGRUND, AKTUELLE LAGE etc.).

## HINTERGRUND
Historischer Kontext, relevante Vorgeschichte

## AKTEURE
Beteiligte Personen, Organisationen, Institutionen und ihre Rollen

## AKTUELLE LAGE
Was ist der aktuelle Stand? Welche Entwicklungen gibt es?

## EINSCHÄTZUNG
Sachliche Bewertung der Situation, mögliche Entwicklungen

## QUELLENQUALITÄT
Kurze Bewertung der Informationslage: Wie belastbar sind die vorliegenden Quellen?

REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
- Nur gesicherte Informationen verwenden
- Bei widersprüchlichen Angaben beide Seiten erwähnen
- Wurde eine Zahl im Verlauf korrigiert, stelle sie als Verlauf dar und nicht als Spanne: "mindestens 67 Tote, zuvor war von 57 die Rede" statt "57 bis 67 Tote". Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben Zeitpunkt verschiedene Werte erheben, dann jeweils mit Zuordnung ("50.000 laut Innenministerium, 60.000 laut Regionalregierung")
- Wertende Wörter nur, wenn die Quellen genau diese Wertung tragen. Erfolgte eine Rückkehr nach behördlicher Aufforderung und unter Androhung von Abschiebung, heißt es "kehrten zurück", nicht "kehrten freiwillig zurück"
- Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..."
- Quellen immer mit [Nr] referenzieren
- Jede verwendete Quelle MUSS im sources-Array aufgelistet sein
- Nummeriere die Quellen fortlaufend ab [1]. Verwende NUR ganze Zahlen als Quellennummern (z.B. [389], [390]), KEINE Buchstaben-Suffixe wie [389a]
- Ältere Quellen zeitlich einordnen (z.B. "laut einem Bericht vom Januar", "Anfang Februar berichtete...")
- Markdown-Überschriften (##) für die Abschnitte verwenden
- KEIN Fettdruck (**) verwenden

Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "key_facts": Array von gesicherten Kernfakten (Strings, in Ausgabesprache)

Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""

INCREMENTAL_ANALYSIS_PROMPT_TEMPLATE = """Du bist ein OSINT-Analyse-Agent für ein Lagemonitoring-System.
HEUTIGES DATUM: {today}
AUSGABESPRACHE: {output_language}
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

VORFALL: {title}
KONTEXT: {description}

BISHERIGES LAGEBILD:
{previous_summary}

BISHERIGE QUELLEN:
{previous_sources_text}

{fact_context_block}NEUE MELDUNGEN SEIT DEM LETZTEN UPDATE:
{new_articles_text}

AUFTRAG:
1. Aktualisiere das Lagebild basierend auf den neuen Meldungen. Das Lagebild soll so ausführlich wie nötig sein, um alle wesentlichen Themenstränge abzudecken
2. Behalte bestätigte Fakten aus dem bisherigen Lagebild bei
3. Arbeite neue Erkenntnisse direkt in den thematisch passenden Abschnitt ein. Erzeuge KEINE datierten Verlaufsblöcke wie "Neu am DD.MM." oder "Neu seit ...". Das Lagebild ist eine zusammenhängende thematische Darstellung des AKTUELLEN Stands, kein chronologisches Änderungsprotokoll. Die zeitliche Abfolge der jüngsten Ereignisse wird separat in der Kachel "Neueste Entwicklungen" gepflegt und darf hier NICHT als Datums-Changelog dupliziert werden
4. Aktualisiere die Quellenverweise — neue Quellen bekommen fortlaufende Nummern nach den bisherigen
5. Entferne nur nachweislich widerlegte Informationen. Behalte alle thematischen Abschnitte bei, auch wenn sie nicht durch neue Meldungen aktualisiert werden

STRUKTUR:
- Fließtext oder mit Markdown-Zwischenüberschriften (##) — je nach Komplexität
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
- KEIN Fettdruck (**) verwenden
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Falls das BISHERIGE LAGEBILD eine solche Sektion enthält, ENTFERNE sie vollständig beim Aktualisieren. Die neuesten Entwicklungen werden separat als eigene Kachel gepflegt und dürfen im Lagebild NICHT dupliziert werden.
- KEINE datierten Verlaufsmarker im Lagebild. Einleitungen wie "Neu am 31.05./01.06.:", "Neu seit gestern:" oder vergleichbare Datums-Changelog-Phrasen sind nicht erlaubt. Falls das BISHERIGE LAGEBILD solche Blöcke enthält, LÖSE SIE AUF: integriere ihren Inhalt in den thematisch passenden Abschnitt und ENTFERNE die "Neu am"-Einleitung samt reiner Datumsgruppierung restlos. Innerhalb eines Abschnitts steht der aktuelle Stand vorne, ältere Belege werden im Fließtext zeitlich eingeordnet (z.B. "Ende Mai berichtete ...").
- KEINE stichwortartigen Fragmente und KEINE blanken Quellennummern-Sammlungen. Verboten sind Telegramm-Verkürzungen wie "Teheran-Bluff-Vorwurf [2897]. NYT-Abraham-Accords [2890]." sowie Auffangblöcke ohne Aussage wie "Frühere Belege [2806][2807]...". Jede Quellennummer muss an einem vollständigen, eigenständigen Satz hängen. Falls das BISHERIGE LAGEBILD solche Fragment- oder Sammelblöcke enthält, formuliere sie zu vollständigen Sätzen aus oder lass die betreffende Quellennummer weg. Am Ende eines Abschnitts oder des Lagebildes darf KEINE reine Aufzählung von Quellennummern stehen.

REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
- Bei widersprüchlichen Angaben beide Seiten erwähnen
- Wurde eine Zahl im Verlauf korrigiert, stelle sie als Verlauf dar und nicht als Spanne: "mindestens 67 Tote, zuvor war von 57 die Rede" statt "57 bis 67 Tote". Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben Zeitpunkt verschiedene Werte erheben, dann jeweils mit Zuordnung ("50.000 laut Innenministerium, 60.000 laut Regionalregierung")
- Wertende Wörter nur, wenn die Quellen genau diese Wertung tragen. Erfolgte eine Rückkehr nach behördlicher Aufforderung und unter Androhung von Abschiebung, heißt es "kehrten zurück", nicht "kehrten freiwillig zurück"
- Falls das BISHERIGE LAGEBILD Umschreibungen enthält (ae, oe, ue, ss anstelle von ä, ö, ü, ß), ersetze diese beim Aktualisieren durch echte Umlaute. Die Regel "echte UTF-8-Umlaute" hat Vorrang vor der Regel "bestehende Formulierungen beibehalten".
- Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..."
- Quellen immer mit [Nr] referenzieren
- Ältere Quellen zeitlich einordnen

Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
- "key_facts": Array aller aktuellen Kernfakten (in Ausgabesprache)

Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""

INCREMENTAL_BRIEFING_PROMPT_TEMPLATE = """Du bist ein OSINT-Analyse-Agent für ein Lagemonitoring-System.
Du aktualisierst ein strukturiertes Briefing für eine Hintergrundrecherche.
HEUTIGES DATUM: {today}
AUSGABESPRACHE: {output_language}
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

THEMA: {title}
KONTEXT: {description}

BISHERIGES BRIEFING:
{previous_summary}

BISHERIGE QUELLEN:
{previous_sources_text}

{fact_context_block}NEUE QUELLEN SEIT DEM LETZTEN UPDATE:
{new_articles_text}

AUFTRAG:
Aktualisiere das Briefing mit den neuen Erkenntnissen. Sei so ausführlich wie nötig. Behalte die Struktur bei:

## ZUSAMMENFASSUNG
## HINTERGRUND

WICHTIG zur Sektion ZUSAMMENFASSUNG:
- Falls das bisherige Briefing eine Sektion "## ÜBERBLICK" hat, benenne sie in "## ZUSAMMENFASSUNG" um
- Die ZUSAMMENFASSUNG muss als Aufzählung formatiert sein (4-8 Bullet Points mit "- "). Jeder Punkt fasst einen Kernaspekt in 1-2 Sätzen zusammen
- Falls der bisherige ÜBERBLICK Fliesstext ist, wandle ihn in Bullet Points um
- KEIN Fliesstext vor, zwischen oder nach den Bullet Points. Die ZUSAMMENFASSUNG besteht AUSSCHLIESSLICH aus Bullet Points. Detaillierte Ausführungen gehören in die anderen Sektionen
## AKTEURE
## AKTUELLE LAGE
## EINSCHÄTZUNG
## QUELLENQUALITÄT

REGELN:
- Bisherige gesicherte Fakten beibehalten
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
- Falls das bisherige Briefing Umschreibungen enthält (ae, oe, ue, ss anstelle von ä, ö, ü, ß), ersetze diese beim Aktualisieren durch echte Umlaute. Die Regel "echte UTF-8-Umlaute" hat Vorrang vor der Regel "bestehende Formulierungen beibehalten".
- Neue Erkenntnisse einarbeiten
- Veraltete Informationen aktualisieren
- Wenn eine Quelle eine erkennbare Ausrichtung hat (z.B. pro-russisch, pro-iranisch, staatsnah, rechtsextrem), muss dies im Fliesstext erwaehnt werden, damit der Leser die Information einordnen kann. Beispiel: "Laut dem pro-russischen Telegram-Kanal Rybar..." oder "Die iranische Nachrichtenagentur Fars meldete..." oder "Der rechtsextreme Kanal Compact behauptete..."
- Quellen immer mit [Nr] referenzieren
- Markdown-Überschriften (##) für die Abschnitte verwenden

Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Das aktualisierte Briefing als Markdown-Text mit Quellenverweisen
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
- "key_facts": Array aller gesicherten Kernfakten (in Ausgabesprache)

Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""


LATEST_DEVELOPMENTS_PROMPT_TEMPLATE = """Du erzeugst die Kachel "Neueste Entwicklungen" für eine Live-Monitoring-Lage.
HEUTIGES DATUM: {today}
AUSGABESPRACHE: {output_language}
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

LAGE: {title}
KONTEXT: {description}

AKTUELLES LAGEBILD (autoritative inhaltliche Grundlage):
{summary}

BELEGENDE MELDUNGEN (chronologisch absteigend, neueste zuerst — nur hieraus dürfen Zeitstempel und Quellen-Klammern stammen):
{articles_text}

AUFTRAG:
Extrahiere aus dem LAGEBILD die wichtigsten jüngsten Ereignisse und stelle sie als chronologisch absteigende Bullet-Liste dar. Für jedes Bullet wählst du eine oder mehrere belegende Meldungen aus der obigen Liste und übernimmst deren Publikationsdatum als Zeitstempel.

REGELN zur Auswahl der Bullets:
- Ziel: 4 bis 6 Bullets. Wenn das Lagebild weniger tatsächlich AKTUELLE Ereignisse hergibt, dann lieber 3 ehrliche Bullets als 6 mit veralteten. Kein Auffüllen.
- "AKTUELL" bedeutet: belegende Meldung ist spätestens ~7 Tage alt (relativ zu HEUTIGES DATUM). Ältere Ereignisse — auch wenn sie im Lagebild stehen — gehören NICHT rein. Sie sind Hintergrund, keine Neuesten Entwicklungen.
- Wenn das Lagebild ein Ereignis erwähnt, aber KEINE aktuelle belegende Meldung dafür existiert: Bullet verwerfen. Lieber weglassen als fabulieren.
- Bevorzuge Ereignisse mit hohem Neuigkeitswert und konkretem Vorfall/Aussage gegenüber allgemeinen Hintergrundkonstatierungen.

REGELN zur Formulierung:
- Jedes Bullet = EIN konkretes Ereignis oder eine konkrete Aussage, 1-2 Sätze, präzise und neutral.
- Beginne JEDES Bullet mit dem Zeitstempel der frühesten belegenden Meldung im Format "[DD.MM. HH:MM]".
- Ende JEDES Bullet mit einer Quellen-Klammer mit Pipe-getrennten Paaren "Name|URL", kommagetrennt bei mehreren Belegen: {{Reuters|https://reuters.com/..., Rybar|https://t.me/rybar/123}}. Maximal 3 Quellen pro Bullet. Bullets ohne Klammer werden verworfen.
- Sortiere die Bullets nach Zeitstempel absteigend — neueste zuerst.
- Wenn eine Quelle eine erkennbare politische Ausrichtung hat (pro-russisch, staatsnah, rechtsextrem etc.), im Bullet-Text erwähnen ("laut pro-russischem Telegram-Kanal Rybar...").
- KEINE Gedankenstriche (—, –). Stattdessen Kommas, Doppelpunkte, neue Sätze.
- Bei widersprüchlichen Angaben beide Seiten knapp nennen.
- Korrigierte Zahlen als Verlauf, nicht als Spanne ("Zahl der Toten steigt auf 67" statt "57 bis 67 Tote").
- Wertende Wörter nur, wenn die Meldung genau diese Wertung trägt (nicht "freiwillig zurückgekehrt", wenn die Rückkehr nach behördlicher Aufforderung erfolgte).
- KEINE Einleitung, KEINE Überschrift, KEINE Nachbemerkungen.

OUTPUT-FORMAT (ausschliesslich, kein Code-Fence, JEDE Zeile beginnt mit "- "):
- [DD.MM. HH:MM] Ereignistext. {{Quellenname1|URL1}}
- [DD.MM. HH:MM] Ereignistext mit mehreren Belegen. {{Quellenname1|URL1, Quellenname2|URL2}}
..."""


TOPIC_FILTER_PROMPT_TEMPLATE = """Du bist ein OSINT-Relevanzfilter. Ein vorgeschalteter Keyword-Prefilter hat diese Artikel für eine Lage durchgelassen — aber Keyword-Treffer allein reichen nicht. Artikel müssen das SPEZIFISCHE KERNTHEMA der Lage inhaltlich behandeln.

LAGE: {title}
KONTEXT: {description}

ARTIKEL-KANDIDATEN:
{articles_text}

AUFGABE:
Entscheide je Artikel, ob er thematisch zur Lage passt, und gib die laufenden Nummern der relevanten Artikel zurück.

REGELN:
- Relevant = der Artikel behandelt konkret das im Titel + Kontext beschriebene Kernthema. Zentrale Akteure, Handlungen, Aussagen oder Ereignisse des Themas müssen im Artikel erkennbar sein.
- NICHT relevant = Artikel, die nur allgemeine Begriffe aus dem Thema streifen (z.B. "Russland", "Iran", "Krieg", "Drohne"), ohne das Spezifikum der Lage zu behandeln. Allgemeine Kontext-Berichte aus der gleichen Region oder zum gleichen Großkonflikt sind NICHT automatisch relevant.
- Breit gefasste Lagen (z.B. "Iran-Israel-Krieg", "Ukrainekrieg – aktuelle Lage") akzeptieren alle Meldungen, die einen der direkt beteiligten Akteure oder Kriegsschauplätze behandeln.
- Eng gefasste Lagen (z.B. "Russische Militärblogger", "Ausfall bei Cloudflare", "Cybervorfall Stadtwerke X") akzeptieren NUR Meldungen zum Spezifikum. Peripheres, auch wenn im selben Großkontext, wird abgelehnt.
- 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]}}"""




# Status-Gruppen fuer den Fakten-Kontext im Analyse-Prompt.
# adhoc nutzt confirmed/unconfirmed/contradicted/developing,
# research nutzt established/unverified/disputed/developing — beide Domaenen
# werden in dieselben vier Anzeige-Gruppen abgebildet.
_FACT_STATUS_GROUPS = [
    ("Bestätigt (mehrere unabhängige Quellen oder durch Faktencheck als gesichert eingestuft):",
     {"confirmed", "established"}),
    ("Umstritten (Quellen widersprechen sich oder Faktencheck hat Widersprüche dokumentiert):",
     {"contradicted", "disputed"}),
    ("Widerlegt (ein Beleg entkräftet die Aussage, sie darf im Lagebild nicht als Tatsache stehen):",
     {"false"}),
    ("Unbestätigt (nur eine einzelne Quelle, eine unabhängige Bestätigung steht aus):",
     {"unconfirmed", "unverified"}),
    ("In Entwicklung (laufender Sachverhalt, Stand offen):",
     {"developing"}),
]

_FACT_STATUS_PRIORITY = {
    "confirmed": 5, "established": 5,
    "contradicted": 4, "disputed": 4, "false": 4,
    "unconfirmed": 3, "unverified": 3,
    "developing": 1,
}


def build_fact_context_block(
    existing_facts: list[dict] | None,
    new_or_updated_facts: list[dict] | None,
    incident_type: str,
    max_total: int = 20,
) -> str:
    """Baut den 'GEPRUEFTE FAKTEN'-Block fuer den Analyse-Prompt.

    Wird vom Orchestrator zwischen Faktencheck und Lagebild aufgerufen, damit
    das Lagebild auf gepruefter Faktenbasis schreibt und Unklarheiten explizit
    benennt. Bei leerer Faktenliste wird ein leerer String zurueckgegeben — der
    Prompt laeuft dann ohne Fakten-Kontext (Fallback bei Faktencheck-Fail oder
    bei Lagen ohne bisherige Fakten).
    """
    existing_facts = existing_facts or []
    new_or_updated_facts = new_or_updated_facts or []
    if not existing_facts and not new_or_updated_facts:
        return ""

    seen_claims: set[str] = set()
    merged: list[dict] = []
    # Neue/aktualisierte Fakten zuerst (Status ist aktueller Stand).
    for f in new_or_updated_facts:
        c = (f.get("claim") or "").strip().lower()
        if not c or c in seen_claims:
            continue
        seen_claims.add(c)
        merged.append(f)
    # Dann alte unveraenderte Fakten.
    for f in existing_facts:
        c = (f.get("claim") or "").strip().lower()
        if not c or c in seen_claims:
            continue
        seen_claims.add(c)
        merged.append(f)

    if not merged:
        return ""

    merged.sort(key=lambda f: (
        -_FACT_STATUS_PRIORITY.get((f.get("status") or "").lower(), 0),
        -(f.get("sources_count") or 0),
    ))
    merged = merged[:max_total]

    grouped: dict[str, list[dict]] = {label: [] for label, _ in _FACT_STATUS_GROUPS}
    for f in merged:
        s = (f.get("status") or "").lower()
        for label, codes in _FACT_STATUS_GROUPS:
            if s in codes:
                grouped[label].append(f)
                break

    if not any(grouped.values()):
        return ""

    lines: list[str] = []
    lines.append("GEPRÜFTE FAKTEN (Stand nach dem Faktencheck dieses Refresh, max. {n} priorisiert):".format(n=max_total))
    for label, _codes in _FACT_STATUS_GROUPS:
        items = grouped[label]
        if not items:
            continue
        lines.append("")
        lines.append(label)
        for f in items:
            claim = (f.get("claim") or "").strip()
            sc = f.get("sources_count") or 0
            sc_text = f" ({sc} {'Quellen' if sc != 1 else 'Quelle'})" if sc else ""
            lines.append(f"- {claim}{sc_text}")

    lines.append("")
    lines.append("AUSSAGE-DISZIPLIN für das Lagebild:")
    lines.append("- Bestätigte Fakten als Grundgerüst nehmen, ohne Hedging.")
    lines.append("- Umstrittene Punkte explizit als umstritten kennzeichnen, beide Seiten knapp benennen.")
    lines.append("- Unbestätigtes klar einordnen ('Eine einzelne Quelle berichtet ...', 'Eine unabhängige Bestätigung steht aus.').")
    lines.append("- Bei Aussagen, die durch keinen geprüften Fakt gedeckt sind und auch nicht direkt aus einer der vorliegenden Meldungen hervorgehen: NICHT spekulieren — entweder weglassen oder als unklar kennzeichnen.")
    lines.append("- Triff KEINE Aussagen, die mit den oben gelisteten geprüften Fakten in Widerspruch stehen.")
    lines.append("")
    return "\n".join(lines)


class AnalyzerAgent:
    """Analysiert und übersetzt Meldungen über Claude CLI."""

    def _format_articles_text(self, articles: list[dict], max_articles: int = 30) -> str:
        """Formatiert Artikel als Text für den Prompt."""
        articles_text = ""
        for i, article in enumerate(articles[:max_articles]):
            articles_text += f"\n--- Meldung {i+1} (ID: {article.get('id', 'neu')}) ---\n"
            articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
            url = article.get('source_url', '')
            if url:
                articles_text += f"URL: {url}\n"
            articles_text += f"Sprache: {article.get('language', 'de')}\n"
            bias = article.get('source_bias', '')
            if bias:
                articles_text += f"Einordnung: {bias}\n"
            published = article.get('published_at', '')
            if published:
                articles_text += f"Veröffentlicht: {published}\n"
            headline = article.get('headline_de') or article.get('headline', '')
            articles_text += f"Überschrift: {headline}\n"
            content = article.get('content_de') or article.get('content_original', '')
            if content:
                articles_text += f"Inhalt: {content[:800]}\n"
        return articles_text

    async def analyze(self, title: str, description: str, articles: list[dict], incident_type: str = "adhoc", fact_context_block: str = "", output_language: str = "Deutsch") -> tuple[dict | None, ClaudeUsage | None]:
        """Erstanalyse: Analysiert alle Meldungen zu einem Vorfall (erster Refresh)."""
        if not articles:
            return None, None

        articles_text = self._format_articles_text(articles)

        today = datetime.now(TIMEZONE).strftime("%d.%m.%Y")
        template = BRIEFING_PROMPT_TEMPLATE if incident_type == "research" else ANALYSIS_PROMPT_TEMPLATE
        prompt = template.format(
            title=title,
            description=description or "Keine weiteren Details",
            articles_text=articles_text,
            today=today,
            output_language=output_language,
            fact_context_block=fact_context_block,
        )

        try:
            # EU-Modus (Phase 3): Lagebild rein auf dem Meldungsbestand, über Bedrock.
            # Die EU-Recherche hat unmittelbar davor gesammelt, eigene Websuche
            # braucht die Analyse deshalb nicht.
            from agents.claude_client import _ai_backend_var
            from config import AI_BACKEND
            if (_ai_backend_var.get(None) or AI_BACKEND) == "bedrock":
                from agents.eu_researcher import eu_call_with_search
                result, usage = await eu_call_with_search(prompt, output_language=output_language)
            else:
                result, usage = await call_claude(prompt)
            analysis = self._parse_response(result)
            if analysis:
                analysis = self._sanitize_sources(analysis)
                logger.info(f"Erstanalyse abgeschlossen: {len(analysis.get('sources', []))} Quellen referenziert")
            return analysis, usage
        except Exception as e:
            logger.error(f"Analyse-Fehler: {e}")
            return None, None

    async def analyze_incremental(
        self,
        title: str,
        description: str,
        new_articles: list[dict],
        previous_summary: str,
        previous_sources_json: str | None,
        incident_type: str = "adhoc",
        fact_context_block: str = "",
        output_language: str = "Deutsch",
    ) -> tuple[dict | None, ClaudeUsage | None]:
        """Inkrementelle Analyse: Aktualisiert das Lagebild mit nur den neuen Artikeln.

        Spart erheblich Tokens, da nicht alle Artikel erneut gesendet werden.
        """
        if not new_articles:
            logger.info("Inkrementelle Analyse übersprungen: keine neuen Artikel")
            return None, None

        new_articles_text = self._format_articles_text(new_articles, max_articles=20)

        previous_sources_text = "Keine bisherigen Quellen"
        self._all_previous_sources = []
        if previous_sources_json:
            try:
                self._all_previous_sources = json.loads(previous_sources_json)
                total = len(self._all_previous_sources)
                recent = self._all_previous_sources[-100:] if total > 100 else self._all_previous_sources
                src_lines = []
                if total > 100:
                    highest_nr = self._all_previous_sources[-1].get("nr", "?")
                    src_lines.append(f"(... {total - 100} aeltere Quellen ausgelassen, hoechste Nr: {highest_nr})")
                for s in recent:
                    nr = s.get("nr", "?")
                    name = s.get("name", "?")
                    src_lines.append(f"[{nr}] {name}")
                previous_sources_text = chr(10).join(src_lines)
            except (json.JSONDecodeError, TypeError):
                previous_sources_text = "Fehler beim Laden der bisherigen Quellen"

        today = datetime.now(TIMEZONE).strftime("%d.%m.%Y")

        template = INCREMENTAL_BRIEFING_PROMPT_TEMPLATE if incident_type == "research" else INCREMENTAL_ANALYSIS_PROMPT_TEMPLATE
        prompt = template.format(
            title=title,
            description=description or "Keine weiteren Details",
            previous_summary=previous_summary,
            previous_sources_text=previous_sources_text,
            new_articles_text=new_articles_text,
            today=today,
            output_language=output_language,
            fact_context_block=fact_context_block,
        )

        try:
            # EU-Modus (Phase 3): wie bei der Erstanalyse, werkzeuglos über Bedrock.
            from agents.claude_client import _ai_backend_var
            from config import AI_BACKEND
            if (_ai_backend_var.get(None) or AI_BACKEND) == "bedrock":
                from agents.eu_researcher import eu_call_with_search
                result, usage = await eu_call_with_search(prompt, output_language=output_language)
            else:
                result, usage = await call_claude(prompt)
            analysis = self._parse_response(result)
            if analysis:
                analysis = self._sanitize_sources(analysis)
            if analysis and self._all_previous_sources:
                # Merge: alte Quellen beibehalten, neue hinzufuegen
                returned_sources = analysis.get("sources", [])
                returned_nrs = {s.get("nr") for s in returned_sources}
                merged = [s for s in self._all_previous_sources if s.get("nr") not in returned_nrs]
                merged.extend(returned_sources)
                merged.sort(key=lambda s: s.get("nr", 0) if isinstance(s.get("nr"), int) else 9999)
                analysis["sources"] = merged
                logger.info(
                    f"Inkrementelle Analyse abgeschlossen: {len(new_articles)} neue Artikel, "
                    f"{len(analysis.get('sources', []))} Quellen gesamt (merged)"
                )
            elif analysis:
                logger.info(
                    f"Inkrementelle Analyse abgeschlossen: {len(new_articles)} neue Artikel, "
                    f"{len(analysis.get('sources', []))} Quellen gesamt"
                )
            return analysis, usage
        except Exception as e:
            logger.error(f"Inkrementelle Analyse-Fehler: {e}")
            return None, None

    async def filter_relevant_articles(
        self,
        title: str,
        description: str,
        articles: list[dict],
    ) -> tuple[list[dict], ClaudeUsage | None]:
        """Semantischer Topic-Filter (Haiku).

        Nimmt die vom Keyword-Prefilter durchgelassenen Artikel und wirft diejenigen raus,
        die zwar auf Keywords matchen, aber das Kernthema der Lage thematisch nicht treffen.
        Fällt bei Parsing- oder API-Fehlern auf die unveränderte Liste zurück.
        """
        if not articles:
            return articles, None

        lines = []
        for i, article in enumerate(articles, 1):
            headline = article.get("headline_de") or article.get("headline", "")
            source = article.get("source", "Unbekannt")
            content = article.get("content_de") or article.get("content_original") or ""
            # 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")
            # 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}")
            if content:
                lines.append(f"    Inhalt: {content[:400]}")
            if content_en and content_en.strip().lower() != (content or "")[:len(content_en)].strip().lower():
                lines.append(f"    Inhalt (EN): {content_en[:400]}")
        articles_text = "\n".join(lines)

        prompt = TOPIC_FILTER_PROMPT_TEMPLATE.format(
            title=title,
            description=description or "Keine weiteren Details",
            articles_text=articles_text,
        )

        from config import CLAUDE_MODEL_FAST
        try:
            result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
        except Exception as e:
            logger.warning(f"Topic-Filter-Fehler (behalte alle {len(articles)} Artikel): {e}")
            return articles, None

        parsed = self._parse_response(result)
        if not parsed or not isinstance(parsed.get("relevant_ids"), list):
            logger.warning(
                f"Topic-Filter: keine relevant_ids geparst, behalte alle {len(articles)} Artikel"
            )
            return articles, usage

        relevant_set = {
            i for i in parsed["relevant_ids"]
            if isinstance(i, int) and 1 <= i <= len(articles)
        }
        filtered = [a for i, a in enumerate(articles, 1) if i in relevant_set]

        rejected_articles = [
            (idx, a) for idx, a in enumerate(articles, 1) if idx not in relevant_set
        ]
        rejected = len(rejected_articles)
        if not filtered and articles:
            logger.warning(
                f"Topic-Filter hat ALLE {len(articles)} Artikel verworfen — "
                "möglicherweise zu aggressiv. Behalte Original."
            )
            return articles, usage

        logger.info(
            f"Topic-Filter: {len(filtered)}/{len(articles)} Artikel thematisch relevant "
            f"({rejected} verworfen)"
        )
        for idx, a in rejected_articles:
            src = a.get("source", "Unbekannt")
            hl = (a.get("headline_de") or a.get("headline") or "").strip()
            hl_en = (a.get("headline_en_for_topic") or "").strip()
            if hl_en and hl_en.lower() != hl.lower():
                logger.info("Topic-Filter REJECT [%d] %s | %s | EN: %s", idx, src, hl[:120], hl_en[:120])
            else:
                logger.info("Topic-Filter REJECT [%d] %s | %s", idx, src, hl[:120])
        return filtered, usage

    async def generate_latest_developments(
        self,
        title: str,
        description: str,
        summary: str,
        recent_articles: list[dict],
        previous_developments: str | None = None,
        output_language: str = "Deutsch",
    ) -> tuple[str | None, ClaudeUsage | None]:
        """Generiert die Kachel 'Neueste Entwicklungen' aus dem Lagebild.

        Der LLM extrahiert aus dem Summary die jüngsten Ereignisse und bindet sie an
        das Publikationsdatum der belegenden Meldungen (recent_articles). Damit bleiben
        die Einträge zwingend aktuell und thematisch an das Lagebild gekoppelt. Alte
        Hintergrund-Erwähnungen im Lagebild erzeugen keine Bullets, weil keine aktuelle
        Meldung sie belegen würde.

        Gibt 4–6 Bullets (absteigend nach Zeitstempel) zurück. Bei Fehler/Parsing-Leer:
        Fallback auf previous_developments (falls vorhanden), sonst None.
        """
        prev = (previous_developments or "").strip() or None
        if not summary or not summary.strip():
            return prev, None
        if not recent_articles:
            return prev, None

        from config import CLAUDE_MODEL_FAST
        today = datetime.now(TIMEZONE).strftime("%d.%m.%Y")

        # Kompakter Artikel-Block: nur die für Zeitstempel/Quellen nötigen Felder.
        # Sortiert nach published_at absteigend — damit der LLM die jüngsten sofort sieht.
        def _pub_sort_key(a: dict) -> str:
            return a.get("published_at") or ""

        sorted_articles = sorted(recent_articles, key=_pub_sort_key, reverse=True)
        lines: list[str] = []
        for a in sorted_articles[:60]:
            headline = a.get("headline_de") or a.get("headline", "")
            source = a.get("source", "Unbekannt")
            url = a.get("source_url", "")
            published = a.get("published_at") or "unbekannt"
            bias = a.get("source_bias") or ""
            line = f"- [{published}] {source}"
            if bias:
                line += f" ({bias})"
            line += f" | {headline}"
            if url:
                line += f" | {url}"
            lines.append(line)
        articles_text = "\n".join(lines) if lines else "(keine belegenden Meldungen verfügbar)"

        prompt = LATEST_DEVELOPMENTS_PROMPT_TEMPLATE.format(
            title=title,
            description=description or "Keine weiteren Details",
            summary=summary.strip(),
            articles_text=articles_text,
            today=today,
            output_language=output_language,
        )

        try:
            result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST, raw_text=True)
        except Exception as e:
            logger.error(f"Latest-Developments-Fehler: {e}")
            return prev, None

        bullets = self._parse_latest_developments(result, recent_articles)
        if not bullets:
            logger.info("Latest-Developments: keine Bullets geparst, behalte bisherigen Stand")
            return prev, usage

        bullets = bullets[:6]
        output = "\n".join(bullets)
        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,
        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.

        Jeder Bullet MUSS mit einer Quellen-Klammer enden (geschweifte Klammern).
        Items koennen drei Formen haben, werden alle zu 'Name|URL' normalisiert (URL optional):
        - M<ID>: Aufloesung gegen new_articles, ergibt 'Name|URL'.
        - 'Name|URL': wird uebernommen (Format aus previous_developments).
        - 'Name' (ohne URL): bleibt unveraendert, wird als 'Name' gespeichert (Fallback).

        Bullets ohne Klammer oder mit leerer Klammer werden verworfen.
        Die URL wird direkt dem belegenden Artikel entnommen (article.source_url) — damit
        ist der Klick im Frontend eindeutig auf den belegenden Post, ohne sources_json-Lookup.
        """
        if not text:
            return []

        # Mapping id -> (name, url) aus new_articles
        articles_by_id: dict[str, tuple[str, str]] = {}
        if new_articles:
            for a in new_articles:
                aid = a.get("id")
                if aid is not None:
                    name = (a.get("source") or "").strip()
                    url = (a.get("source_url") or "").strip()
                    if name:
                        articles_by_id[str(aid)] = (name, url)

        bullets: list[str] = []
        # Dash-Praefix + zweiter Datums-Punkt + optionales Jahr: Claude Haiku laesst diese gelegentlich weg.
        bullet_re = re.compile(
            r"^\s*(?:[-*•]\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:\d{2,4})?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$"
        )
        trailing_braces = re.compile(r"\{([^{}]+)\}\s*\.?\s*$")
        id_item = re.compile(r"^[M#]\s*(\d+)$", re.IGNORECASE)
        junk_item = re.compile(r"^(unbekannt|unknown|n/?a|keine|keine quelle|tba)$", re.IGNORECASE)

        def _format_item(name: str, url: str) -> str:
            """Formatiert Name + URL zu 'Name|URL' (oder 'Name' wenn URL leer)."""
            name = (name or "").strip()
            url = (url or "").strip()
            # Pipe im Namen ist extrem unwahrscheinlich, aber sicher ersetzen
            name = name.replace("|", "/")
            return f"{name}|{url}" if url else name

        for raw_line in text.splitlines():
            line = raw_line.strip()
            if not line:
                continue
            m = bullet_re.match(line)
            if not m:
                continue
            day, month, time = m.group(1), m.group(2), m.group(3)
            ts = f"{int(day):02d}.{int(month):02d}. {time}"
            body = m.group(4).rstrip()

            brace_match = trailing_braces.search(body)
            if not brace_match:
                logger.debug(f"Bullet ohne Quellen-Klammer verworfen: {line[:80]}")
                continue

            raw_items = [it.strip() for it in brace_match.group(1).split(",") if it.strip()]
            resolved: list[str] = []
            seen_keys: set[str] = set()

            def _dedupe_key(name: str) -> str:
                return name.strip().lower()

            for it in raw_items:
                if junk_item.match(it):
                    continue
                mid = id_item.match(it)
                if mid:
                    pair = articles_by_id.get(mid.group(1))
                    if pair:
                        name, url = pair
                        key = _dedupe_key(name)
                        if key not in seen_keys:
                            seen_keys.add(key)
                            resolved.append(_format_item(name, url))
                elif "|" in it:
                    # bereits im Name|URL-Format
                    parts = it.split("|", 1)
                    name_p = parts[0].strip()
                    url_p = (parts[1] if len(parts) > 1 else "").strip()
                    if name_p and not junk_item.match(name_p):
                        key = _dedupe_key(name_p)
                        if key not in seen_keys:
                            seen_keys.add(key)
                            resolved.append(_format_item(name_p, url_p))
                else:
                    key = _dedupe_key(it)
                    if key not in seen_keys:
                        seen_keys.add(key)
                        resolved.append(it)

            if not resolved:
                logger.debug(f"Bullet mit leerer/unaufloesbarer Quellen-Klammer verworfen: {line[:80]}")
                continue

            body_clean = body[: brace_match.start()].rstrip()
            bullets.append(f"- [{ts}] {body_clean} {{{', '.join(resolved)}}}")
        return bullets

    def _sanitize_sources(self, analysis: dict) -> dict:
        """Entfernt Buchstaben-Suffixe aus Quellennummern (z.B. '1383a' -> 1383).

        Das LLM erzeugt trotz Anweisung gelegentlich Suffix-Nummern.
        Diese werden hier auf die Basisnummer normalisiert.
        Duplikate werden entfernt, wobei Eintraege mit URL bevorzugt werden.
        """
        sources = analysis.get("sources", [])
        if not sources:
            return analysis

        cleaned = {}
        suffix_count = 0
        for s in sources:
            nr = s.get("nr", "")
            nr_str = str(nr)
            # Prüfe auf Buchstaben-Suffix (z.B. "1383a", "1383b")
            m = re.match(r"^(\d+)[a-z]$", nr_str)
            if m:
                base_nr = int(m.group(1))
                suffix_count += 1
                # Nur übernehmen wenn Basisnummer noch nicht existiert oder
                # dieser Eintrag eine URL hat und der bisherige nicht
                if base_nr not in cleaned:
                    s_copy = dict(s)
                    s_copy["nr"] = base_nr
                    cleaned[base_nr] = s_copy
                elif s.get("url") and not cleaned[base_nr].get("url"):
                    s_copy = dict(s)
                    s_copy["nr"] = base_nr
                    cleaned[base_nr] = s_copy
            else:
                nr_int = int(nr) if isinstance(nr, (int, float)) or (isinstance(nr, str) and nr.isdigit()) else nr
                if nr_int not in cleaned:
                    cleaned[nr_int] = s
                elif s.get("url") and not cleaned[nr_int].get("url"):
                    cleaned[nr_int] = s

        if suffix_count > 0:
            logger.info(f"Quellen-Sanitierung: {suffix_count} Buchstaben-Suffixe entfernt")
            analysis["sources"] = sorted(cleaned.values(),
                key=lambda s: s.get("nr", 0) if isinstance(s.get("nr"), int) else 9999)

        return analysis

    def _parse_response(self, response: str) -> dict | None:
        """Parst die Claude-Antwort als JSON-Objekt mit robustem Fallback."""
        # Markdown-Code-Fences entfernen
        cleaned = response.strip()
        if cleaned.startswith("```"):
            first_nl = cleaned.find(chr(10))
            if first_nl != -1:
                cleaned = cleaned[first_nl + 1:]
        if cleaned.endswith("```"):
            cleaned = cleaned[:-3].rstrip()

        # Versuch 1: Direkt parsen
        try:
            data = json.loads(cleaned)
            if isinstance(data, dict):
                return data
        except json.JSONDecodeError:
            pass

        # Versuch 2: Aeusserstes JSON-Objekt per Regex
        match = re.search(r"\{.*\}", response, re.DOTALL)
        if match:
            try:
                data = json.loads(match.group())
                if isinstance(data, dict):
                    return data
            except json.JSONDecodeError:
                pass

        # Versuch 2b: Nicht maskierte Anfuehrungszeichen im Text reparieren.
        # Haeufigster Bruch (deutsche Zitate mit geradem Schlusszeichen); ohne
        # diesen Schritt rettete der Regex-Fallback unten nur ein Bruchstueck
        # und das Lagebild brach still mitten im Satz ab.
        # Enthaelt die Antwort mehrere Objekte, weil das Modell sich selbst
        # korrigiert hat ("Let me redo this properly with all sections"), zaehlt
        # das ausfuehrlichste. Sonst landete nur die erste, kurze Fassung in der
        # Datenbank und der Bericht verlor fuenf von sechs Abschnitten.
        from json_utils import extract_json_objects as _forgiving_objects
        kandidaten = [o for o in _forgiving_objects(response)
                      if isinstance(o, dict) and "summary" in o]
        if kandidaten:
            bester = max(kandidaten, key=lambda o: len(str(o.get("summary") or "")))
            if len(kandidaten) > 1:
                logger.warning(
                    "Analyse enthielt %d Berichtsfassungen, nehme die ausfuehrlichste "
                    "(%d statt %d Zeichen)",
                    len(kandidaten), len(str(bester.get("summary") or "")),
                    len(str(kandidaten[0].get("summary") or "")),
                )
            return bester

        # Versuch 3: Abgeschnittenes JSON reparieren (haeufig bei langen Antworten)
        candidate = match.group() if match else cleaned
        repaired = self._repair_truncated_json(candidate)
        if repaired:
            return repaired

        # Versuch 4: summary per Regex extrahieren als letzter Fallback
        fallback = self._extract_summary_fallback(response)
        if fallback:
            from json_utils import looks_truncated as _looks_truncated
            gerettet = fallback.get("summary", "")
            if _looks_truncated(gerettet):
                # Sichtbar machen statt still hinnehmen: hier geht Inhalt verloren.
                logger.error(
                    "JSON-Parse fehlgeschlagen und Regex-Fallback liefert ein "
                    "abgeschnittenes Lagebild (%d von vermutlich mehr Zeichen). "
                    "Antwortlaenge %d, Ende: %r",
                    len(gerettet), len(response), gerettet[-80:],
                )
            else:
                logger.warning("JSON-Parse fehlgeschlagen, nutze Regex-Fallback fuer summary")
            return fallback

        logger.error(
            "Konnte Analyse-Antwort nicht als JSON parsen "
            f"(Laenge: {len(response)} Zeichen, Anfang: {response[:200]!r})"
        )
        return None

    def _repair_truncated_json(self, text: str) -> dict | None:
        """Versucht abgeschnittenes JSON zu reparieren (fehlende Klammern am Ende)."""
        start = text.find("{")
        if start == -1:
            return None
        fragment = text[start:]

        open_braces = fragment.count("{") - fragment.count("}")
        open_brackets = fragment.count("[") - fragment.count("]")

        if open_braces <= 0 and open_brackets <= 0:
            return None  # Nicht abgeschnitten

        trimmed = fragment.rstrip()

        # Entferne unvollstaendigen trailing content
        for end_pattern in [
            r'"\s*$',
            r',\s*"[^"]*$',
            r',\s*$',
        ]:
            m = re.search(end_pattern, trimmed)
            if m:
                trimmed = trimmed[:m.start()]
                break

        # Schliesse offene Strukturen
        trimmed = trimmed.rstrip().rstrip(",")
        open_braces = trimmed.count("{") - trimmed.count("}")
        open_brackets = trimmed.count("[") - trimmed.count("]")
        trimmed += "]" * max(0, open_brackets) + "}" * max(0, open_braces)

        try:
            data = json.loads(trimmed)
            if isinstance(data, dict) and "summary" in data:
                logger.info(f"Abgeschnittenes JSON erfolgreich repariert ({len(trimmed)} Zeichen)")
                return data
        except json.JSONDecodeError:
            pass
        return None

    def _extract_summary_fallback(self, response: str) -> dict | None:
        """Extrahiert summary und sources per Regex wenn JSON-Parse komplett fehlschlaegt."""
        m = re.search(r'"summary"\s*:\s*"((?:[^"\\]|\\.)*)"', response, re.DOTALL)
        if not m:
            return None
        summary = m.group(1)
        summary = summary.replace('\\n', chr(10)).replace('\\"', '"').replace('\\\\', '\\')

        sources = []
        sm = re.search(r'"sources"\s*:\s*(\[.*?\])', response, re.DOTALL)
        if sm:
            try:
                sources = json.loads(sm.group(1))
            except json.JSONDecodeError:
                pass

        return {"summary": summary, "sources": sources, "key_facts": []}

