fix(analyse): vollstaendigen Bericht nehmen, wenn das Modell sich selbst korrigiert
Der Recherchebericht der EU-Fassung enthielt seit zwei Laeufen nur noch den
Abschnitt ZUSAMMENFASSUNG, 1513 statt der ueblichen mehreren tausend Zeichen.
Die Abschnitte HINTERGRUND, AKTEURE, AKTUELLE LAGE, EINSCHAETZUNG und
QUELLENQUALITAET fehlten vollstaendig.
Ursache. Das Modell lieferte zuerst ein knappes JSON, bemerkte den Fehler
selbst ("Wait, I need to include the full briefing content in the summary
field. Let me redo this properly with all sections") und haengte danach eine
zweite, vollstaendige Fassung an. Unsere Auswertung nahm das erste Objekt und
verwarf alles danach. Der Bericht war also da, wir haben ihn weggeworfen.
Behoben. json_utils bekommt extract_json_objects und extract_json_arrays, die
alle vollstaendigen Bloecke einer Antwort liefern statt nur den ersten. Der
Analyzer waehlt daraus den Bericht mit dem laengsten summary, der Faktencheck
die Liste mit den meisten Fakten. Beide protokollieren, wenn mehrere Fassungen
auftauchen, damit der Fall sichtbar bleibt. Die bisherigen Einzelabfragen
extract_json_object und extract_json_array bleiben unveraendert, damit sich an
den uebrigen Aufrufstellen nichts aendert.
Gegenprobe an der echten Antwort aus Lauf 50. Vorher 1513 Zeichen mit einem
Abschnitt, jetzt 8189 Zeichen mit allen sechs.
Neu sind 16 Pruefungen, insgesamt laufen 96 ohne Netzzugriff und ohne Kosten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
107
tests/test_mehrfachantwort.py
Normale Datei
107
tests/test_mehrfachantwort.py
Normale Datei
@@ -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)
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren