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>
278 Zeilen
12 KiB
Python
278 Zeilen
12 KiB
Python
"""Testet die Absicherung der Duplikatpruefung und den Abbruch der Suchrunden.
|
|
|
|
Laeuft ohne Netzzugriff und ohne Kosten, Datenbank, Modell und Suche sind
|
|
durch Testdoubles ersetzt.
|
|
|
|
Aufruf aus dem Projektstamm:
|
|
venv/bin/python tests/test_qc_und_runden.py
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
|
|
|
import services.post_refresh_qc as qc # noqa: E402
|
|
import agents.eu_researcher as eur # noqa: E402
|
|
from agents.claude_client import ClaudeUsage # noqa: E402
|
|
|
|
ok = 0
|
|
fail = 0
|
|
|
|
|
|
def pruefe(name, bedingung, extra=""):
|
|
global ok, fail
|
|
if bedingung:
|
|
ok += 1
|
|
print(" OK " + name)
|
|
else:
|
|
fail += 1
|
|
print(" FEHL " + name + " " + str(extra))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Testdouble fuer die Datenbank
|
|
# ---------------------------------------------------------------------------
|
|
class FakeCursor:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
async def fetchall(self):
|
|
return self._rows
|
|
|
|
async def fetchone(self):
|
|
return self._rows[0] if self._rows else None
|
|
|
|
|
|
class FakeDB:
|
|
"""Liefert eine feste Faktenliste und merkt sich die Loeschauftraege."""
|
|
|
|
def __init__(self, fakten):
|
|
self.fakten = fakten
|
|
self.geloescht = []
|
|
|
|
async def execute(self, sql, params=()):
|
|
if sql.strip().upper().startswith("DELETE"):
|
|
self.geloescht.extend(params)
|
|
return FakeCursor([])
|
|
return FakeCursor(list(self.fakten))
|
|
|
|
|
|
def fakt(fid, claim, status="established", quellen=3):
|
|
return {"id": fid, "claim": claim, "status": status, "sources_count": quellen,
|
|
"evidence": "e", "checked_at": "2026-08-01 10:00:00"}
|
|
|
|
|
|
FAHRPLAN = "Die IMK beschloss einen gemeinsamen Bund-Laender-Fahrplan zum Aufbau der zivilen Verteidigungsfaehigkeit bis 2029"
|
|
FAHRPLAN_ANDERS = "Die IMK beschloss einen Bund-Laender-Fahrplan fuer die zivile Verteidigung bis zum Jahr 2029"
|
|
BUNDESWEHR = "Die IMK beschloss die institutionelle Einbindung der Bundeswehr in die Innenministerkonferenz"
|
|
SOZIAL = "Die IMK beschloss Massnahmen zur haerteren Bekaempfung von organisiertem Sozialmissbrauch"
|
|
MILLIARDEN = "Die IMK forderte zusaetzliche Milliardeninvestitionen des Bundes in den Zivilschutz"
|
|
ABSCHIEBUNG = "Die Innenminister waren sich einig, dass der Bund bei Abschiebungen mehr leisten muss"
|
|
GDP = "Die Gewerkschaft der Polizei bewertete die Beschluesse als grundsaetzlich richtig"
|
|
SYRER = "In der Frage der Rueckfuehrung von Syrern besteht ein Konflikt zwischen SPD und CDU"
|
|
IMK_DATUM = "Die 225. Innenministerkonferenz fand im Juni 2026 in Hamburg statt"
|
|
BESCHLUESSE = "Die IMK fasste insgesamt 66 Beschluesse"
|
|
|
|
geplante_cluster = []
|
|
|
|
|
|
async def fake_cluster(facts, incident_title):
|
|
return geplante_cluster
|
|
|
|
|
|
qc._haiku_find_duplicate_clusters = fake_cluster
|
|
|
|
|
|
def lauf_qc(fakten, cluster):
|
|
global geplante_cluster
|
|
geplante_cluster = cluster
|
|
db = FakeDB(fakten)
|
|
entfernt = asyncio.run(qc.check_fact_duplicates(db, 49, "Innenministerkonferenz 2026"))
|
|
return entfernt, db.geloescht
|
|
|
|
|
|
print("\nA) Der Fall aus Lage 49, sieben verschiedene Fakten in einer Gruppe")
|
|
zehn = [
|
|
fakt(469, FAHRPLAN), fakt(468, IMK_DATUM), fakt(470, BUNDESWEHR),
|
|
fakt(471, SOZIAL), fakt(472, MILLIARDEN), fakt(473, ABSCHIEBUNG),
|
|
fakt(474, SYRER, "disputed"), fakt(475, BESCHLUESSE), fakt(476, GDP),
|
|
fakt(477, "Die IMK befasste sich mit der Umsetzung des europaeischen Asylsystems"),
|
|
]
|
|
entfernt, geloescht = lauf_qc(zehn, [[468, 469, 470, 471, 472, 473, 475, 476]])
|
|
pruefe("A1 unplausible Gruppe wird komplett verworfen", entfernt == 0, entfernt)
|
|
pruefe("A2 kein Fakt geloescht", geloescht == [], geloescht)
|
|
|
|
print("\nB) Echte Dubletten werden weiterhin entfernt")
|
|
entfernt, geloescht = lauf_qc(
|
|
[fakt(1, FAHRPLAN, quellen=8), fakt(2, FAHRPLAN_ANDERS, quellen=2),
|
|
fakt(3, BUNDESWEHR), fakt(4, SOZIAL), fakt(5, MILLIARDEN)],
|
|
[[1, 2]])
|
|
pruefe("B1 echtes Duplikat entfernt", entfernt == 1, entfernt)
|
|
pruefe("B2 der besser belegte Fakt bleibt", geloescht == [2], geloescht)
|
|
|
|
print("\nC) Kleine Gruppe mit unaehnlichen Fakten wird nicht geloescht")
|
|
entfernt, geloescht = lauf_qc(
|
|
[fakt(1, FAHRPLAN), fakt(2, SOZIAL), fakt(3, BUNDESWEHR), fakt(4, MILLIARDEN),
|
|
fakt(5, ABSCHIEBUNG), fakt(6, GDP)],
|
|
[[1, 2]])
|
|
pruefe("C1 unaehnlicher Fakt bleibt erhalten", entfernt == 0, entfernt)
|
|
|
|
print("\nD) Gemischte Gruppe, nur der wirklich aehnliche Fakt faellt weg")
|
|
entfernt, geloescht = lauf_qc(
|
|
[fakt(1, FAHRPLAN, quellen=9), fakt(2, FAHRPLAN_ANDERS, quellen=1), fakt(3, SOZIAL),
|
|
fakt(4, BUNDESWEHR), fakt(5, MILLIARDEN), fakt(6, ABSCHIEBUNG),
|
|
fakt(7, GDP), fakt(8, SYRER), fakt(9, BESCHLUESSE), fakt(10, IMK_DATUM)],
|
|
[[1, 2, 3]])
|
|
pruefe("D1 genau ein Fakt entfernt", entfernt == 1, entfernt)
|
|
pruefe("D2 der aehnliche wurde entfernt", geloescht == [2], geloescht)
|
|
|
|
print("\nE) Grenzwerte")
|
|
pruefe("E1 Anteilsgrenze gesetzt", 0 < qc.DEDUP_MAX_CLUSTER_ANTEIL <= 1, qc.DEDUP_MAX_CLUSTER_ANTEIL)
|
|
pruefe("E2 Aehnlichkeitsgrenze gesetzt", 0 < qc.DEDUP_MIN_AEHNLICHKEIT < 1, qc.DEDUP_MIN_AEHNLICHKEIT)
|
|
pruefe("E3 verschiedene Sachverhalte liegen unter der Grenze",
|
|
qc._aehnlichkeit(FAHRPLAN, SOZIAL) < qc.DEDUP_MIN_AEHNLICHKEIT,
|
|
round(qc._aehnlichkeit(FAHRPLAN, SOZIAL), 2))
|
|
pruefe("E4 echte Dublette liegt ueber der Grenze",
|
|
qc._aehnlichkeit(FAHRPLAN, FAHRPLAN_ANDERS) >= qc.DEDUP_MIN_AEHNLICHKEIT,
|
|
round(qc._aehnlichkeit(FAHRPLAN, FAHRPLAN_ANDERS), 2))
|
|
pruefe("E5 bei zwei Fakten bleibt die Gruppengrenze nutzbar",
|
|
max(2, int(2 * qc.DEDUP_MAX_CLUSTER_ANTEIL)) == 2)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Suchrunden
|
|
# ---------------------------------------------------------------------------
|
|
print("\nF) Die Suchphase endet, sobald die Treffergrenze erreicht ist")
|
|
|
|
runden = {"n": 0}
|
|
|
|
|
|
async def fake_bedrock_research(prompt, model=None, raw_text=False, timeout=None):
|
|
runden["n"] += 1
|
|
# Jede Runde fordert vier Suchen an, das Modell will nie von selbst enden.
|
|
antwort = {"action": "search", "reason": "weiter",
|
|
"queries": [{"q": f"anfrage {runden['n']}-{i}", "market": "de-de",
|
|
"full_content": False} for i in range(1, 5)]}
|
|
return json.dumps(antwort), ClaudeUsage(input_tokens=100, output_tokens=50, cost_usd=0.05)
|
|
|
|
|
|
async def fake_staan_research(q, market="de-de", extra_snippets=True, max_snippets=4,
|
|
full_content=False, extra_exclude=None, timeout=None):
|
|
# Jede Suche liefert 20 neue Treffer, nach zwei Runden ist die Grenze voll.
|
|
basis = q.replace(" ", "")
|
|
return [{
|
|
"title": "Treffer " + basis + str(i),
|
|
"hostname": "example.org",
|
|
"url": "https://example.org/" + basis + "/" + str(i),
|
|
"snippet": "Auszug",
|
|
"extra_snippets": [],
|
|
"full_text": "",
|
|
} for i in range(1, 21)]
|
|
|
|
|
|
letzter_prompt = {"text": ""}
|
|
|
|
|
|
async def fake_bedrock_final(prompt, model=None, raw_text=False, timeout=None):
|
|
"""Ab dem Abschlussauftrag wird eine Auswahl zurueckgegeben."""
|
|
letzter_prompt["text"] = prompt
|
|
return "[]", ClaudeUsage(input_tokens=100, output_tokens=50, cost_usd=0.05)
|
|
|
|
|
|
async def bedrock_weiche(prompt, model=None, raw_text=False, timeout=None):
|
|
# Der Rundenauftrag traegt die Rundennummer im Kopf, der Abschlussauftrag
|
|
# nicht. Daran laesst sich beides sicher unterscheiden.
|
|
if "Es ist Runde " in prompt:
|
|
return await fake_bedrock_research(prompt, model, raw_text, timeout)
|
|
return await fake_bedrock_final(prompt, model, raw_text, timeout)
|
|
|
|
|
|
eur.call_bedrock = bedrock_weiche
|
|
eur.staan_search = fake_staan_research
|
|
|
|
runden["n"] = 0
|
|
text, usage = 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",
|
|
))
|
|
# 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 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)
|