Dateien
AegisSight-Monitor/src/agents/analyzer.py
Claude Dev f60edb42f7 Fix broken source links caused by LLM-generated letter suffixes (e.g. 1383a)
The LLM occasionally generates source references with letter suffixes
(e.g. [1383a], [1396b]) despite being instructed not to. This caused
broken links because the sources array only contained integer nr values.

Backend: Add _sanitize_sources() to strip letter suffixes after parsing
and deduplicate, preferring entries with valid URLs.

Frontend: Add fallback in citation renderer - when a suffix reference
like [1383a] has no matching source with URL, fall back to the base
number [1383].

Also cleaned up 99 broken suffix entries and 44 suffix references in
the Irankonflikt incident (ID 6) database records.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:47:02 +01:00

482 Zeilen
22 KiB
Python

"""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}
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 (##)
- Die Entscheidung liegt bei dir — Überschriften nur wenn sie dem Leser helfen, verschiedene Themenstränge auseinanderzuhalten
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
- 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)
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
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}
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.
## ÜBERBLICK
Kurze Einordnung des Themas (2-3 Sätze)
## 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
- 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)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
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}
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. Ergänze neue Erkenntnisse und markiere wichtige neue Entwicklungen
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
- KEIN Fettdruck (**) verwenden
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
- 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)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
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}
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:
## ÜBERBLICK
## HINTERGRUND
## AKTEURE
## AKTUELLE LAGE
## EINSCHÄTZUNG
## QUELLENQUALITÄT
REGELN:
- Bisherige gesicherte Fakten beibehalten
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
- 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)
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
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") -> 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)
from config import OUTPUT_LANGUAGE
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,
)
try:
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",
) -> 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"
from config import OUTPUT_LANGUAGE
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,
)
try:
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
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 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:
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": [], "translations": []}