"""Ebene 4: Guide. Eine Section pro Baustein, Stages seriell je Karte:
writer → pruefer → fix → done (persistiert in sections.stage, resume-fähig).
Der Writer bekommt die komplette Faktenbasis INLINE (Definitionen, Anker-Zitate,
verifizierte Beispiele — Lektion 48) und muss Marker als unsichtbare Invariante
setzen (Lektion 57). Der Prüfer ist EIN verschmolzener Call (Fakten+Coverage,
Lektion 52); deterministische Checks laufen davor im Code."""
import asyncio
import json
import logging
import os
import re
import subprocess
from pathlib import Path
import db
import jsonx
import llm
import belege
import textkit
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, KLAERUNG_PANEL,
LEVEL_RANG, SECTION_WOERTER_PRO_ATOM)
log = logging.getLogger("creator2.guide")
_KATEX_SKRIPT = Path(__file__).resolve().parent / "katex_check.mjs"
_KATEX_BASE = Path(__file__).resolve().parent.parent / "frontend"
_katex_gewarnt = False
def _katex_fehler(formeln: list[dict]) -> list[str]:
"""Parse-Gate: Formeln durch DENSELBEN Renderer wie das Frontend jagen.
Regex-Checks jagen eine offene Menge (heute \\textsc, morgen der nächste
unbekannte Befehl) — das Gate schließt die Klasse. Fake-Modus überspringt
(deterministisch + schnell); Unit-Test ruft direkt."""
global _katex_gewarnt
if not formeln or os.getenv("CREATOR_FAKE_AGENTS"):
return []
try:
res = subprocess.run(
["node", str(_KATEX_SKRIPT)], input=json.dumps(formeln),
capture_output=True, text=True, timeout=30,
env={**os.environ, "KATEX_BASE": str(_KATEX_BASE)})
if res.returncode:
raise RuntimeError(res.stderr.strip()[:200])
return json.loads(res.stdout)
except Exception as e: # nie fail-open OHNE Signal (Befund-Prinzip)
if not _katex_gewarnt:
_katex_gewarnt = True
log.warning("KaTeX-Gate nicht verfügbar (%s) — Formel-Validierung fällt aus", e)
return []
EBENE = "guide"
_MARKER = re.compile(r"")
_MARKER_BEISPIEL = re.compile(r"")
_BEISPIEL_BESCHR = {"code": "Code-Beispiel", "tabelle": "Tabelle/Vergleich"}
_VERBATIM_FORMEN = ("code", "tabelle")
def _ohne_marker(text: str) -> str:
"""Marker-Zeilen entfernen: sie sind Backend-Schnittstelle, kein Fließtext.
ALLE Text-Checks (Stil, Mathe, Länge, Vorwärtsverweise) laufen auf dem
gestrippten Text — sonst erzeugen Atom-TITEL wie „a_n" oder „Satz 6.26"
unfixierbare Aufträge (der Fix darf Marker nie ändern → Endlos-Loop).
Beispiel-Marker gehören ebenso raus."""
return _MARKER_BEISPIEL.sub("", _MARKER_VOLL.sub("", text))
def _beispiel_verbatim(atom_id: int) -> list[tuple[int, dict]]:
"""Verifizierte Beispiele des Atoms in VERBATIM-Form (code/tabelle) —
(artefakt_id, inhalt). Woven-Formen (text/mathe) gehen über `_beispiel`."""
out = []
for r in db.query("SELECT id, inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status='verifiziert'", (atom_id,)):
inh = db.uj(r["inhalt"], {})
if inh.get("form") in _VERBATIM_FORMEN:
out.append((r["id"], inh))
return out
def _beispiel_render(inh: dict) -> str:
"""Verbatim-Beispiel → Markdown (Code-Fence / Tabelle)."""
form = inh.get("form")
if form == "code" and inh.get("code"):
return f"```{inh.get('sprache', '')}\n{inh['code']}\n```"
if form == "tabelle" and inh.get("tabelle"):
return inh["tabelle"]
return ""
def _marker_platzieren(b: dict, lang: str) -> str:
"""Verbatim-Marker DETERMINISTISCH setzen (der Writer tut es nicht mehr — er
ignorierte die Regel, 2/13 platziert). Beispiel-Marker hinter den Atom-Absatz
(atom-genau via Atom-Marker). Idempotent: alte Verbatim-Marker erst raus."""
lang = _MARKER_BEISPIEL.sub("", lang)
je_atom = {}
for a in _atome_von(b["id"]):
vs = _beispiel_verbatim(a["id"])
if vs:
je_atom[a["id"]] = vs[0][0] # erstes Verbatim-Beispiel des Atoms
if je_atom:
out, pending = [], None
for z in lang.split("\n"):
out.append(z)
m = _MARKER.search(z)
if m and int(m.group(1)) in je_atom:
pending = je_atom.pop(int(m.group(1)))
elif pending is not None and z.strip() == "": # Absatzende → Marker als Block
out += [f"", ""]
pending = None
if pending is not None:
out += ["", f""]
lang = "\n".join(out)
return lang
def _beispiel_einsetzen(baustein_id: int, lang: str) -> str:
"""Assembly: `` → gerendertes Verbatim-Beispiel; tote Marker weg."""
if "`-Marker rein."""
row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status='verifiziert' LIMIT 1", (atom_id,))
if not row:
return ""
inh = db.uj(row["inhalt"], {})
return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else ""
def _quote(block: str) -> str:
return "\n".join(f"> {l}" for l in block.splitlines())
def _atom_paket(a: dict) -> str:
zitate = "\n".join(_quote(z) for z in _zitate(a["id"])) or "(kein Zitat)"
beispiel = _beispiel(a["id"])
teil = (f"MARKER (exakt so übernehmen): \n"
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
if beispiel:
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
return teil
def _laenge_band(n_atome: int) -> tuple[int, int]:
lo, hi = SECTION_WOERTER_PRO_ATOM
return lo * n_atome, hi * n_atome
def _anknuepf_kontext(b: dict) -> str:
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
desselben Ziels — aber NIE Atome dieses Bausteins selbst (seit Level =
dominantes Atom-Level enthält ein M-Baustein auch eigene E-Atome; die
stünden sonst doppelt im Writer-Prompt: als bekannt UND als zu schreiben)."""
rang = LEVEL_RANG.get(b["level"], 1)
kontext: dict[int, dict] = {}
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
" ('gemerged','verworfen')", (b["ziel_id"],)):
if a["baustein_id"] != b["id"] and LEVEL_RANG.get(a["level"], 1) < rang:
kontext[a["id"]] = a
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
# ── Stages ────────────────────────────────────────────────────────────────────
def _writer_saeubern(s: str) -> str:
"""Geleakte Writer-Delimiter/Modelltokens entfernen. Der Writer beendet seine
Ausgabe mit `=====`; steht diese Zeile unter einem Bullet, ist sie eine
Setext-H1-Unterstreichung → marked macht daraus ein riesiges
. Reine
`===…`-Zeilen und ``/``-Tokens fallen daher ganz raus."""
s = re.sub(r"?s>", "", s or "")
s = re.sub(r"(?m)^\s*={3,}\s*$", "", s)
return s.strip()
def _split_writer(text: str) -> tuple[str, str]:
"""Delimited-Ausgabe des Writers/Fix parsen: ===LANG===… / ===KOMPAKT===….
Grund: rohe LaTeX-Backslashes im Text sind invalides JSON (34 % Parse-Fehler).
Robust: Blöcke unabhängig von der Reihenfolge; fehlt der Delimiter, jsonx-Fallback
(alt-JSON), sonst ganzer Text = lang. Beide Blöcke werden von geleakten
Terminatoren (`=====`/``) gesäubert."""
def block(name: str) -> str | None:
m = re.search(rf"===\s*{name}\s*===\s*(.*?)(?=\n===\s*[A-ZÄÖÜ]+\s*===|$)",
text or "", re.S)
return _writer_saeubern(m.group(1)) if m else None
lang, kompakt = block("LANG"), block("KOMPAKT")
if lang is not None or kompakt is not None:
return kompakt or "", lang or ""
daten = jsonx.parse(text or "") # Fallback: altes JSON-Format
if isinstance(daten, dict):
return (_writer_saeubern(str(daten.get("kompakt", ""))),
_writer_saeubern(str(daten.get("lang", ""))))
return "", _writer_saeubern(text or "")
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
lo, hi = _laenge_band(len(atome))
werte = {"titel": b["titel"], "ziel": ziel["text"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi,
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]),
"kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"}
res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte,
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=str)
if res is None:
return "writer" # Stage bleibt, nächster Lauf versucht erneut
kompakt, lang = _split_writer(res)
fehlend = _marker_fehlend(lang, atome)
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
schritt="writer", role="guide", n=len(atome),
item=f"b{b['id']}-2", werte=werte, erwartet=str)
if res2:
k2, l2 = _split_writer(res2)
if not _marker_fehlend(l2, atome):
kompakt, lang = k2, l2
lang = _marker_platzieren(b, lang) # Beispiel-Marker deterministisch setzen
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
return "pruefer"
def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
da = {int(m) for m in _MARKER.findall(text)}
return [a["id"] for a in atome if a["id"] not in da]
def _marker_fremd_ids(text: str, atome: list[dict]) -> list[int]:
"""Marker fremder Atome (z. B. aus dem Anknüpf-Kontext übernommen)."""
eigene = {a["id"] for a in atome}
return sorted({int(m) for m in _MARKER.findall(text)} - eigene)
_FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))"
r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE)
_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)"
r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b")
# ohne deutsche Homographen (an/will/not/or/is) — die flaggten deutschen Text
_EN_STOP = frozenset("the and of that this are with for as by be from which were"
" has have can its when each such".split())
def _stil_auftraege(text: str, wo: str) -> list[str]:
"""Deterministische Lesbarkeits-Checks je Fassung: Vertröstungs-Floskeln
(der alte Vorwärts-Fix klebte „dazu später mehr“ ein statt zu erklären),
Quellen-Referenzen (Satz 6.25 — der Leser hat kein nummeriertes Skript)
und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen).
Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst
erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop)."""
text = _ohne_marker(text)
auftraege = []
for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}):
auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff"
f" stattdessen kurz mit Bekanntem einordnen oder die Stelle"
f" entfernen.")
for m in sorted({m.group(0) for m in _LEAK.finditer(text)}):
auftraege.append(f"{wo}: Quellen-Referenz „{m}“ — der Leser kennt kein"
f" nummeriertes Skript und keine Aufgabenstellung; Inhalt"
f" eigenständig formulieren.")
for absatz in text.split("\n\n"):
toks = re.findall(r"[a-zA-Z']+", absatz.lower())
treffer = {t for t in toks if t in _EN_STOP} # verschiedene Stopwörter
# ≥3 VERSCHIEDENE Treffer zusätzlich zur Quote — ein deutscher Satz mit
# zufällig zwei englischen Wörtern reicht nicht mehr für einen Fehlalarm
if len(toks) >= 12 and len(treffer) >= 3 \
and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15:
kurz = " ".join(absatz.split())[:60]
auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche"
f" übersetzen — etablierte Fachtermini dürfen bleiben.")
return auftraege
def _mathe_auftraege(text: str, wo: str) -> list[str]:
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
Lang-Fassungen betroffen). Marker vorher strippen — Atom-Titel wie „a_n" oder
„A*-Suche" trafen sonst die Formelreste-Checks unfixierbar (der Fix darf den
Marker nicht ändern → Endlos-Loop)."""
text = _ohne_marker(text)
auftraege = []
for nr, absatz in enumerate(text.split("\n\n"), 1):
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
auftraege.append(f"{wo}, Absatz {nr}: ungerade Anzahl $-Zeichen — jede"
f" Formel braucht öffnendes UND schließendes $.")
ohne = re.sub(r"\$\$[\s\S]*?\$\$|\$[^$\n]*\$", "", text)
nackt = sorted(set(re.findall(
r"\\(?:times|Sigma|Gamma|subseteq|neq|leq|geq|cup|cap|mid|forall|exists"
r"|mathbb|frac|text|dots|ldots|quad|qquad|bar|setminus)\b", ohne)))
if nackt: # KaTeX rendert nur innerhalb von $…$
auftraege.append(f"{wo}: LaTeX ohne $-Delimiter ({', '.join(nackt[:5])}…):"
f" jede Formel vollständig in $…$ bzw. $$…$$ einschließen.")
reste = sorted(set(re.findall(
r"\w+_\{[^}]*\}|\w+\^\{[^}]*\}|\w+\^\([^)]*\)|\b\w+_[a-z0-9]\b", ohne)))
if reste:
auftraege.append(f"{wo}: Formeln im Klartext ({', '.join(reste[:4])}…):"
f" auch Indizes/Potenzen gehören in $…$ (x_i → $x_i$).")
betraege = sorted(set(re.findall(r"\|[A-Za-z][^|\n]{0,8}\|\*?", ohne)))
if betraege: # |C*| im Klartext: Markdown deutet * als Kursiv-Start
auftraege.append(f"{wo}: Beträge im Klartext ({', '.join(betraege[:4])}…):"
f" in $…$ setzen ($|C^*|$) — sonst kollidiert * mit Markdown.")
for z in ohne.splitlines(): # einzelnes * = Kursiv-Unfall (nur **fett** erlaubt)
z = re.sub(r"^\s*[*-]\s", "", z)
if re.search(r"(? 60]
if lange: # Inline-Formeln brechen nie um — auf schmalen Screens Überlauf
auftraege.append(f"{wo}: {len(lange)} überlange Inline-Formel(n)"
f" (>60 Zeichen, z. B. „{lange[0][:40]}…“): abgesetzt"
f" als $$…$$ setzen.")
formeln = ([{"tex": m, "display": True} for m in re.findall(r"\$\$([\s\S]+?)\$\$", text)]
+ [{"tex": m, "display": False} for m in re.findall(r"\$([^$\n]+?)\$", text)])
for f in _katex_fehler(formeln):
auftraege.append(f"{wo}: Formel rendert nicht — {f}. In gültiges KaTeX"
f" umschreiben (Standardbefehle, kein LaTeX-Textsatz).")
return auftraege
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "",
qa: bool = False) -> list[str]:
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise,
Lesbarkeit. qa=True (Ebenen-QA): Marker/Länge/Vorwärts haben dort eigene
Befund-Arten und ein weiteres Band (Lektion 75) — nur die Stil-Checks."""
atome = _atome_von(b["id"])
auftraege = []
if not qa:
auftraege += [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
for i in _marker_fehlend(lang, atome)]
auftraege += [f"KRITISCH: Marker {i} gehört nicht zu diesem Baustein"
f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen."
for i in _marker_fremd_ids(lang, atome)]
lo, hi = _laenge_band(len(atome))
woerter = len(_ohne_marker(lang).split()) # Marker-Tokens zählen nicht mit
if woerter > hi:
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
elif woerter < lo:
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
# Auftrag an den Detektor angleichen: der prüft nur Titel-PRÄSENZ, also kann
# „erklären + behalten" den Befund nie schließen — nur Entfernen/Umschreiben.
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):"
f" den späteren Fachbegriff entfernen oder die Stelle ohne ihn"
f" umschreiben (mit bereits eingeführten Begriffen)."
for t in _vorwaertsverweise(topic, b, lang)]
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen"
f" Worten in den Fließtext einarbeiten, Zitat-Format entfernen.")
if "6=" in lang:
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
" durch $\\neq$ ersetzen.")
for nr, absatz in enumerate(lang.split("\n\n"), 1):
# Marker-Zeilen zählen nicht — sie stehen im selben Block wie der Absatz
zeilen = [z for z in absatz.splitlines()
if z.strip() and not z.strip().startswith(""
f" entfernen (kein solches verifiziertes Beispiel)"})
for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
befunde.append({"art": "det_check", "item": str(b["id"]),
"detail": auftrag[:300]})
# Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
# Gate. Eskalierte dreiteilig: ungeklärt → klaerung_offen (reparierbar,
# Stichentscheid), geklärt → fakten_konflikt (terminal, kleine ehrliche
# Rest-Schuld), Stil → stil_rest (terminal, war vorher komplett stumm).
for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
" AND status='offen'", (b["id"],)):
befunde.append({"art": "auftrag_offen", "item": str(b["id"]),
"detail": f"({a['art']}) {a['detail']}"[:300]})
for a in db.query("SELECT art, detail, geklaert FROM auftraege WHERE"
" baustein_id=? AND status='eskaliert'", (b["id"],)):
if a["art"] == "stil":
art = "stil_rest"
elif a["geklaert"]:
art = "fakten_konflikt"
else:
art = "klaerung_offen"
befunde.append({"art": art, "item": str(b["id"]),
"detail": a["detail"][:300]})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
noetig = _stopfrei(ziel["text"])
# Writer formuliert per Design frei — Schwelle 1/3 statt 1/2, und
# kompakt + Titel zählen mit (Ziel-Wörter erscheinen paraphrasiert).
da = textkit.tokens(f"{b['titel']} {sec['text_kompakt']} {lang}")
if noetig and len(noetig & da) / len(noetig) < 0.34:
befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]),
"detail": ziel["text"][:120]})
lo, hi = _laenge_band(len(atome))
w = len(_ohne_marker(lang).split()) # marker-frei — konsistent mit dem Fix-Band
if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75)
befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"})
for t in _vorwaertsverweise(ctx.topic, b, lang):
befunde.append({"art": "vorwaerts", "item": str(b["id"]), "detail": t})
return befunde
def _qa_auftrag(b_id: int, claim: str) -> None:
"""Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup
gegen alles Nicht-Behobene; trifft er eine offene ODER kein_mangel-Zeile,
widersprechen sich zwei Panels → eskaliert = ab in den Klärungs-Stichentscheid.
geklaert wird NIE zurückgesetzt: war der Auftrag schon geklärt, ist das
terminal (deckelt QA↔Klärung-Ping-Pong hart auf einen Durchlauf)."""
alt = db.one("SELECT id, status FROM auftraege WHERE baustein_id=? AND detail=?"
" AND status != 'behoben'", (b_id, claim))
if alt is None:
db.insert("auftraege", baustein_id=b_id, art="falsch", detail=claim, quelle="qa")
elif alt["status"] in ("offen", "kein_mangel"):
db.update("auftraege", "id", alt["id"], status="eskaliert")
async def messen_llm(ctx: llm.Kontext) -> list[dict]:
"""Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger —
nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections
werden übersprungen (qa_hash) — die Prüfung lief sonst jede Repair-Iteration
über ALLE Sections (gemessen: 806 QA-Calls für 50 Sections)."""
import hashlib
ctx.ebene = EBENE
befunde = []
def hash_setzen(bid: int, h: str) -> None:
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, bid))
async def eine(b: dict) -> None:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
if not sec or not sec["text_lang"]:
return
h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12]
if h == sec["qa_hash"]:
return # unverändert und sauber geprüft — die auftraege-Tabelle ist
# das persistente Gedächtnis bestätigter Claims (kein Re-Check nötig)
# qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall
# bleibt die Section ungeprüft (fail-closed statt fail-open).
atome = _atome_von(b["id"])
fakten = "\n".join(f"- {a['titel']}: "
+ " | ".join(z.replace("\n", " ") for z in _zitate(a["id"], 2))
for a in atome)
verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch",
schritt="qa_judge", role="judge", item=f"b{b['id']}",
werte={"fakten": fakten, "text": sec["text_lang"]},
erwartet=list)
if verdacht is None:
return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash
# dedupen — ein doppelt gemeldeter Claim eskalierte sonst im selben
# Durchlauf seine eigene frisch eingefügte Zeile
claims = list(dict.fromkeys(str(c.get("claim", "")).strip() for c in verdacht
if isinstance(c, dict) and c.get("claim")))
if not claims:
hash_setzen(b["id"], h) # sauber geprüft
return
stimmen = await llm.panel(ctx, 2, stage="qa_falsch_check", template="QA-Guide-Falsch-Check",
schritt="qa_judge", role="judge", item=f"b{b['id']}",
werte={"fakten": fakten,
"claims": "\n".join(f"{i + 1}: {c}"
for i, c in enumerate(claims))},
erwartet=list)
if not stimmen:
return # Panel-Ausfall → kein Hash, nächste Runde prüft erneut
for i, claim in enumerate(claims, 1):
ja = sum(1 for s in stimmen for e in s
if isinstance(e, dict) and e.get("claim") == i and e.get("falsch"))
if ja >= 2:
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
"detail": claim[:200]})
# sofort persistieren (resume-sicher: nur DIESER Moment kennt den
# bestätigten Claim; qa_hash ist danach gesetzt → nie re-geprüft)
_qa_auftrag(b["id"], claim)
hash_setzen(b["id"], h) # vollständig geprüft
await llm.alle(eine(b) for b in _bausteine(ctx.topic))
return befunde
def _text_sig(b_id: int) -> str:
import hashlib
s = db.one("SELECT text_kompakt, text_lang FROM sections WHERE baustein_id=?", (b_id,))
if not s:
return ""
roh = (s["text_kompakt"] or "") + "\x00" + (s["text_lang"] or "")
return hashlib.sha256(roh.encode()).hexdigest()[:16]
def _auftrag_stand(b_ids: list[int]) -> set:
"""Zustands-Snapshot der Aufträge dieser Bausteine. Status- und geklaert-
Übergänge sind Fortschritt (auch ohne Textänderung); ein reines runden-
Inkrement zählt NICHT — identisches Panel-Patt auf identischem Text ist
per Definition Leer-Churn und hielt auto_loop sonst bis LOOP_MAX_ITER am
Leben. Echte Urteilsrunden schließen/eskalieren etwas → Statuswechsel."""
if not b_ids:
return set()
marks = ",".join("?" * len(b_ids))
return {(r["id"], r["status"], r["geklaert"]) for r in db.query(
f"SELECT id, status, geklaert FROM auftraege WHERE baustein_id IN ({marks})",
tuple(b_ids))}
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
"""Kritisches + offene Aufträge → zurück auf pruefer (Urteil/Re-Check mit
vollem Kontext); reine det-Stil-Befunde → direkt in den Fix (genau ein
Rewrite). Rückgabe ist ehrlich: True nur bei Textänderung ODER Auftrag-
Statusübergang — sonst erkennt auto_loop nie Stillstand."""
ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {}
klaerbar: set[int] = set()
for b in befunde:
if b["art"] in ("fakten_konflikt", "stil_rest"):
continue # terminal: geklärt-unauflösbar bzw. Stil-Rest — keine Route
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
if b["art"] == "klaerung_offen":
klaerbar.add(b_id) # Stichentscheid statt Stage-Routing
continue
betroffen.setdefault(b_id, []).append(b)
if not betroffen and not klaerbar:
return False
alle_ids = list(betroffen.keys() | klaerbar)
text_vorher = {b_id: _text_sig(b_id) for b_id in alle_ids}
stand_vorher = _auftrag_stand(alle_ids)
# Klärung VOR dem Routing: text_falsch öffnet Aufträge wieder → die routen
# unten in den Fix; unbegruendet/quelle_unklar brauchen keine Stage.
if klaerbar:
je_baustein = {bb["id"]: bb for bb in _bausteine(ctx.topic)}
await llm.alle(_klaeren(ctx, je_baustein[b_id])
for b_id in sorted(klaerbar) if b_id in je_baustein)
geroutet: list[int] = []
for b_id, liste in betroffen.items():
arten = {x["art"] for x in liste}
if "section_fehlt" in arten:
db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
elif arten & ({"auftrag_offen"} | set(KRITISCH)):
# Urteile sind der Fortschrittsmechanismus — der Fix-Freeze gilt hier
# NIE, sonst wären Aufträge gefrorener Sections wieder unsterblich.
db.update("sections", "baustein_id", b_id, stage="pruefer")
else:
# Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird für
# det-Stil-Befunde eingefroren. Rest-Schuld (Gewicht 0.5) akzeptiert.
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,))
if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE:
continue
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
for b_id in sorted(klaerbar):
# wiedereröffnete Aufträge → direkt in den Fix (der Re-Check urteilt danach;
# umgeht den Freeze bewusst — das Streich-Mandat erzeugt eine Textänderung)
if b_id not in geroutet and _offene_auftraege(b_id):
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
await bauen(ctx)
bewegt = _auftrag_stand(alle_ids) != stand_vorher
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
if _text_sig(b_id) != text_vorher[b_id]:
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))
bewegt = True
else:
db.execute("UPDATE sections SET fix_versuche=fix_versuche+1 WHERE baustein_id=?",
(b_id,))
return bewegt