diff --git a/src/agents/factchecker.py b/src/agents/factchecker.py index 4d565cd..0d05b0f 100644 --- a/src/agents/factchecker.py +++ b/src/agents/factchecker.py @@ -769,30 +769,38 @@ class FactCheckerAgent: # Die zweite Runde ist eine Verbesserung, keine Voraussetzung. Faellt sie # aus, bleibt das Ergebnis des ersten Durchgangs erhalten, statt dass der # ganze Faktencheck verloren geht. + gestellte_anfragen = list(suche.queries) try: + # Erster Schritt: offene Punkte nachbelegen, sofern welche da sind. offene = [f for f in facts if f.get("status") in OFFENE_STATUS] if len(offene) >= EU_VERIFY_FOLLOWUP_MIN_OPEN: logger.info( "EU-Faktencheck: %d von %d Behauptungen offen, starte Nachhak-Runde", len(offene), len(facts)) - facts, zweite_usage = await self._eu_nachhaken( + facts, nachhak_usage, nachhak_anfragen = await self._eu_nachhaken( facts=facts, offene=offene, title=title, output_language=output_language, incident_type=incident_type, - avoid_queries=suche.queries, followup_queries=anzahl_nachhak, + avoid_queries=gestellte_anfragen, followup_queries=anzahl_nachhak, ) - else: - bestaetigt = [f for f in facts if f.get("status") in BESTAETIGTE_STATUS] - if not bestaetigt: - return facts, gesamt + _add_usage(gesamt, nachhak_usage) + gestellte_anfragen += nachhak_anfragen + + # Zweiter Schritt: bleibt danach kaum etwas offen, wird das + # geprueft. Ohne diesen Schritt hob die Nachhak-Runde am 02.08.2026 + # alle drei offenen Punkte wieder hoch und der Lauf endete erneut + # ohne eine einzige Unsicherheit. + offene = [f for f in facts if f.get("status") in OFFENE_STATUS] + bestaetigt = [f for f in facts if f.get("status") in BESTAETIGTE_STATUS] + if bestaetigt and len(offene) < EU_VERIFY_FOLLOWUP_MIN_OPEN: logger.info( - "EU-Faktencheck: %d von %d Behauptungen bestaetigt, starte Belastungsprobe", - len(bestaetigt), len(facts)) - facts, zweite_usage = await self._eu_belastungsprobe( + "EU-Faktencheck: nur %d von %d Behauptungen offen, starte Belastungsprobe", + len(offene), len(facts)) + facts, probe_usage = await self._eu_belastungsprobe( facts=facts, bestaetigt=bestaetigt, title=title, output_language=output_language, incident_type=incident_type, - avoid_queries=suche.queries, queries=anzahl_nachhak, + avoid_queries=gestellte_anfragen, queries=anzahl_nachhak, ) - _add_usage(gesamt, zweite_usage) + _add_usage(gesamt, probe_usage) except asyncio.CancelledError: raise except Exception as e: @@ -912,9 +920,14 @@ class FactCheckerAgent: incident_type: str, avoid_queries: list[str], followup_queries: int | None = None, - ) -> tuple[list[dict], ClaudeUsage]: + ) -> tuple[list[dict], ClaudeUsage, list[str]]: """Sucht gezielt Belege fuer die offenen Behauptungen und bewertet nur - diese erneut. Gibt die zusammengefuehrte Faktenliste zurueck.""" + diese erneut. + + Gibt die zusammengefuehrte Faktenliste, den Verbrauch und die gestellten + Anfragen zurueck. Letztere braucht die Belastungsprobe, damit sie nicht + dieselben Anfragen noch einmal stellt. + """ from agents.claude_client import call_claude from agents.eu_researcher import ( plan_verification_searches, build_eu_prompt, _add_usage, @@ -926,7 +939,7 @@ class FactCheckerAgent: kandidaten = offene[:MAX_FACTS_PER_VERIFY_GROUP] claims = [f.get("claim", "") for f in kandidaten if f.get("claim")] if not claims: - return facts, gesamt + return facts, gesamt, [] suche = await plan_verification_searches( title=title, search_items=claims, output_language=output_language, @@ -937,7 +950,7 @@ class FactCheckerAgent: _add_usage(gesamt, suche.usage) if not block: logger.info("EU-Nachhak-Runde: keine zusaetzlichen Belege gefunden, Fakten bleiben unveraendert") - return facts, gesamt + return facts, gesamt, suche.queries status_werte = ("established|unverified|disputed|false|developing" if incident_type == "research" @@ -954,17 +967,17 @@ class FactCheckerAgent: _add_usage(gesamt, usage) except TimeoutError: logger.warning("EU-Nachhak-Runde: Timeout, Fakten bleiben unveraendert") - return facts, gesamt + return facts, gesamt, suche.queries except Exception as e: logger.warning("EU-Nachhak-Runde fehlgeschlagen (%s), Fakten bleiben unveraendert", e) - return facts, gesamt + return facts, gesamt, suche.queries # Korrigiert sich das Modell selbst und haengt eine zweite Fassung an, # zaehlt die inhaltsreichste, nicht die erste. fassungen = extract_json_arrays(result or "") if not fassungen: logger.warning("EU-Nachhak-Runde: Antwort nicht auswertbar, Fakten bleiben unveraendert") - return facts, gesamt + return facts, gesamt, suche.queries aktualisierungen = max(fassungen, key=len) hochgestuft = 0 @@ -1032,7 +1045,7 @@ class FactCheckerAgent: "EU-Nachhak-Runde abgeschlossen: %d von %d offenen Behauptungen belegt", hochgestuft, len(kandidaten), ) - return facts, gesamt + return facts, gesamt, suche.queries async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc", output_language: str = "Deutsch") -> tuple[list[dict], ClaudeUsage | None]: """Führt vollständigen Faktencheck durch (erster Refresh).""" diff --git a/tests/test_eu_factcheck.py b/tests/test_eu_factcheck.py index ce624e5..0e7aa29 100644 --- a/tests/test_eu_factcheck.py +++ b/tests/test_eu_factcheck.py @@ -114,13 +114,16 @@ antworten.append(json_fakten([ 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 ausgeloest", - len(aufrufe["such"]) == 2 and aufrufe["such"][1]["label"] == "Nachhak-Suche") +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", @@ -133,11 +136,12 @@ 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", +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 aufsummiert", round(usage.cost_usd, 3) == 0.12, usage.cost_usd) +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() @@ -199,7 +203,10 @@ pruefe("E1 inkrementell nutzt die Belegsuche", len(aufrufe["such"]) >= 1, aufruf 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("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]) @@ -246,8 +253,10 @@ facts_h, _ = asyncio.run(fc._eu_pruefen( )) 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, +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), @@ -345,6 +354,35 @@ 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([ diff --git a/tests/test_faktencheck_belegtiefe.py b/tests/test_faktencheck_belegtiefe.py index 908ba8a..7e5c8a1 100644 --- a/tests/test_faktencheck_belegtiefe.py +++ b/tests/test_faktencheck_belegtiefe.py @@ -234,7 +234,7 @@ unklar = { } antwort["text"] = ('[{"claim": "Der Auslöser des massenhaften Grenzübertritts ist weiterhin unklar",' ' "status": "confirmed", "evidence": "Vier Quellen nennen die Unklarheit"}]') -facts, _ = asyncio.run(agent._eu_nachhaken( +facts, _, _ = asyncio.run(agent._eu_nachhaken( facts=[unklar], offene=[unklar], title="Ceuta", output_language="Deutsch", incident_type="adhoc", avoid_queries=[], )) @@ -248,7 +248,7 @@ duenn = { } antwort["text"] = ('[{"claim": "Spaniens Außenminister versicherte, die Integrität sei garantiert",' ' "status": "confirmed", "evidence": "Auch hier (https://www.aljazeera.com/b)"}]') -facts, _ = asyncio.run(agent._eu_nachhaken( +facts, _, _ = asyncio.run(agent._eu_nachhaken( facts=[duenn], offene=[duenn], title="Ceuta", output_language="Deutsch", incident_type="adhoc", avoid_queries=[], )) @@ -263,7 +263,7 @@ tragfaehig = { } antwort["text"] = ('[{"claim": "Italien führt Grenzkontrollen für Flüge und Schiffe aus Spanien ein",' ' "status": "confirmed", "evidence": "Bestätigt (https://www.ansa.it/b)"}]') -facts, _ = asyncio.run(agent._eu_nachhaken( +facts, _, _ = asyncio.run(agent._eu_nachhaken( facts=[tragfaehig], offene=[tragfaehig], title="Ceuta", output_language="Deutsch", incident_type="adhoc", avoid_queries=[], ))