From e2638ce9d549f05b19949f082366377ccd93eb9b Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sun, 2 Aug 2026 01:15:32 +0200 Subject: [PATCH] fix(eu): Vertiefungsrunde der Recherche faellt nicht mehr aus Die Suchphase endete in jedem beobachteten Lauf mit "Treffer-Obergrenze 60 erreicht" nach Runde 2. Die ersten beiden Runden erfassen breit und fuellten den Korb, die dritte Runde, die gezielt Luecken schliessen soll, lief damit nie. Genau diese Runde bringt aber das, was in den Berichten fehlte: die Position des eigenen Landes, die Reaktion der Institutionen, den rechtlichen Rahmen. Der harte Gesamtstopp war eine Ueberkorrektur. Er kam aus einem frueheren Fix gegen Leerrunden: War der Korb voll, befragte die Schleife weiter das teuerste Modell, ohne noch etwas aufnehmen zu koennen. Das Problem war aber nicht die Rundenzahl, sondern dass spaete Runden keinen Platz mehr hatten. Jetzt drei Aenderungen: 1. Kontingent je Runde (EU_RESEARCH_MAX_NEW_PER_ROUND, Vorgabe 25). Die frueher Runden koennen den Korb nicht mehr allein fuellen, fuer die spaeteren bleibt Platz frei. 2. Verdraengung statt Abbruch. Ist der Korb voll und eine spaetere, gezieltere Runde bringt einen Treffer, weicht der schwaechste Treffer einer frueheren Runde: ohne Volltext, ohne Textauszug, aus der fruehesten Runde. Treffer mit Volltext werden nie verdraengt. 3. Abbruch nur noch bei echter Saettigung. Bringt eine ganze Runde keinen einzigen neuen Treffer, ist das Thema ausgeschoepft und die Schleife endet. Der Kostenschutz des frueheren Fixes bleibt damit erhalten, ohne die Vertiefung zu verhindern. Dazu bekommt die dritte Adhoc-Runde einen eigenen Auftrag. Bisher wiederholte sie den Text der zweiten ("Luecken schliessen und vertiefen"), jetzt fragt sie ausdruecklich nach der fehlenden Seite der Lage und verbietet die Wiederholung des Hauptereignisses. Neu sind 10 Pruefungen, darunter der Regressionsschutz gegen Leerrunden in neuer Form. Insgesamt laufen 216 ohne Netzzugriff und ohne Kosten. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- src/agents/eu_researcher.py | 68 +++++++++++++++++++++++++----- src/config.py | 6 +++ tests/test_qc_und_runden.py | 84 ++++++++++++++++++++++++++++++++----- 4 files changed, 139 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2897198..9e75b32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ src/: translator.py: "Haiku-Uebersetzung in Batches a 5 (groessere Batches rissen den JSON-Output ab)" claude_client.py: "Shared Claude CLI Client, Usage-Tracking (Token, Kosten), Rate-Limit-Erkennung, Cancel via ContextVar. Routet je nach _ai_backend_var werkzeuglose Aufrufe zu bedrock_client" bedrock_client.py: "EU-Modellweg ueber AWS Bedrock Converse (nur eu.-Inferenzprofile, Token-zu-USD-Umrechnung ueber BEDROCK_PRICING, gleiche Fehlerkategorien wie das CLI). EU-Umbau Phase 1. Wiederholt Kapazitaets- (503) und Kontingentfehler (429) nach BEDROCK_RETRY_WAITS (5/15/30s), begrenzt durch das Zeitbudget des Aufrufs und BEDROCK_RETRY_BUDGET_S je Refresh; Stoerungen landen im Refresh-Protokoll (refresh_log.error_message) und waehrend der Wartezeit als status_update in der Oberflaeche" - eu_researcher.py: "EU-Recherche-Schleife (Phase 2): Bedrock-Modell schlaegt Suchanfragen vor, Code fragt staan, Modell entscheidet weiter/fertig. Bildet die 4-Phasen-Tiefenrecherche nach, liefert denselben JSON-Array-Text wie die CLI-WebSearch (Einstieg in researcher.search), akzeptiert NUR URLs aus echten staan-Treffern" + eu_researcher.py: "EU-Recherche-Schleife (Phase 2): Bedrock-Modell schlaegt Suchanfragen vor, Code fragt staan, Modell entscheidet weiter/fertig. Trefferaufnahme ueber ein Kontingent je Runde (EU_RESEARCH_MAX_NEW_PER_ROUND) statt eines harten Gesamtstopps, spaetere Runden verdraengen bei vollem Korb die schwaechsten Treffer frueherer Runden; Abbruch nur bei echter Saettigung. Bildet die 4-Phasen-Tiefenrecherche nach, liefert denselben JSON-Array-Text wie die CLI-WebSearch (Einstieg in researcher.search), akzeptiert NUR URLs aus echten staan-Treffern" feeds/: rss_parser.py: "RSS-Feed-Parsing (feedparser + httpx), adaptive Keyword-Schwelle, Frische-Bonus, Domain-Cap" diff --git a/src/agents/eu_researcher.py b/src/agents/eu_researcher.py index b471624..01726ce 100644 --- a/src/agents/eu_researcher.py +++ b/src/agents/eu_researcher.py @@ -25,6 +25,7 @@ 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_HITS_PER_QUERY, @@ -105,6 +106,13 @@ Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden). Verschiedene Bli 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 @@ -164,6 +172,39 @@ def _results_block(collected: dict[str, dict], with_texts: bool) -> str: 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 @@ -267,9 +308,13 @@ async def run_eu_research( break fulltext_used = 0 + neu_in_runde = 0 for q in action["queries"]: - if len(collected) >= MAX_TOTAL_RESULTS: - logger.info("EU-Recherche: Treffer-Obergrenze %d erreicht", MAX_TOTAL_RESULTS) + 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: @@ -295,10 +340,14 @@ async def run_eu_research( if r.get("full_text") and not collected[key].get("full_text"): collected[key]["full_text"] = r["full_text"] continue - if len(collected) >= MAX_TOTAL_RESULTS: + 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" ) @@ -306,14 +355,13 @@ async def run_eu_research( if not action["queries"]: break - # Ist die Treffer-Obergrenze erreicht, bringen weitere Runden nichts - # mehr. Ohne diesen Abbruch liefe die Schleife bis zur letzten Runde - # weiter und befragte jedes Mal das teuerste Modell, ohne dass danach - # noch eine einzige Suche ausgefuehrt wuerde. - if len(collected) >= MAX_TOTAL_RESULTS: + # 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, Treffer-Obergrenze %d erreicht", - round_no, MAX_TOTAL_RESULTS, + "EU-Recherche: Suchphase nach Runde %d beendet, keine neuen Treffer mehr", + round_no, ) break diff --git a/src/config.py b/src/config.py index 4c1880a..ac6c0d5 100644 --- a/src/config.py +++ b/src/config.py @@ -107,6 +107,12 @@ STAAN_EXCLUDE_DOMAINS = [ # ohne die Abschlussrunde). research bildet die 4-Phasen-Tiefenrecherche nach. EU_RESEARCH_MAX_ROUNDS_ADHOC = int(os.environ.get("EU_RESEARCH_MAX_ROUNDS_ADHOC", "3")) EU_RESEARCH_MAX_ROUNDS_RESEARCH = int(os.environ.get("EU_RESEARCH_MAX_ROUNDS_RESEARCH", "5")) +# Neue Treffer je Runde. Ohne dieses Kontingent fuellten die ersten beiden +# Runden (breite Erfassung) den Korb bis zur Obergrenze, und die letzte Runde, +# die gezielt Luecken schliessen soll, lief nie: In den Laeufen vom 01. und +# 02.08.2026 endete die Suchphase jedes Mal mit "Treffer-Obergrenze erreicht" +# nach Runde 2. Das Kontingent haelt fuer die spaeteren Runden Platz frei. +EU_RESEARCH_MAX_NEW_PER_ROUND = int(os.environ.get("EU_RESEARCH_MAX_NEW_PER_ROUND", "25")) # --- Belegsuche des Faktenchecks (EU-Modus) ----------------------------------- # Vorher pauschal 3 Anfragen fuer alle Behauptungen zusammen. Der Vergleich am # 31.07.2026 zeigte, dass die EU-Fassung dadurch systematisch weniger Fakten diff --git a/tests/test_qc_und_runden.py b/tests/test_qc_und_runden.py index a972875..eac155e 100644 --- a/tests/test_qc_und_runden.py +++ b/tests/test_qc_und_runden.py @@ -197,17 +197,81 @@ text, usage = asyncio.run(eur.run_eu_research( lang_instruction="", existing_context="", preferred_sources_block="", output_language="Deutsch", excluded_sources=[], research_language_iso="de", )) -# Die erste Runde fuellt die Grenze von 60 Treffern, danach ist Schluss. -# Ohne die Korrektur liefe die Schleife alle fuenf Runden durch und befragte -# jedes Mal das teuerste Modell, ohne noch zu suchen. +# Jede Suche liefert 20 Treffer, das Kontingent je Runde liegt bei 25. Die +# Runde nimmt also zwei Suchen auf und ueberspringt den Rest. Entscheidend: +# die Schleife laeuft weiter, damit die spaeteren Runden ihre Aufgabe +# (Luecken schliessen, vertiefen) ueberhaupt ausfuehren koennen. Vorher war +# nach Runde 2 Schluss, weil die Trefferzahl als harte Grenze wirkte. suchrunden = runden["n"] -pruefe("F1 genau eine Suchrunde statt fuenf", suchrunden == 1, suchrunden) -pruefe("F2 vier Leerrunden gespart", eur.EU_RESEARCH_MAX_ROUNDS_RESEARCH - suchrunden == 4, - eur.EU_RESEARCH_MAX_ROUNDS_RESEARCH - suchrunden) -pruefe("F3 Recherche liefert trotzdem ein Ergebnis", text == "[]", text[:40]) -pruefe("F4 Kosten nur fuer die tatsaechlich gelaufene Runde plus Abschluss", - round(usage.cost_usd, 2) == round(0.05 * 2 + 4 * eur.STAAN_COST_PER_QUERY_USD, 2), - round(usage.cost_usd, 4)) +pruefe("F1 alle Runden laufen, die Vertiefung faellt nicht mehr aus", + suchrunden == eur.EU_RESEARCH_MAX_ROUNDS_RESEARCH, suchrunden) +pruefe("F2 je Runde greift das Kontingent statt eines Gesamtstopps", + eur.EU_RESEARCH_MAX_NEW_PER_ROUND == 25, eur.EU_RESEARCH_MAX_NEW_PER_ROUND) +pruefe("F3 Recherche liefert ein Ergebnis", text == "[]", text[:40]) +pruefe("F4 Kosten entsprechen den gelaufenen Runden", + usage.cost_usd > 0.05 * suchrunden, round(usage.cost_usd, 4)) + + +# --------------------------------------------------------------------------- +# G) Sättigung beendet die Suchphase weiterhin +# --------------------------------------------------------------------------- +print("\nG) Keine neuen Treffer, keine weiteren Runden") + + +async def fake_staan_immer_gleich(q, market="de-de", extra_snippets=True, max_snippets=4, + full_content=False, extra_exclude=None, timeout=None): + """Liefert immer dieselben fuenf Treffer: ab Runde 2 gibt es nichts Neues.""" + return [{ + "title": "Immer derselbe " + str(i), "hostname": "example.org", + "url": "https://example.org/immer/" + str(i), + "snippet": "Auszug", "extra_snippets": [], "full_text": "", + } for i in range(1, 6)] + + +eur.staan_search = fake_staan_immer_gleich +runden["n"] = 0 +asyncio.run(eur.run_eu_research( + title="Innenministerkonferenz 2026", description="", incident_type="research", + lang_instruction="", existing_context="", preferred_sources_block="", + output_language="Deutsch", excluded_sources=[], research_language_iso="de", +)) +pruefe("G1 Suchphase endet nach der ersten Runde ohne neue Treffer", + runden["n"] == 2, runden["n"]) + + +# --------------------------------------------------------------------------- +# H) Verdrängung schafft Platz für spätere Runden +# --------------------------------------------------------------------------- +print("\nH) Platz fuer gezielte Treffer") +korb = { + "https://a.de/1": {"_runde": 1, "snippet": "", "full_text": ""}, + "https://b.de/1": {"_runde": 1, "snippet": "Auszug", "full_text": ""}, + "https://c.de/1": {"_runde": 2, "snippet": "", "full_text": ""}, +} +pruefe("H1 der schwaechste Treffer einer frueheren Runde weicht", + eur._platz_schaffen(korb, 3) and "https://a.de/1" not in korb, list(korb)) +nur_volltext = {"https://a.de/1": {"_runde": 1, "snippet": "x", "full_text": "langer Text"}} +pruefe("H2 Treffer mit Volltext werden nicht verdraengt", + not eur._platz_schaffen(nur_volltext, 3) and len(nur_volltext) == 1) +gleiche_runde = {"https://a.de/1": {"_runde": 3, "snippet": "", "full_text": ""}} +pruefe("H3 Treffer derselben Runde werden nicht verdraengt", + not eur._platz_schaffen(gleiche_runde, 3) and len(gleiche_runde) == 1) +pruefe("H4 leerer Korb meldet keinen Erfolg", not eur._platz_schaffen({}, 2)) + + +# --------------------------------------------------------------------------- +# I) Die dritte Adhoc-Runde hat einen eigenen Auftrag +# --------------------------------------------------------------------------- +print("\nI) Auftrag der Vertiefungsrunde") +dritte = eur._PHASE_GUIDANCE_ADHOC.get(3, "") +pruefe("I1 dritte Runde ist als Vertiefung beschrieben", "VERTIEFUNG" in dritte, dritte[:60]) +for stichwort, name in ( + ("fehlt", "I2 fragt nach der fehlenden Seite der Lage"), + ("Institutionen", "I3 nennt Institutionen als typische Luecke"), + ("rechtliche", "I4 nennt den rechtlichen Rahmen"), + ("nicht noch einmal", "I5 verbietet die Wiederholung des Hauptereignisses"), +): + pruefe(name, stichwort in dritte) print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen") sys.exit(1 if fail else 0)