"""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