Dateien
AegisSight-Monitor/tests/test_eu_factcheck.py
claude-dev b00076d320 fix(eu): Belastungsprobe folgt auch auf eine erfolgreiche Nachbelegung
Der erste Lauf mit den Gegenproben (Lage 64) zeigte, dass der Faktencheck
selbst jetzt ehrlich arbeitet. Der erste Durchgang liess 3 von 10 Behauptungen
offen, also 30 Prozent und damit genau das Niveau des Anthropic-Wegs. Vorher
waren es acht Laeufe hintereinander null.

Danach hob die Nachhak-Runde alle drei wieder auf bestaetigt, und der Lauf
endete erneut ohne eine einzige Unsicherheit.

Ursache war ein Denkfehler im Aufbau. Die zweite Runde war ein Entweder-oder.
Entweder wurde nachbelegt oder es lief die Belastungsprobe. Sobald nachbelegt
wurde, stand am Ende wieder alles auf bestaetigt und niemand prueft das nach.

Jetzt laufen beide Schritte nacheinander. Erst wird nachbelegt, danach greift
die Belastungsprobe, wenn kaum noch etwas offen ist. Sie prueft damit genau die
frisch vergebenen Bestaetigungen. Damit sie wirklich neu sucht, meldet die
Nachhak-Runde ihre Anfragen zurueck und die Belastungsprobe meidet auch diese.

Der Weg ueber die Anthropic-CLI bleibt unveraendert. 254 Pruefungen laufen ohne
Netzzugriff und ohne Kosten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:27:17 +00:00

433 Zeilen
18 KiB
Python

