Dateien
AegisSight-Monitor/src/agents/eu_researcher.py
claude-dev d948e3a7f4 feat(eu): Faktencheck weist Unsicherheit aus statt alles zu bestaetigen
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>
2026-08-02 11:10:43 +00:00

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