Dateien
AegisSight-Monitor/src/agents/eu_researcher.py
claude-dev a7f1aa29ab feat(eu-umbau): staan-Recherche-Schleife und Doppelspur (Phase 2)
Im EU-Modus laeuft die Recherche jetzt europaeisch: agents/eu_researcher.py
steuert eine Schleife aus Bedrock-Planung (Opus, EU-Profile) und staan-Suchen
(services/staan_client.py, Suche + Volltexte via full_content=markdown,
Pflicht-Domain-Ausschlussliste je Anfrage). Bildet die 4-Phasen-Tiefenrecherche
nach und liefert exakt das JSON der bisherigen CLI-WebSearch, Parsing und
Filter in researcher.search bleiben unveraendert. Anti-Halluzination: nur URLs
aus echten staan-Treffern werden akzeptiert; published_at nur wenn aus Inhalt
oder URL ableitbar (staan liefert keine Daten).

Doppelspur im Orchestrator: Lane-Schluessel ist jetzt (Organisation, Backend),
Aufloesung zentral in _resolve_ai_backend (Lage > Org-Setting > ENV). CLI- und
EU-Lauf derselben Organisation laufen gleichzeitig, gleiche Backends seriell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 20:12:31 +00:00

364 Zeilen
15 KiB
Python

"""EU-Recherche-Schleife. Gesteuerte Websuche über Bedrock plus staan (Phase 2).
Ersetzt für den EU-Modellweg die eingebaute WebSearch-Recherche des Claude CLI.
Das Modell (Opus über Bedrock, EU-Profile Frankfurt) schlägt je Runde
Suchanfragen vor, unser Code fragt staan.ai, liefert Treffer und Volltexte
zurück, und das Modell entscheidet, ob es nachhaken will oder genug hat.
Am Ende produziert die Schleife exakt den JSON-Array-Text, den der heutige
WebSearch-Researcher liefert, sodass Parsing, Quellenfilter und die gesamte
nachgelagerte Pipeline unverändert weiterlaufen (Einstieg in
researcher.ResearcherAgent.search).
Nachgebildet wird auch die vierstufige Tiefenrecherche der Recherche-Lagen
(breite Erfassung, Lückenanalyse, gezielte institutionelle Suche, Vertiefung
per Volltext). Zwei harte Zusagen dieser Schleife gegen Halluzinationen und
für die Aktualität. Erstens akzeptiert die Endauswertung nur URLs, die
wirklich in den staan-Treffern vorkamen (alles andere wird verworfen und
geloggt). Zweitens wird published_at nur gesetzt, wenn das Datum aus Inhalt
oder URL ableitbar ist, staan selbst liefert keine Datumsangaben.
"""
import asyncio
import json
import logging
from datetime import datetime
from config import (
CLAUDE_MODEL_STANDARD,
EU_RESEARCH_MAX_ROUNDS_ADHOC,
EU_RESEARCH_MAX_ROUNDS_RESEARCH,
STAAN_COST_PER_QUERY_USD,
TIMEZONE,
)
from agents.claude_client import ClaudeUsage, _cancel_event_var
from agents.bedrock_client import call_bedrock
from services.staan_client import staan_search, market_for_language, StaanError
logger = logging.getLogger("osint.eu_researcher")
MAX_QUERIES_PER_ROUND = 4
MAX_FULLTEXT_QUERIES_PER_ROUND = 2
MAX_TOTAL_RESULTS = 60
# Kappung der Textmengen im Abschluss-Prompt (Kosten- und Kontextschutz)
FINAL_FULLTEXT_CHARS = 2500
FINAL_SNIPPET_CHARS = 400
ROUND_SNIPPET_CHARS = 250
_ROUND_HEADER = """Du steuerst eine OSINT-Recherche über eine europäische Such-API.
Du kannst NICHT selbst suchen. Du schlägst Suchanfragen vor, unser System führt sie aus
und zeigt dir die Treffer. Es ist Runde {round_no} von maximal {max_rounds}.
Heute ist der {today}. Formuliere Suchanfragen so, dass sie AKTUELLE Berichterstattung
finden (konkrete Ereignisse, Akteure, Ortsnamen; keine Jahreszahlen anhängen).
AUFTRAG:
Titel: {title}
Kontext: {description}
{existing_context}{preferred_sources_block}
SPRACHREGELN:
{lang_instruction}
{phase_guidance}
BISHER GESAMMELTE TREFFER ({n_results} Stück):
{results_block}
VERLAUF DEINER BISHERIGEN SUCHEN:
{rounds_log}
Antworte NUR mit einem JSON-Objekt in genau diesem Format:
{{"action": "search", "queries": [{{"q": "suchbegriffe", "market": "de-de", "full_content": false}}], "reason": "ein Satz"}}
oder, wenn die Treffer für ein vollständiges Bild reichen:
{{"action": "finish", "reason": "ein Satz"}}
REGELN FÜR QUERIES:
- Maximal {max_queries} Queries je Runde, jede maximal 400 Zeichen.
- "market" ist "de-de", "en-us" oder "fr-fr" (Standard für diese Lage: "{default_market}").
Fremdsprachige Suchanfragen (z.B. Russisch, Arabisch, Farsi) funktionieren mit "en-us".
- "full_content": true holt die kompletten Artikeltexte der Treffer dieser Query
(maximal {max_fulltext} Queries je Runde). Nutze das für die wichtigsten Suchen,
deren Artikel du zusammenfassen willst.
- Wiederhole keine Query aus dem Verlauf wortgleich."""
_PHASE_GUIDANCE_RESEARCH = {
1: """PHASE 1, BREITE ERFASSUNG:
Suche nach aktueller Berichterstattung bei Nachrichtenagenturen, Qualitätszeitungen und
öffentlich-rechtlichen Medien. Nutze verschiedene Suchbegriffe und Blickwinkel.""",
2: """PHASE 2 UND 3, LÜCKENANALYSE UND GEZIELTE TIEFENRECHERCHE:
Prüfe die bisherigen Treffer kritisch. Welche Quellentypen fehlen? Typisch fehlen
Parlamentsdokumente, Behörden-Pressemitteilungen, NGO- und UN-Berichte (ohchr.org,
amnesty.org, hrw.org), Think-Tank-Analysen (IISS, Brookings, SWP, DGAP, Chatham House),
investigative Langform-Berichte und Fachmedien. Suche GEZIELT nach diesen Lücken,
auch mit site:-Operatoren für institutionelle Quellen.""",
3: """PHASE 4, VERIFIKATION UND VERTIEFUNG:
Fordere für die wichtigsten Suchanfragen jetzt Volltexte an ("full_content": true),
damit die Artikel ausführlich zusammengefasst werden können. Priorisiere Primärquellen
und investigative Berichte. Ergänze nur noch gezielt, was für ein vollständiges Bild fehlt.""",
}
_PHASE_GUIDANCE_ADHOC = {
1: """SCHWERPUNKT DIESER RUNDE:
Aktuelle Berichterstattung zur Lage bei seriösen Nachrichtenquellen (Agenturen,
Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden). Verschiedene Blickwinkel.""",
2: """SCHWERPUNKT DIESER RUNDE:
Lücken schließen und vertiefen. Fordere für die wichtigsten Suchanfragen Volltexte an
("full_content": true), damit die Artikel fundiert zusammengefasst werden können.""",
}
_FINAL_PROMPT = """Du bist ein OSINT-Recherche-Agent. Die Recherche über die europäische Such-API ist
abgeschlossen. Unten stehen ALLE gesammelten Treffer mit Textauszügen. Heute ist der {today}.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche verwenden, stattdessen Kommas oder neue Sätze.
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß), NIEMALS Umschreibungen.
AUFTRAG WAR:
Titel: {title}
Kontext: {description}
WÄHLE aus den Treffern die {target} relevantesten, inhaltlich substanziellen Artikel aus.
Bevorzuge Vielfalt der Quellen und Blickwinkel. Lass Übersichtsseiten, reine Linklisten
und thematisch unpassende Treffer weg.
GESAMMELTE TREFFER:
{results_block}
Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Text davor oder danach.
Jedes Element hat diese Felder:
- "headline": Originale Überschrift (aus dem Treffer)
- "headline_de": Übersetzung in die Ausgabesprache (falls Originalsprache abweicht)
- "source": Name der Quelle (z.B. "Reuters", "tagesschau")
- "source_url": Die EXAKTE URL aus dem Treffer. NIEMALS eine URL verändern oder erfinden.
- "content_summary": Zusammenfassung des Inhalts ({summary_len}, in Ausgabesprache, auf Basis der Textauszüge)
- "language": Sprache des Originals (z.B. "de", "en", "fa")
- "published_at": Veröffentlichungsdatum im ISO-Format, NUR wenn es aus Textauszug oder URL
eindeutig hervorgeht (z.B. Datumsangabe im Text oder /2026/07/ im Pfad), sonst null.
Antworte NUR mit dem JSON-Array."""
def _norm_url(u: str) -> str:
return (u or "").strip().rstrip("/").lower()
def _results_block(collected: dict[str, dict], with_texts: bool) -> str:
"""Nummerierte Trefferliste für die Prompts. Kompakt in Zwischenrunden,
mit Textauszügen im Abschluss-Prompt."""
if not collected:
return "(noch keine)"
lines = []
for i, (url, r) in enumerate(collected.items(), 1):
lines.append(f"{i}. {r['title'] or '(ohne Titel)'} | {r['hostname']}\n URL: {url}")
if with_texts:
if r.get("snippet"):
lines.append(f" Auszug: {r['snippet'][:FINAL_SNIPPET_CHARS]}")
for chunk in (r.get("extra_snippets") or [])[:2]:
lines.append(f" Auszug: {chunk[:FINAL_SNIPPET_CHARS]}")
if r.get("full_text"):
lines.append(f" Volltext (gekürzt): {r['full_text'][:FINAL_FULLTEXT_CHARS]}")
else:
if r.get("snippet"):
lines.append(f" {r['snippet'][:ROUND_SNIPPET_CHARS]}")
return "\n".join(lines)
def _parse_action(text: str) -> dict:
"""Aktions-JSON des Modells robust parsen. Fallback = finish."""
from agents.researcher import _extract_json_object
obj = None
try:
parsed = json.loads((text or "").strip())
if isinstance(parsed, dict):
obj = parsed
except (json.JSONDecodeError, ValueError):
pass
if obj is None:
obj = _extract_json_object(text or "")
if not isinstance(obj, dict) or obj.get("action") not in ("search", "finish"):
logger.warning("EU-Recherche: Aktions-JSON nicht parsebar, beende Suchphase. Sample: %r", (text or "")[:200])
return {"action": "finish", "queries": []}
queries = []
for q in obj.get("queries") or []:
if isinstance(q, dict) and str(q.get("q", "")).strip():
queries.append({
"q": str(q["q"]).strip()[:400],
"market": str(q.get("market", "")).strip().lower(),
"full_content": bool(q.get("full_content")),
})
obj["queries"] = queries[:MAX_QUERIES_PER_ROUND]
return obj
async def run_eu_research(
*,
title: str,
description: str,
incident_type: str,
lang_instruction: str,
existing_context: str,
preferred_sources_block: str,
output_language: str,
excluded_sources: list[str],
research_language_iso: str,
) -> tuple[str, ClaudeUsage]:
"""Führt die komplette EU-Recherche aus. Gibt (json_array_text, usage) zurück.
Der Rückgabetext wird vom bestehenden ResearcherAgent._parse_response
weiterverarbeitet, der Vertrag entspricht damit exakt dem CLI-Weg.
"""
total = ClaudeUsage()
def _add(u: ClaudeUsage):
total.input_tokens += u.input_tokens
total.output_tokens += u.output_tokens
total.cache_creation_tokens += u.cache_creation_tokens
total.cache_read_tokens += u.cache_read_tokens
total.cost_usd += u.cost_usd
total.duration_ms += u.duration_ms
is_research = incident_type == "research"
max_rounds = EU_RESEARCH_MAX_ROUNDS_RESEARCH if is_research else EU_RESEARCH_MAX_ROUNDS_ADHOC
guidance_map = _PHASE_GUIDANCE_RESEARCH if is_research else _PHASE_GUIDANCE_ADHOC
today = datetime.now(TIMEZONE).strftime("%d.%m.%Y")
default_market = market_for_language(research_language_iso)
# Nutzer-Ausschlüsse an staan weiterreichen (die Pflichtliste belegt die
# ersten Plätze, siehe staan_client). Zusätzlich filtert der Aufrufer
# (ResearcherAgent.search) das Endergebnis erneut gegen die volle Liste.
extra_exclude = [d for d in (excluded_sources or []) if d and "." in d][:6]
collected: dict[str, dict] = {}
rounds_log: list[str] = []
searches_done = 0
for round_no in range(1, max_rounds + 1):
cancel = _cancel_event_var.get(None)
if cancel and cancel.is_set():
raise asyncio.CancelledError("Cancel angefordert")
guidance = guidance_map.get(min(round_no, max(guidance_map.keys())))
prompt = _ROUND_HEADER.format(
round_no=round_no,
max_rounds=max_rounds,
today=today,
title=title,
description=description or "Keine weitere Beschreibung",
existing_context=existing_context or "",
preferred_sources_block=preferred_sources_block or "",
lang_instruction=lang_instruction,
phase_guidance=guidance,
n_results=len(collected),
results_block=_results_block(collected, with_texts=False),
rounds_log="\n".join(rounds_log) or "(noch keine)",
max_queries=MAX_QUERIES_PER_ROUND,
max_fulltext=MAX_FULLTEXT_QUERIES_PER_ROUND,
default_market=default_market,
)
text, usage = await call_bedrock(prompt, model=CLAUDE_MODEL_STANDARD, timeout=420)
_add(usage)
action = _parse_action(text)
if action["action"] == "finish":
if round_no == 1 and not collected:
logger.warning("EU-Recherche: Modell wollte ohne eine einzige Suche beenden")
else:
logger.info("EU-Recherche: Suchphase nach Runde %d beendet (%s)", round_no - 1, action.get("reason", ""))
break
fulltext_used = 0
for q in action["queries"]:
if len(collected) >= MAX_TOTAL_RESULTS:
logger.info("EU-Recherche: Treffer-Obergrenze %d erreicht", MAX_TOTAL_RESULTS)
break
want_full = q["full_content"] and fulltext_used < MAX_FULLTEXT_QUERIES_PER_ROUND
if want_full:
fulltext_used += 1
market = q["market"] if q["market"] else default_market
try:
results = await staan_search(
q["q"], market=market, extra_snippets=True, max_snippets=4,
full_content=want_full, extra_exclude=extra_exclude,
)
searches_done += 1
except StaanError as e:
logger.warning("EU-Recherche: Suche fehlgeschlagen (%s), überspringe Query", e)
rounds_log.append(f"Runde {round_no}: '{q['q'][:70]}' -> FEHLER")
continue
new_count = 0
for r in results:
if not r["url"]:
continue
key = r["url"]
if key in collected:
# Volltext/Snippets aus späteren Suchen ergänzen
if r.get("full_text") and not collected[key].get("full_text"):
collected[key]["full_text"] = r["full_text"]
continue
if len(collected) >= MAX_TOTAL_RESULTS:
break
collected[key] = r
new_count += 1
rounds_log.append(
f"Runde {round_no}: '{q['q'][:70]}' ({market}{', Volltexte' if want_full else ''}) -> {new_count} neue Treffer"
)
if not action["queries"]:
break
total.cost_usd += searches_done * STAAN_COST_PER_QUERY_USD
if not collected:
logger.warning("EU-Recherche: keine Treffer gesammelt (%d Suchen)", searches_done)
return "[]", total
target = "15 bis 25" if is_research else "8 bis 15"
summary_len = "5 bis 8 Sätze" if is_research else "3 bis 5 Sätze"
final_prompt = _FINAL_PROMPT.format(
today=today,
output_language=output_language,
title=title,
description=description or "Keine weitere Beschreibung",
target=target,
summary_len=summary_len,
results_block=_results_block(collected, with_texts=True),
)
text, usage = await call_bedrock(final_prompt, model=CLAUDE_MODEL_STANDARD, timeout=600)
_add(usage)
from agents.researcher import _extract_json_array
arr = None
try:
parsed = json.loads((text or "").strip())
if isinstance(parsed, list):
arr = parsed
except (json.JSONDecodeError, ValueError):
pass
if arr is None:
arr = _extract_json_array(text or "")
if not isinstance(arr, list):
# Unparsebarer Abschluss: Rohtext zurückgeben, damit der bestehende
# Recovery-Pfad in _parse_response greift und die Usage verbucht wird.
logger.warning("EU-Recherche: Abschluss-JSON nicht parsebar, reiche Rohtext weiter")
return text or "[]", total
# Anti-Halluzination. Nur URLs akzeptieren, die wirklich in den staan-Treffern
# vorkamen. published_at, das nicht wie ein Datum aussieht, wird verworfen.
valid = {_norm_url(u) for u in collected}
validated = []
dropped = 0
for item in arr:
if not isinstance(item, dict):
continue
if _norm_url(item.get("source_url", "")) not in valid:
dropped += 1
continue
pub = item.get("published_at")
if pub is not None and not isinstance(pub, str):
item["published_at"] = None
validated.append(item)
if dropped:
logger.warning("EU-Recherche: %d Artikel mit nicht belegten URLs verworfen", dropped)
logger.info(
"EU-Recherche fertig: %d Artikel aus %d Treffern, %d Suchen, %d Modellrunden, $%.4f",
len(validated), len(collected), searches_done, round_no + 1, total.cost_usd,
)
return json.dumps(validated, ensure_ascii=False), total