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>
364 Zeilen
15 KiB
Python
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
|