Bisher haben translations als Teil der Analyzer-JSON-Antwort gelebt
("translations": [...]). Bei vielen Artikeln pro Refresh hat das LLM die
Translations regelmaessig weggelassen (Output-Token-Druck), insbesondere
content_de (lange Texte werden zuerst gestrichen). Folge: viele englische
Artikel ohne deutsche Headline/Inhalt im Frontend.
Aenderungen:
- Neuer Agent src/agents/translator.py:
* translate_articles_batch / translate_articles
* Nutzt CLAUDE_MODEL_FAST (Haiku) - billig
* Batch-Size 5 (mit Reserve gegen Output-Truncate)
* Robustes JSON-Parsing: Markdown-Codefence, Truncate-Fallback,
extrahiert auch unvollstaendige Antworten
* Idempotent: Caller filtert auf fehlende headline_de/content_de
- analyzer.py: translations aus 4 Prompt-Templates entfernt (adhoc/research
x analyze/enhance) und Fallback-Return-Dict bereinigt -> Analyzer-Output
wird kompakter und zuverlaessiger
- orchestrator.py:
* Alter Translation-INSERT-Block entfernt (analysis.translations wird
nicht mehr genutzt)
* Nach Analyse + db.commit + cancel-check neuer Translator-Call:
SELECT WHERE language!=de AND (headline_de OR content_de fehlt),
translate_articles, normalize_german_umlauts, COALESCE-UPDATE
* Vor post_refresh_qc -> normalize_umlaut_articles greift auch frische
Uebersetzungen
* Failure-tolerant: Translator-Fehler bricht Refresh nicht ab
Backfill: migrations/migrate_translations_2026-05-03.py im Verwaltungs-Repo.
797 Zeilen
38 KiB
Python
797 Zeilen
38 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 (##)
|
|
- 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
|
|
- 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}
|
|
|
|
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
|
|
- 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}
|
|
|
|
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
|
|
- 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.
|
|
|
|
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
|
|
- 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}
|
|
|
|
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.
|
|
- 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.
|
|
- Im Zweifel: NICHT relevant. Ein zu schmaler Filter ist besser als ein Schwall off-topic-Treffer.
|
|
|
|
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
|
|
{{"relevant_ids": [1, 3, 7]}}"""
|
|
|
|
|
|
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
|
|
|
|
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 ""
|
|
lines.append(f"[{i}] Quelle: {source}")
|
|
lines.append(f" Überschrift: {headline}")
|
|
if content:
|
|
lines.append(f" Inhalt: {content[: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 = len(articles) - len(filtered)
|
|
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)"
|
|
)
|
|
return filtered, usage
|
|
|
|
async def generate_latest_developments(
|
|
self,
|
|
title: str,
|
|
description: str,
|
|
summary: str,
|
|
recent_articles: list[dict],
|
|
previous_developments: str | None = None,
|
|
) -> 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 OUTPUT_LANGUAGE, 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
|
|
|
|
@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 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": []}
|
|
|