diff --git a/CLAUDE.md b/CLAUDE.md index bf7abc2..f0153db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,7 @@ tests/: test_eu_factcheck.py: "EU-Faktencheck, Nachhak-Runde, Quellenzuordnung, plus Regressionsschutz fuer den Anthropic-Weg" test_eu_belegsuche.py: "Gezielte Belegsuche ueber staan, Planung, Grenzen, Entdopplung, Ausfallverhalten" test_qc_und_runden.py: "Absicherung der Duplikatpruefung und Abbruch der Suchrunden bei erreichter Treffergrenze" + test_mehrfachantwort.py: "Antworten, in denen sich das Modell selbst korrigiert und mehrere Fassungen liefert, die ausfuehrlichste muss gewinnen" ``` ## Architektur diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index 47dd134..1f89f18 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -1112,10 +1112,23 @@ Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohn # Haeufigster Bruch (deutsche Zitate mit geradem Schlusszeichen); ohne # diesen Schritt rettete der Regex-Fallback unten nur ein Bruchstueck # und das Lagebild brach still mitten im Satz ab. - from json_utils import extract_json_object as _forgiving_object - repaired_obj = _forgiving_object(response) - if isinstance(repaired_obj, dict) and "summary" in repaired_obj: - return repaired_obj + # Enthaelt die Antwort mehrere Objekte, weil das Modell sich selbst + # korrigiert hat ("Let me redo this properly with all sections"), zaehlt + # das ausfuehrlichste. Sonst landete nur die erste, kurze Fassung in der + # Datenbank und der Bericht verlor fuenf von sechs Abschnitten. + from json_utils import extract_json_objects as _forgiving_objects + kandidaten = [o for o in _forgiving_objects(response) + if isinstance(o, dict) and "summary" in o] + if kandidaten: + bester = max(kandidaten, key=lambda o: len(str(o.get("summary") or ""))) + if len(kandidaten) > 1: + logger.warning( + "Analyse enthielt %d Berichtsfassungen, nehme die ausfuehrlichste " + "(%d statt %d Zeichen)", + len(kandidaten), len(str(bester.get("summary") or "")), + len(str(kandidaten[0].get("summary") or "")), + ) + return bester # Versuch 3: Abgeschnittenes JSON reparieren (haeufig bei langen Antworten) candidate = match.group() if match else cleaned diff --git a/src/agents/factchecker.py b/src/agents/factchecker.py index 2450599..585dbc4 100644 --- a/src/agents/factchecker.py +++ b/src/agents/factchecker.py @@ -595,7 +595,7 @@ class FactCheckerAgent: plan_verification_searches, build_eu_prompt, _add_usage, ) from config import EU_VERIFY_FOLLOWUP_QUERIES - from json_utils import extract_json_array + from json_utils import extract_json_arrays gesamt = ClaudeUsage() kandidaten = offene[:MAX_FACTS_PER_VERIFY_GROUP] @@ -634,10 +634,13 @@ class FactCheckerAgent: logger.warning("EU-Nachhak-Runde fehlgeschlagen (%s), Fakten bleiben unveraendert", e) return facts, gesamt - aktualisierungen = extract_json_array(result or "") - if not isinstance(aktualisierungen, list): + # 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 + aktualisierungen = max(fassungen, key=len) hochgestuft = 0 for eintrag in aktualisierungen: @@ -1060,10 +1063,19 @@ class FactCheckerAgent: # Nicht maskierte Anfuehrungszeichen im Text reparieren (deutsche Zitate # in claim/evidence). Ohne diesen Schritt lieferte der Faktencheck bei # einem einzigen Stoerzeichen still eine leere Liste. - from json_utils import extract_json_array as _forgiving_array - repaired = _forgiving_array(response) - if isinstance(repaired, list): - return self._validate_facts(repaired, articles=articles) + # Liefert das Modell mehrere Fassungen, weil es sich selbst korrigiert + # hat, zaehlt die mit den meisten Fakten. + from json_utils import extract_json_arrays as _forgiving_arrays + fassungen = _forgiving_arrays(response) + if fassungen: + beste = max(fassungen, key=len) + if len(fassungen) > 1: + logger.warning( + "Faktencheck-Antwort enthielt %d Fassungen, nehme die mit den " + "meisten Fakten (%d statt %d)", + len(fassungen), len(beste), len(fassungen[0]), + ) + return self._validate_facts(beste, articles=articles) logger.warning( "Konnte Faktencheck-Antwort nicht als JSON parsen (Laenge: %d, Anfang: %r)", diff --git a/src/json_utils.py b/src/json_utils.py index 3ebe16f..9fce287 100644 --- a/src/json_utils.py +++ b/src/json_utils.py @@ -170,6 +170,59 @@ def _extract_block(text: str, opener: str, closer: str, want: type) -> Any | Non return None +def _extract_all_blocks(text: str, opener: str, closer: str, want: type) -> list: + """Alle vollstaendigen JSON-Bloecke der gewuenschten Art, in Reihenfolge. + + Modelle korrigieren sich gelegentlich selbst und haengen nach einer ersten + Antwort eine zweite, vollstaendigere an, etwa mit einem Hinweis wie "Let me + redo this properly". Wer nur den ersten Block nimmt, verliert die bessere + Fassung. + """ + if not text: + return [] + for candidate in (text, repair_json_text(text)): + decoder = json.JSONDecoder() + gefunden = [] + idx = 0 + while True: + start = candidate.find(opener, idx) + if start == -1: + break + try: + obj, ende = decoder.raw_decode(candidate, start) + except (json.JSONDecodeError, ValueError): + idx = start + 1 + continue + if isinstance(obj, want): + gefunden.append(obj) + idx = ende + else: + idx = start + 1 + if gefunden: + return gefunden + if candidate is not text: + break + return [] + + +def extract_json_objects(text: str) -> list[dict]: + """Alle vollstaendigen JSON-Objekte im Text, mit Reparatur-Rueckfall.""" + cleaned = _strip_fences(text) + direct = loads_forgiving(cleaned) + if isinstance(direct, dict): + return [direct] + return _extract_all_blocks(text, "{", "}", dict) + + +def extract_json_arrays(text: str) -> list[list]: + """Alle vollstaendigen JSON-Arrays im Text, mit Reparatur-Rueckfall.""" + cleaned = _strip_fences(text) + direct = loads_forgiving(cleaned) + if isinstance(direct, list): + return [direct] + return _extract_all_blocks(text, "[", "]", list) + + def extract_json_object(text: str) -> dict | None: """Erstes vollstaendiges JSON-Objekt im Text, mit Reparatur-Rueckfall.""" cleaned = _strip_fences(text) diff --git a/tests/test_mehrfachantwort.py b/tests/test_mehrfachantwort.py new file mode 100644 index 0000000..7dff916 --- /dev/null +++ b/tests/test_mehrfachantwort.py @@ -0,0 +1,107 @@ +"""Testet den Umgang mit Antworten, die mehrere Fassungen enthalten. + +Modelle korrigieren sich gelegentlich selbst und haengen nach einer ersten, +knappen Antwort eine zweite, vollstaendige an. Wer nur die erste nimmt, +verliert den Grossteil des Berichts. Genau das ist in Lage 49 und 50 passiert, +dort blieb von sechs Abschnitten nur einer uebrig. + +Laeuft ohne Netzzugriff und ohne Kosten. + +Aufruf aus dem Projektstamm: + venv/bin/python tests/test_mehrfachantwort.py +""" +import os +import re +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) + +from json_utils import ( # noqa: E402 + extract_json_object, extract_json_objects, extract_json_array, extract_json_arrays, +) +from agents.analyzer import AnalyzerAgent # noqa: E402 +from agents.factchecker import FactCheckerAgent # 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)) + + +KURZ = "## ZUSAMMENFASSUNG\\n\\n- Ein einzelner Punkt." +LANG = ("## ZUSAMMENFASSUNG\\n\\n- Punkt eins.\\n\\n" + "## HINTERGRUND\\n\\nDie Vorgeschichte.\\n\\n" + "## AKTEURE\\n\\nDie Beteiligten.\\n\\n" + "## AKTUELLE LAGE\\n\\nDer Stand.\\n\\n" + "## EINSCHÄTZUNG\\n\\nDie Bewertung.\\n\\n" + "## QUELLENQUALITÄT\\n\\nDie Belege.") + +SELBSTKORREKTUR = ( + '```json\n{\n "summary": "' + KURZ + '",\n "sources": [{"nr": 1}]\n}\n```\n\n' + 'Wait, I need to include the full briefing content in the summary field. ' + 'Let me redo this properly with all sections.\n\n' + '```json\n{\n "summary": "' + LANG + '",\n "sources": [{"nr": 1}, {"nr": 2}]\n}\n```' +) + +print("\nA) Alle Fassungen werden gefunden") +objekte = extract_json_objects(SELBSTKORREKTUR) +pruefe("A1 beide Fassungen erkannt", len(objekte) == 2, len(objekte)) +pruefe("A2 erste Fassung ist die knappe", + "HINTERGRUND" not in objekte[0]["summary"]) +pruefe("A3 zweite Fassung ist die vollstaendige", + "QUELLENQUALITÄT" in objekte[1]["summary"]) +pruefe("A4 Einzelabfrage liefert weiterhin die erste", + "HINTERGRUND" not in extract_json_object(SELBSTKORREKTUR)["summary"]) + +print("\nB) Der Analyzer waehlt die vollstaendige Fassung") +bericht = AnalyzerAgent()._parse_response(SELBSTKORREKTUR) +pruefe("B1 Objekt erhalten", isinstance(bericht, dict)) +abschnitte = re.findall(r"## *([A-ZÄÖÜ ]{4,20})", bericht.get("summary", "")) +pruefe("B2 alle sechs Abschnitte enthalten", len(abschnitte) == 6, abschnitte) +pruefe("B3 Quellen der gewaehlten Fassung", len(bericht.get("sources", [])) == 2, + bericht.get("sources")) + +print("\nC) Eine einzelne saubere Antwort bleibt unveraendert") +einfach = '{"summary": "' + LANG + '", "sources": [{"nr": 1}]}' +b2 = AnalyzerAgent()._parse_response(einfach) +pruefe("C1 unveraendert geparst", isinstance(b2, dict) and "QUELLENQUALITÄT" in b2["summary"]) +pruefe("C2 nur eine Fassung gefunden", len(extract_json_objects(einfach)) == 1) + +print("\nD) Dasselbe fuer Faktenlisten") +FAKTEN_KURZ = '[{"claim": "Nur ein Fakt", "status": "confirmed", "evidence": "e"}]' +FAKTEN_LANG = ('[{"claim": "Erster Fakt", "status": "confirmed", "evidence": "e"},' + ' {"claim": "Zweiter Fakt", "status": "confirmed", "evidence": "e"},' + ' {"claim": "Dritter Fakt", "status": "confirmed", "evidence": "e"}]') +FC_KORREKTUR = (FAKTEN_KURZ + "\n\nMoment, ich habe Fakten vergessen. Hier vollstaendig:\n\n" + + FAKTEN_LANG) +listen = extract_json_arrays(FC_KORREKTUR) +pruefe("D1 beide Listen erkannt", len(listen) == 2, len(listen)) +pruefe("D2 Einzelabfrage liefert weiterhin die erste", + len(extract_json_array(FC_KORREKTUR)) == 1) +fakten = FactCheckerAgent()._parse_response(FC_KORREKTUR) +pruefe("D3 Faktencheck nimmt die vollstaendige Liste", len(fakten) == 3, len(fakten)) + +print("\nE) Unbrauchbare Antworten bleiben unbrauchbar") +pruefe("E1 Fliesstext liefert keine Objekte", extract_json_objects("Nur Text") == []) +pruefe("E2 Fliesstext liefert keine Listen", extract_json_arrays("Nur Text") == []) +pruefe("E3 leerer Text ist unkritisch", + extract_json_objects("") == [] and extract_json_arrays("") == []) + +print("\nF) Kaputte erste Fassung, brauchbare zweite") +KAPUTT = ('{"summary": "abgeschnitten ' + "\n\n" + + '{"summary": "' + LANG + '", "sources": []}') +objekte_f = extract_json_objects(KAPUTT) +pruefe("F1 die brauchbare Fassung wird gefunden", + any("QUELLENQUALITÄT" in str(o.get("summary", "")) for o in objekte_f), + len(objekte_f)) + +print("\nErgebnis: " + str(ok) + " bestanden, " + str(fail) + " fehlgeschlagen") +sys.exit(1 if fail else 0)