Dateien
AegisSight-Monitor/src/agents/analyzer.py
claude-dev 6b11d643b9 Fix: Analyse-Parser erkennt jetzt Markdown-Code-Fences
Claude-Antworten mit ```json ... ``` Wrapping werden korrekt geparst.
Verhindert den Verlust von Analyse-Ergebnissen bei inkrementellen Refreshes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:55:08 +01:00

330 Zeilen
13 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} (max. 500 Wörter)
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
- Nur gesicherte Informationen in die Zusammenfassung
- Bei widersprüchlichen Angaben beide Seiten erwähnen
- Quellen immer mit [Nr] referenzieren
- Jede verwendete Quelle MUSS im sources-Array aufgelistet sein
- Nummeriere die Quellen fortlaufend ab [1]
- Ä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 (max. 800 Wörter) auf {output_language} mit folgenden Abschnitten.
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
- Nur gesicherte Informationen verwenden
- Bei widersprüchlichen Angaben beide Seiten erwähnen
- Quellen immer mit [Nr] referenzieren
- Jede verwendete Quelle MUSS im sources-Array aufgelistet sein
- Nummeriere die Quellen fortlaufend ab [1]
- Ä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 (max. 500 Wörter)
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 veraltete oder widerlegte Informationen
STRUKTUR:
- Fließtext oder mit Markdown-Zwischenüberschriften (##) — je nach Komplexität
- KEIN Fettdruck (**) verwenden
REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen
- Bei widersprüchlichen Angaben beide Seiten erwähnen
- Quellen immer mit [Nr] referenzieren
- Das sources-Array muss ALLE Quellen enthalten (bisherige + neue)
- Ältere Quellen zeitlich einordnen
Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
- "sources": VOLLSTÄNDIGES Array aller Quellen (alte + neue), je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "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 (max. 800 Wörter) mit den neuen Erkenntnissen. Behalte die Struktur bei:
## ÜBERBLICK
## HINTERGRUND
## AKTEURE
## AKTUELLE LAGE
## EINSCHÄTZUNG
## QUELLENQUALITÄT
REGELN:
- Bisherige gesicherte Fakten beibehalten
- Neue Erkenntnisse einarbeiten
- Veraltete Informationen aktualisieren
- Quellen immer mit [Nr] referenzieren
- Das sources-Array muss ALLE Quellen enthalten (bisherige + neue)
- 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": VOLLSTÄNDIGES Array aller Quellen (alte + neue), je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
- "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"
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:
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"
if previous_sources_json:
try:
sources = json.loads(previous_sources_json)
lines = []
for s in sources:
lines.append(f"[{s.get('nr', '?')}] {s.get('name', '?')}{s.get('url', '?')}")
previous_sources_text = "\n".join(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:
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 _parse_response(self, response: str) -> dict | None:
"""Parst die Claude-Antwort als JSON-Objekt."""
# Markdown-Code-Fences entfernen
cleaned = response.strip()
if cleaned.startswith("```"):
# Erste Zeile (```json oder ```) entfernen
first_nl = cleaned.find(chr(10))
if first_nl != -1:
cleaned = cleaned[first_nl + 1:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3].rstrip()
try:
data = json.loads(cleaned)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
pass
# Fallback: aeusserstes JSON-Objekt per Regex finden
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
logger.warning("Konnte Analyse-Antwort nicht als JSON parsen")
return None