"""Testet die EU-Nachhak-Schleife im Faktencheck und sichert den CLI-Weg ab.
Laeuft ohne Netzzugriff und ohne Kosten, die Belegsuche und der Modellaufruf
sind durch Testdoubles ersetzt.
Aufruf aus dem Projektstamm:
venv/bin/python tests/test_eu_factcheck.py
Seit der Belegtiefe-Pruefung (Block 2) traegt eine einzelne Quelle keine
Bestaetigung mehr. Die Faelle H und J liefern deshalb Belege aus mehreren
unabhaengigen Haeusern, sonst pruefen sie nicht mehr die Zuordnung, sondern
nur noch die Mindestzahl.
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
from agents.factchecker import FactCheckerAgent # noqa: E402
import agents.factchecker as fcmod # noqa: E402
import agents.eu_researcher as eur # noqa: E402
import agents.claude_client as cc # noqa: E402
from agents.claude_client import _ai_backend_var, 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))
aufrufe = {"such": [], "modell": []}
SUCHQUELLE = {
"headline": "Innenministerkonferenz beschliesst Fahrplan",
"source": "example.org",
"source_url": "https://example.org/a",
}
async def fake_plan(*, title, search_items, output_language="Deutsch",
max_queries=7, avoid_queries=None, label="Stuetzsuche",
nur_gegenproben=False):
aufrufe["such"].append({
"label": label,
"items": list(search_items),
"max": max_queries,
"avoid": list(avoid_queries or []),
"nur_gegenproben": nur_gegenproben,
})
u = ClaudeUsage(input_tokens=10, output_tokens=5, cost_usd=0.01)
block = "\n\nBELEGE [" + label + "]\n[S1] Beleg | example.org | https://example.org/a"
return eur.Belegsuche(block, u, [label + "-q1", label + "-q2"], [dict(SUCHQUELLE)])
antworten = []
async def fake_call_claude(prompt, tools="WebSearch,WebFetch", model=None,
raw_text=False, timeout=None):
aufrufe["modell"].append({"tools": tools, "hat_belege": "BELEGE" in prompt})
if not antworten:
raise AssertionError("unerwarteter Modellaufruf")
return antworten.pop(0), ClaudeUsage(input_tokens=100, output_tokens=50, cost_usd=0.05)
eur.plan_verification_searches = fake_plan
fcmod.call_claude = fake_call_claude # CLI-Zweig nutzt das Modul-Symbol
cc.call_claude = fake_call_claude # EU-Zweig importiert erst zur Laufzeit
fc = FactCheckerAgent()
ARTS = [
{"headline": "Ceuta Grenze Tote gemeldet", "source": "tagesschau",
"source_url": "https://tagesschau.de/ceuta", "content_original": "t"},
{"headline": "Sanchez spricht ueber Angriff in Ceuta", "source": "elpais",
"source_url": "https://elpais.com/ceuta", "content_original": "t"},
]
F_TOTE = "In Ceuta wurden 57 Tote gemeldet"
F_SANCHEZ = "Sanchez sprach in Ceuta von einem Angriff"
F_GRENZE = "Die Grenze in Ceuta wurde geschlossen"
def neu():
aufrufe["such"].clear()
aufrufe["modell"].clear()
antworten.clear()
def json_fakten(eintraege):
teile = []
for claim, status, ev in eintraege:
teile.append('{"claim": "%s", "status": "%s", "evidence": "%s"}' % (claim, status, ev))
return "[" + ", ".join(teile) + "]"
print("\nA) EU-Modus, Nachhak-Runde stuft offene Behauptung hoch")
_ai_backend_var.set("bedrock")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "nur eine Quelle"),
(F_SANCHEZ, "unconfirmed", "unklar"),
(F_GRENZE, "confirmed", "belegt"),
]))
antworten.append(json_fakten([
(F_TOTE, "confirmed", "laut [S1] https://example.org/a bestaetigt"),
]))
antworten.append(json_fakten([(F_TOTE, "confirmed", "haelt der Gegenprobe stand")]))
facts, usage = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
status = {f.get("claim", "")[:20]: f.get("status") for f in facts}
pruefe("A1 Erstsuche plant 7 Anfragen", aufrufe["such"][0]["max"] == 7)
pruefe("A2 Erstsuche kennt Titel und Schlagzeilen",
len(aufrufe["such"][0]["items"]) == 3, aufrufe["such"][0]["items"])
pruefe("A3 Nachhak-Runde und danach Belastungsprobe",
[a["label"] for a in aufrufe["such"]]
== ["Stuetzsuche", "Nachhak-Suche", "Belastungsprobe"],
[a["label"] for a in aufrufe["such"]])
pruefe("A4 Nachhak nur fuer die 2 offenen Punkte",
len(aufrufe["such"][1]["items"]) == 2, aufrufe["such"][1]["items"])
pruefe("A5 Nachhak meidet die alten Anfragen",
aufrufe["such"][1]["avoid"] == ["Stuetzsuche-q1", "Stuetzsuche-q2"],
aufrufe["such"][1]["avoid"])
pruefe("A6 belegter Fakt hochgestuft", status.get(F_TOTE[:20]) == "confirmed", status)
pruefe("A7 unbelegter Fakt bleibt offen", status.get(F_SANCHEZ[:20]) == "unconfirmed", status)
pruefe("A8 bestaetigter Fakt unangetastet", status.get(F_GRENZE[:20]) == "confirmed", status)
ev = [f["evidence"] for f in facts if f["claim"].startswith("In Ceuta")][0]
pruefe("A9 Nachrecherche mit URL in der Evidenz",
"Nachrecherche" in ev and "https://example.org/a" in ev, ev[:160])
pruefe("A10 kein Faktenverlust", len(facts) == 3, len(facts))
pruefe("A11 alle Modellaufrufe ohne Werkzeuge",
[m["tools"] for m in aufrufe["modell"]] == [None, None, None], aufrufe["modell"])
pruefe("A12 alle Modellaufrufe mit Belegblock",
all(m["hat_belege"] for m in aufrufe["modell"]))
pruefe("A13 Kosten aller drei Runden aufsummiert",
round(usage.cost_usd, 3) == 0.18, usage.cost_usd)
print("\nB) Ist alles belegt, folgt keine Nachbelegung sondern eine Belastungsprobe")
neu()
antworten.append(json_fakten([
(F_TOTE, "confirmed", "belegt"),
(F_GRENZE, "confirmed", "belegt"),
]))
antworten.append(json_fakten([(F_TOTE, "confirmed", "haelt der Gegenprobe stand")]))
asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
pruefe("B1 keine Nachbelegung, sondern Belastungsprobe",
[a["label"] for a in aufrufe["such"]] == ["Stuetzsuche", "Belastungsprobe"],
[a["label"] for a in aufrufe["such"]])
pruefe("B2 Belastungsprobe sucht nur Gegenproben",
aufrufe["such"][1]["nur_gegenproben"] is True)
pruefe("B3 zwei Modellaufrufe", len(aufrufe["modell"]) == 2, len(aufrufe["modell"]))
print("\nC) Ein einzelner offener Punkt loest keine Nachbelegung aus")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_GRENZE, "confirmed", "belegt"),
]))
antworten.append(json_fakten([(F_GRENZE, "confirmed", "haelt")]))
asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
pruefe("C1 Schwelle von zwei offenen Punkten greift weiterhin",
aufrufe["such"][1]["label"] == "Belastungsprobe",
[a["label"] for a in aufrufe["such"]])
pruefe("C2 nur der belegte Punkt wird angegriffen",
aufrufe["such"][1]["items"] == [F_GRENZE], aufrufe["such"][1]["items"])
print("\nD) Regression, CLI-Weg unveraendert")
_ai_backend_var.set("cli")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_SANCHEZ, "unconfirmed", "unklar"),
]))
facts_d, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
pruefe("D1 CLI macht keine Belegsuche", len(aufrufe["such"]) == 0, aufrufe["such"])
pruefe("D2 CLI genau ein Modellaufruf", len(aufrufe["modell"]) == 1, len(aufrufe["modell"]))
pruefe("D3 CLI behaelt die WebSearch-Werkzeuge",
aufrufe["modell"][0]["tools"] == "WebSearch,WebFetch", aufrufe["modell"])
pruefe("D4 CLI bekommt keinen Belegblock", aufrufe["modell"][0]["hat_belege"] is False)
pruefe("D5 CLI liefert Fakten", len(facts_d) == 2, len(facts_d))
print("\nE) EU-Modus, inkrementelle Pruefung")
_ai_backend_var.set("bedrock")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_SANCHEZ, "unconfirmed", "unklar"),
]))
antworten.append(json_fakten([
(F_SANCHEZ, "confirmed", "[S1] https://example.org/a belegt die Aussage"),
]))
alt = [{"claim": "Alter offener Punkt aus Ceuta", "status": "unconfirmed", "evidence": "e"}]
facts_e, _ = asyncio.run(fc.check_incremental("Ceuta", ARTS, alt, "adhoc", "Deutsch"))
pruefe("E1 inkrementell nutzt die Belegsuche", len(aufrufe["such"]) >= 1, aufrufe["such"])
pruefe("E2 alte offene Punkte fliessen in die Suche",
any("Alter offener Punkt" in i for i in aufrufe["such"][0]["items"]),
aufrufe["such"][0]["items"])
pruefe("E3 inkrementell hakt nach und prueft danach",
[a["label"] for a in aufrufe["such"]]
== ["Stuetzsuche", "Nachhak-Suche", "Belastungsprobe"],
[a["label"] for a in aufrufe["such"]])
pruefe("E4 inkrementelle Hochstufung greift",
any(f["claim"].startswith("Sanchez") and f["status"] == "confirmed" for f in facts_e),
[(f["claim"][:25], f["status"]) for f in facts_e])
print("\nF) Regression, CLI-Weg inkrementell unveraendert")
_ai_backend_var.set("cli")
neu()
antworten.append(json_fakten([(F_TOTE, "unconfirmed", "unklar")]))
asyncio.run(fc.check_incremental("Ceuta", ARTS, alt, "adhoc", "Deutsch"))
pruefe("F1 CLI inkrementell ohne Belegsuche", len(aufrufe["such"]) == 0, aufrufe["such"])
pruefe("F2 CLI inkrementell mit Werkzeugen",
aufrufe["modell"][0]["tools"] == "WebSearch,WebFetch", aufrufe["modell"])
print("\nG) EU-Modus, Nachhak-Runde faellt aus, Fakten bleiben erhalten")
_ai_backend_var.set("bedrock")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_SANCHEZ, "unconfirmed", "unklar"),
]))
antworten.append("Das ist kein JSON, sondern Fliesstext.")
facts_g, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
pruefe("G1 Fakten ueberleben eine unbrauchbare Nachhak-Antwort", len(facts_g) == 2, len(facts_g))
pruefe("G2 Status bleibt offen",
all(f["status"] in ("unconfirmed", "unverified") for f in facts_g),
[(f["claim"][:25], f["status"]) for f in facts_g])
print("\nH) EU-Modus, Gruppenpruefung nutzt die kleinere Suchmenge")
from config import EU_VERIFY_GROUP_QUERIES, EU_VERIFY_GROUP_FOLLOWUP_QUERIES # noqa: E402
_ai_backend_var.set("bedrock")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_SANCHEZ, "unconfirmed", "unklar"),
]))
antworten.append(json_fakten([(F_TOTE, "confirmed",
"[S1] https://example.org/a und [S2] https://beispiel.de/b belegen")]))
facts_h, _ = asyncio.run(fc._eu_pruefen(
"Auftrag", title="Ceuta (Opfer)", articles=None, output_language="Deutsch",
search_items=[F_TOTE, F_SANCHEZ], incident_type="adhoc",
max_queries=EU_VERIFY_GROUP_QUERIES,
followup_queries=EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
))
pruefe("H1 Gruppe plant die kleinere Suchmenge",
aufrufe["such"][0]["max"] == EU_VERIFY_GROUP_QUERIES, aufrufe["such"][0]["max"])
pruefe("H2 Gruppe nutzt die kleinere Menge auch in den Folgerunden",
[a["max"] for a in aufrufe["such"]]
== [EU_VERIFY_GROUP_QUERIES, EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
EU_VERIFY_GROUP_FOLLOWUP_QUERIES],
[a["max"] for a in aufrufe["such"]])
pruefe("H3 Gruppe stuft belegten Fakt hoch",
any(f["claim"].startswith("In Ceuta") and f["status"] == "confirmed" for f in facts_h),
[(f["claim"][:25], f["status"]) for f in facts_h])
print("\nI) Nachhaken abschaltbar")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_SANCHEZ, "unconfirmed", "unklar"),
]))
asyncio.run(fc._eu_pruefen(
"Auftrag", title="Ceuta", articles=None, output_language="Deutsch",
search_items=[F_TOTE], incident_type="adhoc", followup_queries=0,
))
pruefe("I1 followup_queries=0 unterbindet die Nachrunde", len(aufrufe["such"]) == 1, len(aufrufe["such"]))
print("\nJ) Suchbelege zaehlen bei der Quellenzuordnung")
_ai_backend_var.set("bedrock")
neu()
# Behauptung, die zu KEINEM gespeicherten Artikel passt, wohl aber zur
# Suchquelle. Vor der Korrektur wurde so ein Fakt herabgestuft.
NUR_SUCHE = "Die Innenministerkonferenz beschliesst einen Fahrplan"
antworten.append(json_fakten([
(NUR_SUCHE, "established",
"belegt durch die Suche: https://example.org/a, https://beispiel.de/b, https://muster.at/c"),
(F_GRENZE, "confirmed", "belegt"),
]))
facts_j, _ = asyncio.run(fc.check("Ceuta", ARTS, "research", "Deutsch"))
treffer_j = [f for f in facts_j if f["claim"].startswith("Die Innenministerkonferenz")]
pruefe("J1 Fakt allein aus der Suche bleibt belegt",
treffer_j and treffer_j[0]["status"] == "established",
[(f["claim"][:35], f["status"]) for f in facts_j])
pruefe("J2 Adresse der Suchquelle steht in der Evidenz",
treffer_j and "https://example.org/a" in treffer_j[0]["evidence"],
treffer_j[0]["evidence"][:160] if treffer_j else "")
print("\nK) Ohne Suchbelege bleibt die Zuordnung wie bisher")
_ai_backend_var.set("cli")
neu()
antworten.append(json_fakten([(NUR_SUCHE, "established", "belegt")]))
facts_k, _ = asyncio.run(fc.check("Ceuta", ARTS, "research", "Deutsch"))
pruefe("K1 CLI stuft ohne zuordenbare Quelle weiterhin herab",
facts_k and facts_k[0]["status"] != "established",
[(f["claim"][:35], f["status"]) for f in facts_k])
print("\nL) Belastungsprobe greift, wenn alles bestaetigt aussieht")
_ai_backend_var.set("bedrock")
neu()
antworten.append(json_fakten([
(F_TOTE, "confirmed", "belegt"),
(F_GRENZE, "confirmed", "belegt"),
(F_SANCHEZ, "confirmed", "belegt"),
]))
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "[S1] https://example.org/a nennt nur eine Schaetzung"),
]))
facts_l, usage_l = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
status_l = {f["claim"][:20]: f["status"] for f in facts_l}
pruefe("L1 zweite Runde ist eine Belastungsprobe",
len(aufrufe["such"]) == 2 and aufrufe["such"][1]["label"] == "Belastungsprobe",
[a["label"] for a in aufrufe["such"]])
pruefe("L2 Belastungsprobe sucht ausschliesslich Gegenproben",
aufrufe["such"][1]["nur_gegenproben"] is True)
pruefe("L3 Belastungsprobe meidet die schon gestellten Anfragen",
aufrufe["such"][1]["avoid"] == ["Stuetzsuche-q1", "Stuetzsuche-q2"],
aufrufe["such"][1]["avoid"])
pruefe("L4 nicht haltbare Bestaetigung wird zurueckgenommen",
status_l.get(F_TOTE[:20]) == "unconfirmed", status_l)
pruefe("L5 haltbare Bestaetigungen bleiben",
status_l.get(F_GRENZE[:20]) == "confirmed" and status_l.get(F_SANCHEZ[:20]) == "confirmed",
status_l)
pruefe("L6 kein Faktenverlust", len(facts_l) == 3, len(facts_l))
pruefe("L7 Begruendung vermerkt die Belastungsprobe",
any("Belastungsprobe" in (f.get("evidence") or "") for f in facts_l),
[f.get("evidence", "")[:70] for f in facts_l])
print("\nM) Die Belastungsprobe kann nur zurueckstufen")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_GRENZE, "confirmed", "belegt"),
(F_SANCHEZ, "confirmed", "belegt"),
]))
antworten.append(json_fakten([
(F_GRENZE, "confirmed", "haelt"),
(F_SANCHEZ, "established", "sogar noch besser belegt"),
]))
facts_m, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
status_m = {f["claim"][:20]: f["status"] for f in facts_m}
pruefe("M1 Belastungsprobe laeuft auch bei einem offenen Punkt",
len(aufrufe["such"]) == 2 and aufrufe["such"][1]["label"] == "Belastungsprobe",
[a["label"] for a in aufrufe["such"]])
pruefe("M2 keine Hochstufung durch die Belastungsprobe",
status_m.get(F_SANCHEZ[:20]) == "confirmed", status_m)
pruefe("M3 offener Punkt bleibt offen", status_m.get(F_TOTE[:20]) == "unconfirmed", status_m)
print("\nN1) Nach der Nachbelegung folgt die Belastungsprobe")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_SANCHEZ, "unconfirmed", "unklar"),
(F_GRENZE, "confirmed", "belegt"),
]))
# Die Nachbelegung hebt beide offenen Punkte hoch, danach ist nichts mehr offen.
antworten.append(json_fakten([
(F_TOTE, "confirmed", "[S1] https://example.org/a belegt es"),
(F_SANCHEZ, "confirmed", "[S1] https://example.org/a belegt es"),
]))
# Die Belastungsprobe nimmt eine der Bestaetigungen wieder zurueck.
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "[S1] nennt nur eine Schaetzung"),
]))
facts_n1, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
status_n1 = {f["claim"][:20]: f["status"] for f in facts_n1}
pruefe("N1a beide Runden laufen nacheinander",
[a["label"] for a in aufrufe["such"]]
== ["Stuetzsuche", "Nachhak-Suche", "Belastungsprobe"],
[a["label"] for a in aufrufe["such"]])
pruefe("N1b Belastungsprobe meidet auch die Anfragen der Nachbelegung",
"Nachhak-Suche-q1" in aufrufe["such"][2]["avoid"], aufrufe["such"][2]["avoid"])
pruefe("N1c nicht haltbare Nachbelegung wird zurueckgenommen",
status_n1.get(F_TOTE[:20]) == "unconfirmed", status_n1)
pruefe("N1d haltbare Nachbelegung bleibt",
status_n1.get(F_SANCHEZ[:20]) == "confirmed", status_n1)
print("\nN) Widerspruch in der Nachhak-Runde stuft zurueck")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_SANCHEZ, "unconfirmed", "unklar"),
(F_GRENZE, "confirmed", "belegt"),
]))
antworten.append(json_fakten([
(F_TOTE, "contradicted", "[S1] https://example.org/a nennt eine andere Zahl"),
]))
facts_n, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
status_n = {f["claim"][:20]: f["status"] for f in facts_n}
pruefe("N1 Nachhak-Runde nimmt den Widerspruch auf",
status_n.get(F_TOTE[:20]) == "contradicted", status_n)
print("\nO) Faellt die zweite Runde aus, bleibt der erste Durchgang erhalten")
neu()
antworten.append(json_fakten([
(F_TOTE, "confirmed", "belegt"),
(F_GRENZE, "confirmed", "belegt"),
]))
async def plan_kaputt(**kw):
raise RuntimeError("Suche nicht erreichbar")
eur.plan_verification_searches = fake_plan
_alt_plan = eur.plan_verification_searches
async def plan_erst_gut_dann_kaputt(**kw):
if kw.get("label") == "Belastungsprobe":
raise RuntimeError("Suche nicht erreichbar")
return await fake_plan(**kw)
eur.plan_verification_searches = plan_erst_gut_dann_kaputt
facts_o, _ = asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
eur.plan_verification_searches = fake_plan
pruefe("O1 Fakten ueberleben den Ausfall", len(facts_o) == 2, len(facts_o))
pruefe("O2 Bewertungen bleiben unveraendert",
all(f["status"] == "confirmed" for f in facts_o),
[(f["claim"][:25], f["status"]) for f in facts_o])
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
sys.exit(1 if fail else 0)