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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-08-02 01:15:32 +02:00
Ursprung 9868a9748a
Commit e2638ce9d5
4 geänderte Dateien mit 139 neuen und 21 gelöschten Zeilen

Datei anzeigen

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