"""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", )) # 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. 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)) print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen") sys.exit(1 if fail else 0)