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)