Der EU-Faktencheck bestaetigte praktisch jede Behauptung. Ueber alle Laeufe seit Lage 42 lag der Anteil offener Punkte bei 6 Prozent, beim Weg ueber die Anthropic-CLI bei 31 Prozent. In acht aufeinanderfolgenden Laeufen zur Ceuta-Lage war er exakt null, achtmal zehn Behauptungen, zehnmal bestaetigt. Bei derselben Lage bewerteten beide Wege dieselben Aussagen verschieden. Der Anthropic-Weg stufte "22 EU-Laender kritisieren Spanien" mit sechs Quellen als unbestaetigt ein, der EU-Weg dieselbe Aussage mit vier Quellen als bestaetigt. Mehr Quellen und trotzdem vorsichtiger, das ist kein Zufall, sondern Bauart. Drei Ursachen, drei Aenderungen. 1. Die Belegsuche kannte nur Bestaetigung. Alle Anfragen waren stuetzend formuliert, wer so sucht findet auch nur Stuetzendes. Ein Anteil der Anfragen (EU_VERIFY_COUNTER_SHARE, Vorgabe 0.3) ist jetzt eine Gegenprobe und sucht gezielt nach Dementi, Richtigstellung, abweichenden Angaben und der Darstellung der Gegenseite. Die Treffer sind im Kontext als [GEGENPROBE] gekennzeichnet. 2. Das Modell konnte Belege nicht wirklich pruefen. Die Belegsuche lief mit abgeschaltetem Volltext, das Modell sah je Treffer nur Ueberschrift und Auszug. Ein Artikel zum Thema wurde damit zum Beleg fuer eine konkrete Zahl. Bis zu EU_VERIFY_FULLTEXT_QUERIES Anfragen holen jetzt den Artikeltext. Dazu die Ansage, dass ein Treffer, der nur das Thema erwaehnt, kein Beleg ist, und dass eine Angabe, die praeziser ist als ihre Quelle, nicht traegt. 3. Die zweite Runde lief nur in eine Richtung. Sie belegte offene Punkte nach, und wenn nichts offen war, lief sie gar nicht. Ausgerechnet der Fall "alles bestaetigt" blieb ungeprueft. Jetzt folgt dort eine Belastungsprobe. Sie sucht ausschliesslich Gegenproben zu den staerksten Behauptungen und darf Bestaetigungen zuruecknehmen, aber niemals vergeben. Zurueckgestuft wird bei Widerspruch, bei Dementi, wenn die Belege die konkrete Angabe gar nicht nennen, wenn die Behauptung praeziser ist als ihre Quellen, oder wenn nur eine einzige Stelle berichtet. Ausserdem darf die Nachhak-Runde einen Status jetzt auch absenken, wenn die Nachrecherche der Behauptung widerspricht. Dabei fiel ein Robustheitsmangel auf. Scheiterte die zweite Runde, etwa weil die Suche nicht erreichbar war, ging der gesamte Faktencheck verloren statt nur die Zusatzpruefung. Die zweite Runde ist jetzt gekapselt, das Ergebnis des ersten Durchgangs bleibt in jedem Fall erhalten. Der Weg ueber die Anthropic-CLI bleibt unveraendert. Neu sind 26 Pruefungen, insgesamt laufen 250 ohne Netzzugriff und ohne Kosten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
738 Zeilen
32 KiB
Python
738 Zeilen
32 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 dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
from config import (
|
|
CLAUDE_MODEL_STANDARD,
|
|
EU_RESEARCH_MAX_NEW_PER_ROUND,
|
|
EU_RESEARCH_MAX_ROUNDS_ADHOC,
|
|
EU_RESEARCH_MAX_ROUNDS_RESEARCH,
|
|
EU_VERIFY_COUNTER_SHARE,
|
|
EU_VERIFY_FULLTEXT_CHARS,
|
|
EU_VERIFY_FULLTEXT_QUERIES,
|
|
EU_VERIFY_HITS_PER_QUERY,
|
|
EU_VERIFY_MAX_ITEMS,
|
|
EU_VERIFY_MAX_QUERIES,
|
|
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.""",
|
|
3: """SCHWERPUNKT DIESER RUNDE, GEZIELTE VERTIEFUNG:
|
|
Sieh die bisherigen Treffer durch und frage dich, welche Seite der Lage darin fehlt.
|
|
Typische Lücken bei einem Ereignis mit politischer Wirkung: die Position des eigenen
|
|
Landes und der Nachbarstaaten, die Reaktion der zuständigen Institutionen (EU-Kommission,
|
|
Ministerien, Behörden), die Sicht der Gegenseite, der rechtliche Rahmen, die Ursachen und
|
|
die Vorgeschichte. Suche gezielt nach dem, was fehlt, nicht noch einmal nach dem
|
|
Hauptereignis. Fordere für diese Suchen Volltexte an ("full_content": true).""",
|
|
}
|
|
|
|
_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 _platz_schaffen(collected: dict[str, dict], runde: int) -> bool:
|
|
"""Verdraengt den schwaechsten Treffer einer frueheren Runde. True bei Erfolg.
|
|
|
|
Frueher war die Trefferzahl eine harte Obergrenze: War sie erreicht, brach
|
|
die Suchphase ab. In der Praxis fuellten die ersten beiden Runden den Korb
|
|
mit breiter Erfassung, und die dritte Runde, die gezielt Luecken schliessen
|
|
soll, lief nie. Genau diese Runde bringt aber die Treffer, die im Bericht
|
|
fehlen.
|
|
|
|
Statt abzubrechen macht ein spaeterer, gezielterer Treffer jetzt Platz. Als
|
|
schwaechster gilt ein Treffer ohne Volltext und ohne Textauszug aus der
|
|
fruehesten Runde. Findet sich keiner, bleibt der Korb wie er ist.
|
|
"""
|
|
def guete(eintrag: dict) -> tuple:
|
|
return (
|
|
eintrag.get("_runde", 0),
|
|
1 if eintrag.get("full_text") else 0,
|
|
1 if eintrag.get("snippet") else 0,
|
|
len(eintrag.get("extra_snippets") or []),
|
|
)
|
|
|
|
kandidaten = [(k, v) for k, v in collected.items() if v.get("_runde", 0) < runde]
|
|
if not kandidaten:
|
|
return False
|
|
schwaechster = min(kandidaten, key=lambda kv: guete(kv[1]))
|
|
if schwaechster[1].get("full_text"):
|
|
# Nur noch Treffer mit Volltext uebrig: die sind zu wertvoll zum
|
|
# Verdraengen, der Korb bleibt wie er ist.
|
|
return False
|
|
del collected[schwaechster[0]]
|
|
return True
|
|
|
|
|
|
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
|
|
neu_in_runde = 0
|
|
for q in action["queries"]:
|
|
if neu_in_runde >= EU_RESEARCH_MAX_NEW_PER_ROUND:
|
|
logger.info(
|
|
"EU-Recherche: Rundenkontingent %d erreicht, Rest der Runde uebersprungen",
|
|
EU_RESEARCH_MAX_NEW_PER_ROUND,
|
|
)
|
|
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 neu_in_runde >= EU_RESEARCH_MAX_NEW_PER_ROUND:
|
|
break
|
|
if len(collected) >= MAX_TOTAL_RESULTS and not _platz_schaffen(collected, round_no):
|
|
break
|
|
r["_runde"] = round_no
|
|
collected[key] = r
|
|
new_count += 1
|
|
neu_in_runde += 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
|
|
|
|
# Bringt eine ganze Runde keinen einzigen neuen Treffer, ist das Thema
|
|
# ausgeschoepft. Weitere Runden wuerden nur das teuerste Modell
|
|
# befragen, ohne dass danach etwas Neues dazukaeme.
|
|
if neu_in_runde == 0:
|
|
logger.info(
|
|
"EU-Recherche: Suchphase nach Runde %d beendet, keine neuen Treffer mehr",
|
|
round_no,
|
|
)
|
|
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
|
|
|
|
|
|
# --- Phase 3: Stützsuche für Faktencheck und Lagebild --------------------------
|
|
# Faktenchecker und Analyzer verlangen in ihren Prompts die eingebaute Websuche
|
|
# (WebSearch/WebFetch), die es auf Bedrock nicht gibt. Im EU-Modus ersetzt
|
|
# eu_call_with_search diese Aufrufe. Optional läuft vorab eine gezielte
|
|
# staan-Stützsuche (Haiku plant Verifikations-Queries), deren Treffer als
|
|
# Kontextblock in den Prompt wandern, danach geht der Aufruf werkzeuglos über
|
|
# Bedrock. Der CLI-Modus bleibt in den Agenten unverändert.
|
|
|
|
_EU_TOOL_HINT = """
|
|
|
|
HINWEIS ZUR WEBSUCHE (EU-Modus):
|
|
WebSearch und WebFetch stehen NICHT zur Verfügung. Ignoriere alle Anweisungen oben,
|
|
diese Werkzeuge zu benutzen.{search_note} Behauptungen, die sich so nicht belegen
|
|
lassen, bekommen den entsprechenden Unbestätigt-Status."""
|
|
|
|
_SEARCH_NOTE_WITH_RESULTS = (
|
|
" Nutze stattdessen die unten angehängten ERGEBNISSE DER EUROPÄISCHEN WEBSUCHE "
|
|
"als unabhängige Zweitquellen und zitiere sie als Evidenz, wenn sie eine "
|
|
"Behauptung stützen oder widerlegen. Zusätzlich zählen die übergebenen Meldungen."
|
|
" WICHTIG: Nenne bei jedem Beleg, auf den du dich stützt, immer die vollständige "
|
|
"Adresse (die URL hinter der [S..]-Nummer), nicht nur die Nummer. Ohne Adresse "
|
|
"gilt ein Beleg als nicht nachprüfbar."
|
|
" EBENSO WICHTIG, so prüfst du einen Beleg: Ein Treffer, der nur das Thema "
|
|
"erwähnt, ist KEIN Beleg. Die konkrete Aussage mit ihrer Zahl, ihrem Namen oder "
|
|
"ihrem Datum muss im Titel, im Auszug oder im Artikeltext tatsächlich vorkommen. "
|
|
"Steht dort nur, dass es die Lage gibt, oder ist die Angabe vager als deine "
|
|
"Behauptung, dann trägt dieser Treffer sie nicht. Zähle ihn dann nicht mit und "
|
|
"sage das in der Begründung ehrlich. Lieber ein unbestätigter Punkt mit klarer "
|
|
"Begründung als eine Bestätigung, die einer Nachprüfung nicht standhält."
|
|
)
|
|
_SEARCH_NOTE_NO_RESULTS = (
|
|
" Stütze dich ausschließlich auf die übergebenen Meldungen und Quellen."
|
|
)
|
|
|
|
_VERIFY_QUERY_PROMPT = """Du planst Verifikations-Suchanfragen für einen OSINT-Faktencheck.
|
|
Heute ist der {today}. Lage: {title}
|
|
|
|
ZU PRÜFENDE PUNKTE (nummeriert):
|
|
{items_block}
|
|
|
|
AUFGABE:
|
|
Erzeuge bis zu {max_queries} Websuche-Anfragen. Plane sie GEZIELT je Punkt, nicht
|
|
pauschal für alle zusammen. Eine gute Anfrage macht genau einen dieser Punkte
|
|
überprüfbar, mit seinen konkreten Zahlen, Namen und Orten.
|
|
|
|
Davon sind {counter_queries} Anfragen ausdrücklich GEGENPROBEN. Eine Gegenprobe sucht
|
|
nicht nach Bestätigung, sondern nach allem, was der Behauptung widerspricht. Also nach
|
|
Dementis, Richtigstellungen, abweichenden Zahlen anderer Stellen, nach der Darstellung
|
|
der Gegenseite und nach Berichten, die den Vorgang anders einordnen. Wer nur nach
|
|
Bestätigung sucht, findet auch nur Bestätigung, und der Faktencheck wird wertlos.
|
|
Beispiele für Gegenproben: "Behörde dementiert Angaben zu X", "X Zahlen umstritten
|
|
Kritik", "X widerspricht Darstellung". Markiere sie mit "gegenprobe": true.
|
|
|
|
REGELN:
|
|
- Ordne jeder Anfrage über "for_items" zu, welche Punkte sie prüfen soll.
|
|
- Decke möglichst viele verschiedene Punkte ab, statt denselben mehrfach zu suchen.
|
|
- Priorisiere Punkte mit harten, überprüfbaren Angaben (Zahlen, Daten, Zitate, Beschlüsse).
|
|
- Wähle für die Gegenproben die Punkte aus, bei denen ein Irrtum am schwersten wiegt,
|
|
also Opferzahlen, Zuschreibungen von Verantwortung, Beschlüsse und Zitate.
|
|
- Setze "full_content": true bei den Anfragen, deren Punkt sich nur im Artikeltext
|
|
wirklich prüfen lässt, höchstens bei {fulltext_queries} Anfragen. Bei diesen bekommst
|
|
du den Artikeltext statt nur der Überschrift.
|
|
- Nutze die Sprache, in der die Berichterstattung zu erwarten ist. Für Ereignisse in
|
|
einem Land sind Quellen in dessen Landessprache oft ergiebiger.
|
|
- Keine Jahreszahlen anhängen, keine Wiederholung derselben Anfrage.
|
|
{avoid_block}
|
|
Antworte NUR mit JSON:
|
|
{{"queries": [{{"q": "suchbegriffe", "market": "{default_market}", "for_items": [1, 2],
|
|
"gegenprobe": false, "full_content": false}}]}}
|
|
"market" ist "de-de", "en-us" oder "fr-fr". Fremdsprachige Anfragen funktionieren mit "en-us"."""
|
|
|
|
|
|
def _iso_from_display(output_language: str) -> str:
|
|
"""Anzeige-Sprachname der Agenten ('Deutsch') auf ISO für die Marktwahl."""
|
|
mapping = {"deutsch": "de", "english": "en", "french": "fr"}
|
|
return mapping.get((output_language or "").strip().lower(), "en")
|
|
|
|
|
|
def _add_usage(total: ClaudeUsage, u: ClaudeUsage | None) -> None:
|
|
"""Zaehlt einen Einzelverbrauch auf die Gesamtsumme."""
|
|
if not u:
|
|
return
|
|
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
|
|
|
|
|
|
@dataclass
|
|
class Belegsuche:
|
|
"""Ergebnis einer gezielten Belegsuche.
|
|
|
|
block = fertiger Kontextblock fuer den Auftrag, leer wenn nichts gefunden
|
|
usage = Verbrauch aus Planung und Suche
|
|
queries = tatsaechlich ausgefuehrte Suchanfragen, fuer die Nachrunde
|
|
quellen = gefundene Quellen einzeln, fuer die Quellenzuordnung im Faktencheck
|
|
"""
|
|
block: str
|
|
usage: ClaudeUsage
|
|
queries: list[str]
|
|
quellen: list[dict]
|
|
|
|
|
|
async def plan_verification_searches(
|
|
*,
|
|
title: str,
|
|
search_items: list[str],
|
|
output_language: str = "Deutsch",
|
|
max_queries: int = EU_VERIFY_MAX_QUERIES,
|
|
avoid_queries: list[str] | None = None,
|
|
label: str = "Stützsuche",
|
|
nur_gegenproben: bool = False,
|
|
) -> Belegsuche:
|
|
"""Plant gezielte Belegsuchen zu den uebergebenen Punkten und fuehrt sie aus.
|
|
|
|
Die Planung erfolgt punktbezogen, das planende Modell ordnet jeder Anfrage
|
|
zu, welche Punkte sie belegen soll. avoid_queries verhindert, dass eine
|
|
Nachrunde dieselben Anfragen noch einmal stellt.
|
|
|
|
Gibt eine Belegsuche zurueck. Der Kontextblock ist leer, wenn nichts
|
|
gefunden wurde. Die gefundenen Quellen werden zusaetzlich einzeln
|
|
zurueckgegeben, damit die Quellenzuordnung im Faktencheck sie kennt.
|
|
"""
|
|
from config import CLAUDE_MODEL_FAST
|
|
|
|
total = ClaudeUsage()
|
|
items = [str(s).strip() for s in (search_items or []) if str(s).strip()][:EU_VERIFY_MAX_ITEMS]
|
|
if not items:
|
|
return Belegsuche("", total, [], [])
|
|
|
|
default_market = market_for_language(_iso_from_display(output_language))
|
|
avoid_block = ""
|
|
if avoid_queries:
|
|
avoid_block = (
|
|
"- Diese Anfragen wurden bereits gestellt und brachten nicht genug, stelle ANDERE:\n"
|
|
+ "\n".join(f" {q[:120]}" for q in avoid_queries[:10]) + "\n"
|
|
)
|
|
|
|
# Ein fester Anteil der Anfragen sucht nach Widerspruch statt nach
|
|
# Bestaetigung. Ohne das sieht der Faktencheck nur stuetzendes Material und
|
|
# bestaetigt praktisch alles.
|
|
if nur_gegenproben:
|
|
anzahl_gegenproben = max_queries
|
|
else:
|
|
anzahl_gegenproben = max(1, round(max_queries * EU_VERIFY_COUNTER_SHARE)) if max_queries >= 3 else 0
|
|
anzahl_volltexte = min(EU_VERIFY_FULLTEXT_QUERIES, max_queries)
|
|
|
|
plan_prompt = _VERIFY_QUERY_PROMPT.format(
|
|
today=datetime.now(TIMEZONE).strftime("%d.%m.%Y"),
|
|
title=title or "(ohne Titel)",
|
|
items_block="\n".join(f"{i}. {s[:220]}" for i, s in enumerate(items, 1)),
|
|
max_queries=max_queries,
|
|
counter_queries=anzahl_gegenproben,
|
|
fulltext_queries=anzahl_volltexte,
|
|
default_market=default_market,
|
|
avoid_block=avoid_block,
|
|
)
|
|
|
|
try:
|
|
plan_text, plan_usage = await call_bedrock(plan_prompt, model=CLAUDE_MODEL_FAST, timeout=120)
|
|
_add_usage(total, plan_usage)
|
|
except Exception as e:
|
|
logger.warning("EU-%s: Planung der Anfragen fehlgeschlagen (%s), fahre ohne Suche fort", label, e)
|
|
return Belegsuche("", total, [], [])
|
|
|
|
from agents.researcher import _extract_json_object
|
|
obj = _extract_json_object(plan_text or "")
|
|
queries: list[dict] = []
|
|
gesehen: set[str] = {(q or "").strip().lower() for q in (avoid_queries or [])}
|
|
for q in (obj or {}).get("queries", []) or []:
|
|
if not isinstance(q, dict):
|
|
continue
|
|
text = str(q.get("q", "")).strip()[:400]
|
|
if not text or text.lower() in gesehen:
|
|
continue
|
|
gesehen.add(text.lower())
|
|
queries.append({
|
|
"q": text,
|
|
"market": str(q.get("market", "")).strip().lower(),
|
|
"gegenprobe": nur_gegenproben or bool(q.get("gegenprobe")),
|
|
"full_content": bool(q.get("full_content")),
|
|
})
|
|
if len(queries) >= max_queries:
|
|
break
|
|
|
|
if not queries:
|
|
logger.warning("EU-%s: Planung lieferte keine brauchbaren Anfragen", label)
|
|
return Belegsuche("", total, [], [])
|
|
|
|
lines: list[str] = []
|
|
ausgefuehrt: list[str] = []
|
|
quellen: list[dict] = []
|
|
gesehene_urls: set[str] = set()
|
|
treffer = 0
|
|
volltexte_genutzt = 0
|
|
gegenproben_gelaufen = 0
|
|
for q in queries:
|
|
will_volltext = q["full_content"] and volltexte_genutzt < anzahl_volltexte
|
|
try:
|
|
res = await staan_search(
|
|
q["q"],
|
|
market=q["market"] if q["market"] in ("de-de", "en-us", "fr-fr") else default_market,
|
|
extra_snippets=True, max_snippets=3, full_content=will_volltext,
|
|
)
|
|
ausgefuehrt.append(q["q"])
|
|
if will_volltext:
|
|
volltexte_genutzt += 1
|
|
if q["gegenprobe"]:
|
|
gegenproben_gelaufen += 1
|
|
except StaanError as e:
|
|
logger.warning("EU-%s: Suche fehlgeschlagen (%s)", label, e)
|
|
continue
|
|
for r in res[:EU_VERIFY_HITS_PER_QUERY]:
|
|
# Mehrere Anfragen finden oft dieselbe Seite, jede Quelle nur einmal
|
|
# in den Kontextblock legen.
|
|
url = (r.get("url") or "").strip()
|
|
if url and url in gesehene_urls:
|
|
continue
|
|
if url:
|
|
gesehene_urls.add(url)
|
|
treffer += 1
|
|
# Die Quelle wird zusaetzlich einzeln gefuehrt, damit die
|
|
# Quellenzuordnung im Faktencheck sie wie eine Meldung behandeln
|
|
# kann. Ohne das gelten Fakten als unbelegt, die sich allein auf
|
|
# diese Belege stuetzen.
|
|
quellen.append({
|
|
"headline": r["title"] or "",
|
|
"source": r["hostname"] or "",
|
|
"source_url": url,
|
|
})
|
|
marke = " [GEGENPROBE]" if q["gegenprobe"] else ""
|
|
lines.append(
|
|
f"[S{treffer}]{marke} {r['title'] or '(ohne Titel)'} | {r['hostname']} | {r['url']}")
|
|
if r.get("snippet"):
|
|
lines.append(f" Auszug: {r['snippet'][:300]}")
|
|
for chunk in (r.get("extra_snippets") or [])[:1]:
|
|
lines.append(f" Auszug: {chunk[:300]}")
|
|
# Volltext, damit das Modell pruefen kann, ob die Quelle die
|
|
# Behauptung wirklich traegt, statt aus der Ueberschrift zu schliessen.
|
|
volltext = (r.get("full_text") or "").strip()
|
|
if volltext:
|
|
lines.append(f" Artikeltext: {volltext[:EU_VERIFY_FULLTEXT_CHARS]}")
|
|
|
|
total.cost_usd += len(ausgefuehrt) * STAAN_COST_PER_QUERY_USD
|
|
logger.info(
|
|
"EU-%s: %d Suchen (davon %d Gegenproben, %d mit Artikeltext), %d Treffer im Kontextblock",
|
|
label, len(ausgefuehrt), gegenproben_gelaufen, volltexte_genutzt, treffer)
|
|
|
|
if not lines:
|
|
return Belegsuche("", total, ausgefuehrt, [])
|
|
|
|
kopf = (
|
|
"\n\nERGEBNISSE DER EUROPÄISCHEN WEBSUCHE "
|
|
"(von unserem System ausgeführt, als unabhängige Zweitquellen nutzbar).\n"
|
|
)
|
|
if gegenproben_gelaufen:
|
|
kopf += (
|
|
"Ein Teil dieser Suchen war eine GEGENPROBE, sie suchte gezielt nach "
|
|
"Widerspruch, Dementi oder abweichenden Angaben. Diese Treffer sind mit "
|
|
"[GEGENPROBE] gekennzeichnet. Findet sich dort nichts Widersprechendes, "
|
|
"stützt das die Behauptung zusätzlich. Findet sich etwas, muss es in die "
|
|
"Bewertung einfließen.\n"
|
|
)
|
|
block = kopf + "\n".join(lines)
|
|
return Belegsuche(block, total, ausgefuehrt, quellen)
|
|
|
|
|
|
def build_eu_prompt(prompt: str, results_block: str = "") -> str:
|
|
"""Haengt den Werkzeug-Hinweis und einen etwaigen Belegblock an."""
|
|
hint = _EU_TOOL_HINT.format(
|
|
search_note=_SEARCH_NOTE_WITH_RESULTS if results_block else _SEARCH_NOTE_NO_RESULTS
|
|
)
|
|
return prompt + hint + results_block
|
|
|
|
|
|
async def eu_call_with_search(
|
|
prompt: str,
|
|
*,
|
|
title: str = "",
|
|
search_items: list[str] | None = None,
|
|
output_language: str = "Deutsch",
|
|
max_queries: int = EU_VERIFY_MAX_QUERIES,
|
|
) -> tuple[str, ClaudeUsage]:
|
|
"""EU-Ersatz für call_claude mit Default-Tools (Faktencheck/Lagebild).
|
|
|
|
Mit search_items läuft vorab die gezielte staan-Belegsuche, ohne wird nur
|
|
der Werkzeug-Hinweis angehängt. Der Modellaufruf geht werkzeuglos über
|
|
call_claude und damit (aktives Bedrock-Backend vorausgesetzt) über
|
|
Frankfurt. Gibt (result_text, gesamt_usage) zurück."""
|
|
from agents.claude_client import call_claude
|
|
|
|
total = ClaudeUsage()
|
|
results_block = ""
|
|
if search_items:
|
|
suche = await plan_verification_searches(
|
|
title=title, search_items=search_items,
|
|
output_language=output_language, max_queries=max_queries,
|
|
)
|
|
results_block = suche.block
|
|
_add_usage(total, suche.usage)
|
|
|
|
result, usage = await call_claude(build_eu_prompt(prompt, results_block), tools=None)
|
|
_add_usage(total, usage)
|
|
return result, total
|