Regel in alle 4 Analyse-Prompt-Templates eingefuegt: Claude soll keine Gedankenstriche verwenden, sondern Kommas, Doppelpunkte oder neue Saetze. Bestehende Dashes in DB (Summary + 76 Snapshots) bereinigt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
334 Zeilen
14 KiB
Python
334 Zeilen
14 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
|
|
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
|
- 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
|
|
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
|
- 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
|
|
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
|
- 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
|
|
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze
|
|
- 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
|