feat(eu): Faktencheck weist Unsicherheit aus statt alles zu bestaetigen
Der EU-Faktencheck bestaetigte praktisch jede Behauptung. Ueber alle Laeufe seit Lage 42 lag der Anteil offener Punkte bei 6 Prozent, beim Weg ueber die Anthropic-CLI bei 31 Prozent. In acht aufeinanderfolgenden Laeufen zur Ceuta-Lage war er exakt null, achtmal zehn Behauptungen, zehnmal bestaetigt. Bei derselben Lage bewerteten beide Wege dieselben Aussagen verschieden. Der Anthropic-Weg stufte "22 EU-Laender kritisieren Spanien" mit sechs Quellen als unbestaetigt ein, der EU-Weg dieselbe Aussage mit vier Quellen als bestaetigt. Mehr Quellen und trotzdem vorsichtiger, das ist kein Zufall, sondern Bauart. Drei Ursachen, drei Aenderungen. 1. Die Belegsuche kannte nur Bestaetigung. Alle Anfragen waren stuetzend formuliert, wer so sucht findet auch nur Stuetzendes. Ein Anteil der Anfragen (EU_VERIFY_COUNTER_SHARE, Vorgabe 0.3) ist jetzt eine Gegenprobe und sucht gezielt nach Dementi, Richtigstellung, abweichenden Angaben und der Darstellung der Gegenseite. Die Treffer sind im Kontext als [GEGENPROBE] gekennzeichnet. 2. Das Modell konnte Belege nicht wirklich pruefen. Die Belegsuche lief mit abgeschaltetem Volltext, das Modell sah je Treffer nur Ueberschrift und Auszug. Ein Artikel zum Thema wurde damit zum Beleg fuer eine konkrete Zahl. Bis zu EU_VERIFY_FULLTEXT_QUERIES Anfragen holen jetzt den Artikeltext. Dazu die Ansage, dass ein Treffer, der nur das Thema erwaehnt, kein Beleg ist, und dass eine Angabe, die praeziser ist als ihre Quelle, nicht traegt. 3. Die zweite Runde lief nur in eine Richtung. Sie belegte offene Punkte nach, und wenn nichts offen war, lief sie gar nicht. Ausgerechnet der Fall "alles bestaetigt" blieb ungeprueft. Jetzt folgt dort eine Belastungsprobe. Sie sucht ausschliesslich Gegenproben zu den staerksten Behauptungen und darf Bestaetigungen zuruecknehmen, aber niemals vergeben. Zurueckgestuft wird bei Widerspruch, bei Dementi, wenn die Belege die konkrete Angabe gar nicht nennen, wenn die Behauptung praeziser ist als ihre Quellen, oder wenn nur eine einzige Stelle berichtet. Ausserdem darf die Nachhak-Runde einen Status jetzt auch absenken, wenn die Nachrecherche der Behauptung widerspricht. Dabei fiel ein Robustheitsmangel auf. Scheiterte die zweite Runde, etwa weil die Suche nicht erreichbar war, ging der gesamte Faktencheck verloren statt nur die Zusatzpruefung. Die zweite Runde ist jetzt gekapselt, das Ergebnis des ersten Durchgangs bleibt in jedem Fall erhalten. Der Weg ueber die Anthropic-CLI bleibt unveraendert. Neu sind 26 Pruefungen, insgesamt laufen 250 ohne Netzzugriff und ohne Kosten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -16,7 +16,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."
|
||||
import agents.eu_researcher as eur # noqa: E402
|
||||
from agents.claude_client import ClaudeUsage # noqa: E402
|
||||
from config import ( # noqa: E402
|
||||
EU_VERIFY_HITS_PER_QUERY, EU_VERIFY_MAX_ITEMS, STAAN_COST_PER_QUERY_USD,
|
||||
EU_VERIFY_FULLTEXT_CHARS, EU_VERIFY_FULLTEXT_QUERIES, EU_VERIFY_HITS_PER_QUERY,
|
||||
EU_VERIFY_MAX_ITEMS, STAAN_COST_PER_QUERY_USD,
|
||||
)
|
||||
|
||||
ok = 0
|
||||
@@ -47,7 +48,7 @@ treffer_je_anfrage = {}
|
||||
|
||||
async def fake_staan(q, market="de-de", extra_snippets=True, max_snippets=3,
|
||||
full_content=False, extra_exclude=None, timeout=None):
|
||||
zustand["suchen"].append({"q": q, "market": market})
|
||||
zustand["suchen"].append({"q": q, "market": market, "full_content": full_content})
|
||||
return treffer_je_anfrage.get(q, [])
|
||||
|
||||
|
||||
@@ -222,5 +223,86 @@ pruefe("G1 Hinweis auf die Adresspflicht vorhanden",
|
||||
"vollständige" in mit and "URL" in mit)
|
||||
pruefe("G2 ohne Belege kein Adresshinweis", "Adresse (die URL" not in ohne)
|
||||
|
||||
print("\nH) Ein Teil der Anfragen ist eine Gegenprobe")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "ceuta opferzahl offiziell", "market": "de-de", "for_items": [1]},
|
||||
{"q": "ceuta opferzahl dementiert kritik", "market": "de-de", "for_items": [1],
|
||||
"gegenprobe": True},
|
||||
]
|
||||
treffer_je_anfrage["ceuta opferzahl offiziell"] = treffer(2, "a")
|
||||
treffer_je_anfrage["ceuta opferzahl dementiert kritik"] = treffer(2, "b")
|
||||
_rj = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["57 Tote gemeldet"], max_queries=7,
|
||||
))
|
||||
def markierte_treffer(block):
|
||||
"""Zaehlt nur Trefferzeilen, nicht die Erklaerung im Kopf."""
|
||||
return sum(1 for z in block.splitlines()
|
||||
if z.startswith("[S") and "[GEGENPROBE]" in z)
|
||||
|
||||
|
||||
pruefe("H1 Auftrag verlangt Gegenproben", "GEGENPROBEN" in zustand["plan_prompt"])
|
||||
pruefe("H2 Gegenprobe-Treffer sind gekennzeichnet",
|
||||
markierte_treffer(_rj.block) == 2, markierte_treffer(_rj.block))
|
||||
pruefe("H3 Kopf erklaert die Gegenprobe", "GEGENPROBE" in _rj.block.split("[S1]")[0])
|
||||
pruefe("H4 stuetzende Treffer bleiben unmarkiert",
|
||||
_rj.block.count("\n[S") == 4, _rj.block.count("\n[S"))
|
||||
|
||||
print("\nI) Belastungsprobe sucht ausschliesslich Gegenproben")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "eine anfrage", "market": "de-de"},
|
||||
{"q": "andere anfrage", "market": "de-de"},
|
||||
]
|
||||
treffer_je_anfrage["eine anfrage"] = treffer(1, "c")
|
||||
treffer_je_anfrage["andere anfrage"] = treffer(1, "d")
|
||||
_rk = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=4,
|
||||
label="Belastungsprobe", nur_gegenproben=True,
|
||||
))
|
||||
pruefe("I1 alle Treffer als Gegenprobe gekennzeichnet",
|
||||
markierte_treffer(_rk.block) == 2, markierte_treffer(_rk.block))
|
||||
pruefe("I2 Auftrag fordert so viele Gegenproben wie Anfragen",
|
||||
"Davon sind 4 Anfragen" in zustand["plan_prompt"])
|
||||
|
||||
print("\nJ) Artikeltexte werden geholt und begrenzt")
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": "mit text", "market": "de-de", "full_content": True},
|
||||
{"q": "ohne text", "market": "de-de", "full_content": False},
|
||||
]
|
||||
treffer_je_anfrage["mit text"] = [{
|
||||
"title": "Titel mit Text", "hostname": "example.org",
|
||||
"url": "https://example.org/volltext", "snippet": "Auszug",
|
||||
"extra_snippets": [], "full_text": "W" * 5000,
|
||||
}]
|
||||
treffer_je_anfrage["ohne text"] = treffer(1, "e")
|
||||
_rl = asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=5,
|
||||
))
|
||||
pruefe("J1 Volltext-Anfrage wurde mit Volltext gestellt",
|
||||
zustand["suchen"][0].get("full_content") is True, zustand["suchen"][0])
|
||||
pruefe("J2 andere Anfrage ohne Volltext",
|
||||
zustand["suchen"][1].get("full_content") is False, zustand["suchen"][1])
|
||||
pruefe("J3 Artikeltext steht im Belegblock", "Artikeltext:" in _rl.block)
|
||||
pruefe("J4 Artikeltext ist begrenzt",
|
||||
_rl.block.count("W") <= EU_VERIFY_FULLTEXT_CHARS + 10, _rl.block.count("W"))
|
||||
|
||||
neu()
|
||||
plan_antwort["queries"] = [
|
||||
{"q": f"anfrage {i}", "market": "de-de", "full_content": True} for i in range(1, 7)
|
||||
]
|
||||
for i in range(1, 7):
|
||||
treffer_je_anfrage[f"anfrage {i}"] = [{
|
||||
"title": "T", "hostname": "example.org", "url": f"https://example.org/{i}",
|
||||
"snippet": "s", "extra_snippets": [], "full_text": "Text",
|
||||
}]
|
||||
asyncio.run(eur.plan_verification_searches(
|
||||
title="Ceuta", search_items=["Punkt"], max_queries=6,
|
||||
))
|
||||
mit_volltext = sum(1 for s in zustand["suchen"] if s.get("full_content"))
|
||||
pruefe("J5 Zahl der Volltext-Anfragen ist gedeckelt",
|
||||
mit_volltext == EU_VERIFY_FULLTEXT_QUERIES, mit_volltext)
|
||||
|
||||
print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
|
||||
@@ -48,12 +48,14 @@ SUCHQUELLE = {
|
||||
|
||||
|
||||
async def fake_plan(*, title, search_items, output_language="Deutsch",
|
||||
max_queries=7, avoid_queries=None, label="Stuetzsuche"):
|
||||
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"
|
||||
@@ -137,24 +139,34 @@ 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")
|
||||
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 Nachrunde bei belegten Fakten", len(aufrufe["such"]) == 1, len(aufrufe["such"]))
|
||||
pruefe("B2 nur ein Modellaufruf", len(aufrufe["modell"]) == 1, len(aufrufe["modell"]))
|
||||
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) EU-Modus, nur ein offener Punkt, Schwelle nicht erreicht")
|
||||
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 2 offenen Punkten greift", len(aufrufe["such"]) == 1, len(aufrufe["such"]))
|
||||
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")
|
||||
@@ -282,5 +294,101 @@ 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("\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)
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren