Dateien
AegisSight-Monitor/tests/test_eu_factcheck.py
claude-dev 3495409377 fix(faktencheck): Belegtiefe zaehlt Medienhaeuser, Widerspruch ist keine Widerlegung
Block 2 der Auswertung des Ceuta-Vergleichs vom 01.08.2026. Der Faktencheck der
EU-Fassung zeigte dort drei Fehler, die alle dieselbe Wurzel haben: Der Status
behauptete eine Belegtiefe, die niemand nachgerechnet hat.

1. Die Zaehlung war kaputt. Das Muster fuer die Quellenzaehlung war
   r'https?://' ohne den Rest der Adresse, findall lieferte also eine Liste aus
   "https://"-Bruchstuecken und len(set(...)) ergab immer 1 oder 2. Daher stand
   im Bericht eine Behauptung als BESTAETIGT mit 1 Quelle, obwohl vier Haeuser
   in der Evidenz standen, und der gesamte Faktencheck der Anthropic-Fassung
   wies durchgehend 1 bis 2 Quellen aus. Die Adresse wird jetzt vollstaendig
   erfasst.

2. Gezaehlt wird nach Medienhaus, nicht nach Fundstelle. Sechs France24-Treffer,
   davon vier Videofassungen derselben Meldung, sind ein Haus. Grundlage ist die
   registrierbare Domain aus services/media_registry.py. Weiterleitungsportale
   bekommen keine gemeinsame Identitaet, hinter news.google.com koennen
   beliebige Zeitungen stecken. Nachgerechnet an Lage 53 sinken die Angaben von
   9 auf 6, von 8 auf 4 und von 7 auf 5 Belege.

3. Neue Nachbedingung. pruefe_belegtiefe stuft jeden Fakt ab, dessen Status mehr
   behauptet als vorliegt: confirmed braucht zwei, established drei unabhaengige
   Haeuser. Ein Primaerbeleg (Originalzitat im Interview, amtliche Mitteilung,
   Urteil) traegt allein, dafuer liefert das Modell jetzt "evidence_type". Damit
   verschwindet auch die Unterkonfidenz der Gegenprobe, in der ein ZDF-Interview
   als unbestaetigt galt, obwohl der Kritisierte dort selbst spricht.

4. Die Nachhak-Runde stuft "developing" nicht mehr hoch. Sie hatte die
   Behauptung "Der Ausloeser ist weiterhin unklar" auf BESTAETIGT gesetzt, weil
   developing in der Statusordnung die niedrigste Stufe ist. Eine Unklarheit
   laesst sich nicht bestaetigen. Ausserdem rechnet die Runde die Belegzahl nach
   dem Hochstufen neu und nimmt die Stufe zurueck, wenn die Belege sie nicht
   decken.

5. Widerspruch und Widerlegung sind getrennt. "contradicted" meint laut Auftrag
   Belege, die einander widersprechen, wurde aber als "Widerlegt" angezeigt. Im
   Bericht stand deshalb die sachlich zutreffende Spanne von 50.000 bis 60.000
   Grenzuebertritten als widerlegt, obwohl die Evidenz woertlich sagt "Die
   Zahlen variieren je nach Quelle". Jetzt: contradicted = "Widersprüchlich",
   der neue Status "false" = "Widerlegt" und setzt einen entkraeftenden Beleg
   voraus. Angepasst in Bericht, Oberflaeche, beiden Sprachdateien, Farben und
   Statusordnung.

6. Ein verbindlicher Regelblock haengt an jedem Faktencheck-Auftrag
   (bewertungsregeln()): Unabhaengigkeit heisst verschiedene Medienhaeuser,
   Agenturuebernahmen zaehlen einmal, unterschiedliche Zahlen verschiedener
   Erhebungsstellen sind kein Widerspruch sondern eine Spanne mit Zuordnung,
   und Aussagen ueber den Kenntnisstand ("Der Ausloeser ist unklar") sind keine
   pruefbaren Behauptungen und gehoeren nicht in den Faktencheck.

Die Aenderungen wirken auf beide Modellwege. Die Fixtures in test_eu_factcheck
liefern jetzt Belege aus mehreren Haeusern, weil eine einzelne Quelle keine
Bestaetigung mehr traegt.

Nachgerechnet an beiden gespeicherten Laeufen: kein Fakt wird durch die neue
Nachbedingung abgestuft, die Belegzahlen werden aber durchgehend ehrlicher.

Neu sind 27 Pruefungen, insgesamt laufen 156 ohne Netzzugriff und ohne Kosten.

Offen: Die Oberflaechen-Aenderungen (components.js, app.js, style.css, i18n)
sind noch nicht in den Lokal-Fork portiert.

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

287 Zeilen
12 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"):
aufrufe["such"].append({
"label": label,
"items": list(search_items),
"max": max_queries,
"avoid": list(avoid_queries or []),
})
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"),
]))
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 ausgeloest",
len(aufrufe["such"]) == 2 and aufrufe["such"][1]["label"] == "Nachhak-Suche")
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 beide Modellaufrufe ohne Werkzeuge",
[m["tools"] for m in aufrufe["modell"]] == [None, None], aufrufe["modell"])
pruefe("A12 beide Modellaufrufe mit Belegblock",
all(m["hat_belege"] for m in aufrufe["modell"]))
pruefe("A13 Kosten aufsummiert", round(usage.cost_usd, 3) == 0.12, usage.cost_usd)
print("\nB) EU-Modus, alles belegt, keine Nachrunde")
neu()
antworten.append(json_fakten([
(F_TOTE, "confirmed", "belegt"),
(F_GRENZE, "confirmed", "belegt"),
]))
asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
pruefe("B1 keine Nachrunde bei belegten Fakten", len(aufrufe["such"]) == 1, len(aufrufe["such"]))
pruefe("B2 nur ein Modellaufruf", len(aufrufe["modell"]) == 1, len(aufrufe["modell"]))
print("\nC) EU-Modus, nur ein offener Punkt, Schwelle nicht erreicht")
neu()
antworten.append(json_fakten([
(F_TOTE, "unconfirmed", "unklar"),
(F_GRENZE, "confirmed", "belegt"),
]))
asyncio.run(fc.check("Ceuta", ARTS, "adhoc", "Deutsch"))
pruefe("C1 Schwelle von 2 offenen Punkten greift", len(aufrufe["such"]) == 1, len(aufrufe["such"]))
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", len(aufrufe["such"]) == 2, len(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 hakt mit kleinerer Menge nach",
len(aufrufe["such"]) == 2 and aufrufe["such"][1]["max"] == 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("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
sys.exit(1 if fail else 0)