"""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