Files
creator3/backend/guide.py
2026-07-24 11:23:18 +02:00

505 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Stufe 4: Writer (DELIMITED, „bekannt"-Kontext, Verweis statt Wiederholung)
→ deterministische Prüfung → 2er-Prüfer-Panel (qa_hash, fail-closed) → Fix
(Cap 3, Klärung 3er einmalig). Gate re-prüft die harten Checks selbst."""
import hashlib
import re
from . import arten, belege, config, db, engine, graph, llm, panels, textkit
_MARKER_RE = re.compile(r"<!--\s*atom:\s*(\d+)\s*-->")
def _baustein(bid: int) -> dict:
return db.one("SELECT * FROM bausteine WHERE id=?", bid)
def _atome(bid: int) -> list[dict]:
return db.query("SELECT * FROM atome WHERE baustein_id=? AND status='aktiv' "
"ORDER BY ord", bid)
def _band(n_atome: int) -> tuple[int, int]:
lo, hi = config.SECTION_WOERTER_PRO_ATOM
return lo * n_atome, hi * n_atome
def _slug(titel: str) -> str:
s = textkit.norm(titel).replace(" ", "-")
return "".join(c for c in s if c.isalnum() or c == "-") # Unicode-fähig
def _bekannt_kontext(topic: str, baustein: dict) -> str:
"""Bereits behandelte Atome (frühere Kapitel/Bausteine) als Linkziele."""
fruehere = db.query(
"SELECT a.titel, b2.titel bt FROM atome a JOIN bausteine b2 ON "
"b2.id=a.baustein_id JOIN kapitel k2 ON k2.id=b2.kapitel_id "
"JOIN bausteine b ON b.id=? JOIN kapitel k ON k.id=b.kapitel_id "
"WHERE a.topic=? AND a.status='aktiv' AND "
"(k2.ord < k.ord OR (k2.ord=k.ord AND b2.ord < b.ord))",
baustein["id"], topic)
if not fruehere:
return "-"
return "\n".join(
config.TEXTE["abschnitt_verweis"].format(titel=f["titel"],
abschnitt=f["bt"])
for f in fruehere[:60])
@engine.worker("guide.writer")
async def writer(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
bid = payload["baustein_id"]
b = _baustein(bid)
atome = _atome(bid)
if not atome:
return engine.Ergebnis(daten={"skip": True})
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
if sec and sec["text"] and sec["stage"] != "writer" and \
not payload.get("neu"): # Resume-Skip — außer bei Neuschreiben
return engine.Ergebnis(daten={"skip": True}, neue_tasks=[
{"knoten": "det_pruefung", "item": task["item"].replace("baustein", "det")}])
# Prosa-Form statt Feldnamen — „TITEL:/DEFINITION:" leakte 41× in Guides
atome_text = "\n".join(
f"<!-- atom: {a['id']} -->\n**{a['titel']}** — {a['definition']}"
for a in atome)
lo, hi = _band(len(atome))
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"],
role="writer",
skill_namen=graph.skills_von(task["knoten"]),
werte={"baustein": b["titel"], "atome": atome_text,
"belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000],
"bekannt": _bekannt_kontext(task["topic"], b),
"band": f"{lo}-{hi}"})
if antwort is None:
raise RuntimeError("writer ohne Ergebnis")
text = textkit.block_text(antwort, "TEXT") or antwort
text = re.sub(r"^=+\s*$", "", text, flags=re.MULTILINE).strip()
# Führende Baustein-Titel-Überschrift entfernen — die setzt die Montage
m = re.match(r"^#{1,3}\s+(.+)\n+", text)
if m and textkit.jaccard(m.group(1), b["titel"], gestemmt=False) >= 0.6:
text = text[m.end():]
versuche = 0 if payload.get("neu") else \
(sec or {}).get("fix_versuche") or 0 # Neuschreiben resettet den Fix-Cap
db.execute("INSERT OR REPLACE INTO sections(baustein_id, stage, text, "
"qa_hash, fix_versuche) VALUES(?,?,?,?,?)",
bid, "geprueft", text, "", versuche)
return engine.Ergebnis(daten={"woerter": len(text.split())}, neue_tasks=[
{"knoten": "det_pruefung", "item": task["item"].replace("baustein", "det"),
"payload": {"baustein_id": bid}}])
def _det_befunde(topic: str, bid: int, text: str) -> list[dict]:
atome = _atome(bid)
eigene = {a["id"] for a in atome}
marker = [int(m) for m in _MARKER_RE.findall(text)]
befunde = []
for a in atome:
if marker.count(a["id"]) != 1:
befunde.append({"art": "marker_fehlend" if a["id"] not in marker
else "marker_doppelt",
"item": f"baustein:{bid}",
"detail": f"Atom {a['id']} ({a['titel']})"})
for m in set(marker) - eigene:
befunde.append({"art": "marker_fremd", "item": f"baustein:{bid}",
"detail": f"Atom {m} gehört nicht zum Baustein"})
lo, hi = _band(len(atome))
woerter = len(re.sub(r"<!--.*?-->", "", text).split())
if not lo * 0.75 <= woerter <= hi * 1.25:
befunde.append({"art": "laenge", "item": f"baustein:{bid}",
"detail": f"{woerter} Wörter, Band {lo}-{hi}"})
leak = re.search(r"^(TITEL|TYP|DEFINITION|ZITAT|BELEG):", text, re.MULTILINE)
if leak:
befunde.append({"art": "format_leak", "item": f"baustein:{bid}",
"detail": f"Rohformat im Text: {leak.group(0)}"})
# Lesefluss-Checks: linear lesbar, keine Sprünge, keine Echo-Zeilen
link = re.search(r"\]\(#[^)]*\)", text)
if link:
befunde.append({"art": "link_im_text", "item": f"baustein:{bid}",
"detail": "Anker-Link im Fließtext (Rückbezug als Prosa)"})
echo = re.search(r"^\*\*[^*\n]+\*\*\s+—", text, re.MULTILINE)
if echo:
befunde.append({"art": "atom_echo", "item": f"baustein:{bid}",
"detail": f"Fettzeilen-Echo: {echo.group(0)[:60]}"})
else:
# Semantisches Echo: erster Satz nach dem Marker ≈ Atom-Definition
# (fängt Fettzeilen OHNE Gedankenstrich, Gutachten-Rückfall Z.294/297)
for a in atome:
m = re.search(rf"<!--\s*atom:\s*{a['id']}\s*-->\s*\n+(.+)", text)
if not m:
continue
erster = textkit.satz_split(m.group(1))
if erster and textkit.jaccard(erster[0], a["definition"]) >= \
config.ECHO_JACCARD:
befunde.append({"art": "atom_echo", "item": f"baustein:{bid}",
"detail": f"Definition-Echo bei Atom {a['id']}: "
f"{erster[0][:60]}"})
break
h = re.search(r"^#{1,3}\s", text, re.MULTILINE)
if h:
befunde.append({"art": "h_ebene", "item": f"baustein:{bid}",
"detail": "H1-H3 im Section-Text (nur #### erlaubt)"})
# Vorwärtsverweise: Titel von Atomen SPÄTERER Kapitel fallen nicht früher
b = _baustein(bid)
spaetere = db.query(
"SELECT a.titel FROM atome a JOIN bausteine b2 ON b2.id=a.baustein_id "
"JOIN kapitel k2 ON k2.id=b2.kapitel_id JOIN bausteine b ON b.id=? "
"JOIN kapitel k ON k.id=b.kapitel_id WHERE a.topic=? AND "
"a.status='aktiv' AND k2.ord > k.ord", bid, topic)
# Verlinkte Erwähnungen sind KEIN Vorwärtsverweis — „Verweis statt
# Wiederholung" ist ja genau das gewollte Muster
ohne_links = re.sub(r"\[[^\]]*\]\([^)]*\)", " ", text)
text_norm = textkit.norm(ohne_links)
for s in spaetere:
titel_norm = textkit.norm(s["titel"])
if len(titel_norm) >= 12 and titel_norm in text_norm:
befunde.append({"art": "vorwaerts", "item": f"baustein:{bid}",
"detail": f"'{s['titel']}' wird erst später gelehrt"})
return befunde
@engine.worker("guide.det_pruefung")
async def det_pruefung(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
bid = payload["baustein_id"]
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
befunde = _det_befunde(task["topic"], bid, sec["text"]) if sec else []
# Nur Befunde schließen, die es LIVE nicht mehr gibt — fortbestehende
# Zeilen behalten ihre Historie (geklaert-Flag, Eskalations-Stand)
frisch = {(b["art"], b["item"], b["detail"][:400]) for b in befunde}
for row in db.query(
"SELECT id, art, item, detail FROM befunde WHERE item=? AND "
f"status='offen' AND art IN ({','.join('?' * len(arten.DET_ARTEN))})",
f"baustein:{bid}", *arten.DET_ARTEN):
if (row["art"], row["item"], row["detail"][:400]) not in frisch:
db.update("befunde", "id=?", (row["id"],), status="veraltet")
for b in befunde:
befund_merken(task["run_id"], "det_pruefung", b["art"], b["item"],
b["detail"], task["runde"])
return engine.Ergebnis(daten={"befunde": len(befunde)}, neue_tasks=[
{"knoten": "llm_pruefung", "item": task["item"].replace("det", "pruef"),
"payload": {"baustein_id": bid}}])
@engine.worker("guide.llm_pruefung", buendel=True)
async def llm_pruefung(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
neue = [{"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}]
teil, aktive = {}, {} # bid → (mitglied, section, hash)
for m in mitglieder:
bid = db.uj(m["payload"], {})["baustein_id"]
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
if sec is None or not sec["text"]:
teil[m["id"]] = "fertig"
continue
hash_ = hashlib.sha256(sec["text"].encode()).hexdigest()[:16]
if sec["qa_hash"] == hash_: # unverändert seit letzter Prüfung
teil[m["id"]] = "fertig"
continue
aktive[bid] = (m, sec, hash_)
if not aktive:
return engine.Ergebnis(daten={"skip": True}, neue_tasks=neue,
teil_status=teil)
sections_text = "\n\n".join(
f"=== SECTION {bid} ===\n{sec['text']}\n\n"
f"--- BELEGE für SECTION {bid} ---\n"
+ belege.fenster_fuer_atome([a["id"] for a in _atome(bid)])[:30000]
for bid, (_, sec, _) in aktive.items())
async def ruf(i):
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="llm_pruefung",
item=f"{task['item']}:p{i}", role="judge", n=len(aktive),
skill_namen=graph.skills_von(task["knoten"]),
werte={"sections": sections_text})
if text is None:
return None
return {"befunde": [b for b in textkit.bloecke(text, "BEFUND")
if b.get("art") in ("falsch", "luecke", "stil")
and b.get("section", "").strip().isdigit()],
"ok": {int(b["section"]) for b in textkit.bloecke(text, "LEER")
if b.get("section", "").strip().isdigit()}}
stimmen = await panels.panel(ruf, config.PRUEF_PANEL)
gueltig = [s for s in stimmen if s is not None]
if len(gueltig) < config.PRUEF_PANEL:
# fail-closed: qa_hash NICHT setzen — nächste Runde prüft erneut
for _, (m, _, _) in aktive.items():
teil[m["id"]] = "fertig"
return engine.Ergebnis(daten={"panel_ausfall": True}, neue_tasks=neue,
teil_status=teil)
gesamt = 0
for bid, (m, sec, hash_) in aktive.items():
je_stimme = [[b for b in s["befunde"] if int(b["section"]) == bid]
for s in gueltig]
if not all(bid in s["ok"] or je_stimme[k]
for k, s in enumerate(gueltig)):
teil[m["id"]] = "neu" # Section fehlt in einer Stimme → neuer Versuch
continue
bestaetigt = []
for b in je_stimme[0]: # nur DOPPELT bestätigte Befunde zählen (Lektion 15)
for anderes in je_stimme[1:]:
if any(b["art"] == b2.get("art") and textkit.jaccard(
b.get("stelle", ""), b2.get("stelle", ""),
gestemmt=False) >= 0.3 for b2 in anderes):
bestaetigt.append(b)
break
for b in bestaetigt:
befund_merken(task["run_id"], "llm_pruefung", b["art"],
f"baustein:{bid}",
f"{b.get('stelle', '')[:150]}{b.get('detail', '')[:200]}",
task["runde"])
db.update("sections", "baustein_id=?", (bid,), qa_hash=hash_)
offen = db.query("SELECT id FROM befunde WHERE stufe='guide' AND item=? "
"AND status='offen'", f"baustein:{bid}")
if offen and (sec["fix_versuche"] or 0) < config.FIX_MAX_VERSUCHE:
neue.append({"knoten": "fix", "item": f"r{task['runde']}:fix:{bid}",
"payload": {"baustein_id": bid}})
teil[m["id"]] = "fertig"
gesamt += len(bestaetigt)
return engine.Ergebnis(daten={"befunde": gesamt}, neue_tasks=neue,
teil_status=teil)
@engine.worker("guide.fix")
async def fix(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
bid = payload["baustein_id"]
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
offen = db.query("SELECT * FROM befunde WHERE item=? AND status='offen'",
f"baustein:{bid}")
if sec is None or not offen:
return engine.Ergebnis(daten={"skip": True})
if task["art"] == "klaerung": # 3er-Stichentscheid, je Befund genau einmal
return await _klaerung(task, bid, sec, offen)
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"],
role="writer",
skill_namen=graph.skills_von(task["knoten"]),
werte={"section": sec["text"],
"befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen),
"belege": belege.fenster_fuer_atome(
[a["id"] for a in _atome(bid)])[:30000]})
if antwort is None:
raise RuntimeError("fix ohne Ergebnis")
text = textkit.block_text(antwort, "TEXT")
alte_marker = sorted(_MARKER_RE.findall(sec["text"]))
if not text or sorted(_MARKER_RE.findall(text)) != alte_marker:
# Fix, der die Marker-Invariante bricht, wird verworfen
db.update("sections", "baustein_id=?", (bid,),
fix_versuche=(sec["fix_versuche"] or 0) + 1)
return engine.Ergebnis(daten={"verworfen": True})
db.update("sections", "baustein_id=?", (bid,), text=text, qa_hash="",
fix_versuche=(sec["fix_versuche"] or 0) + 1, stage="fix")
for b in offen:
db.update("befunde", "id=?", (b["id"],), status="behoben")
return engine.Ergebnis(daten={"gefixt": len(offen)})
async def _klaerung(task, bid, sec, offen) -> engine.Ergebnis:
beleg_text = belege.fenster_fuer_atome([a["id"] for a in _atome(bid)])[:20000]
for b in offen:
if b["geklaert"]:
continue
async def ruf(i, b=b):
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix",
item=f"{task['item']}:k{b['id']}:p{i}", role="judge",
skill_namen=graph.skills_von(task["knoten"], extra="klaerung"),
werte={"befund": f"[{b['art']}] {b['detail']}", "belege": beleg_text})
urteile = textkit.bloecke(text or "", "URTEIL")
return urteile[0].get("urteil") if urteile else None
stimmen = await panels.panel(ruf, config.KLAERUNG_PANEL)
ist_mangel = panels.mehrheit(
stimmen, lambda s: True if s == "mangel"
else (False if s == "kein_mangel" else None))
db.update("befunde", "id=?", (b["id"],), geklaert=1,
status="offen" if ist_mangel else "freispruch")
return engine.Ergebnis(daten={"geklaert": len(offen)})
@engine.worker("guide.kapitel_intro")
async def kapitel_intro(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
kap = db.one("SELECT * FROM kapitel WHERE id=?", payload["kapitel_id"])
if kap is None:
return engine.Ergebnis(daten={"skip": True})
if kap["intro"]:
return engine.Ergebnis(daten={"skip": True},
neue_tasks=[{"knoten": "redundanz",
"item": f"r{task['runde']}:redundanz"}])
titel = db.query("SELECT titel FROM bausteine WHERE kapitel_id=? ORDER BY ord",
kap["id"])
vorherige = db.query("SELECT titel FROM kapitel WHERE topic=? AND ord<? "
"ORDER BY ord", task["topic"], kap["ord"])
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="kapitel_intro",
item=task["item"], role="writer",
skill_namen=graph.skills_von(task["knoten"]),
werte={"kapitel": kap["titel"],
"sections": "\n".join(f"- {t['titel']}" for t in titel),
"vorherige": "\n".join(f"- {v['titel']}" for v in vorherige)
or "- (keine)"})
intro = textkit.block_text(antwort or "", "INTRO")
if not intro: # Ausfall/Format kaputt: neuer Versuch statt leerem Intro
raise RuntimeError("kapitel_intro ohne INTRO-Block")
db.update("kapitel", "id=?", (kap["id"],), intro=intro[:800])
return engine.Ergebnis(daten={"intro": True}, neue_tasks=[
{"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}])
@engine.worker("guide.lektorat")
async def lektorat(task: dict) -> engine.Ergebnis:
"""Gesamt-Wächter (Kurzbuch-Ziel): EIN Blick über den ganzen Guide —
section-übergreifende Dopplungen, Splitter-Sections, zähe Übergänge.
Läuft einmal je Topic; Befunde laufen durch die normale Fix-Maschine."""
topic = task["topic"]
if db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' AND "
"status='fertig' AND id!=?", topic, task["id"]):
return engine.Ergebnis(daten={"skip": True})
teile = []
for kap in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord",
topic):
teile.append(f"## {kap['titel']}")
for b in db.query(
"SELECT b.id, b.titel, s.text FROM bausteine b JOIN sections s "
"ON s.baustein_id=b.id WHERE b.kapitel_id=? AND s.text!='' "
"ORDER BY b.ord", kap["id"]):
teile.append(f"[SECTION {b['id']}] {b['titel']}\n{b['text']}")
if len(teile) < 2:
return engine.Ergebnis(daten={"skip": True})
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="lektorat",
item=task["item"], role="judge",
skill_namen=graph.skills_von(task["knoten"]),
werte={"guide": "\n\n".join(teile)[:120_000]})
if antwort is None:
raise RuntimeError("lektorat ohne Ergebnis")
befunde_bloecke = textkit.bloecke(antwort, "BEFUND")
if not befunde_bloecke and not textkit.bloecke(antwort, "LEER"):
raise RuntimeError("lektorat: weder BEFUND noch LEER") # Format kaputt
n = 0
for b in befunde_bloecke:
sid, art = b.get("section", "").strip(), b.get("art", "").strip()
if sid.isdigit() and art in ("redundanz", "stil") and befund_merken(
task["run_id"], "lektorat", art, f"baustein:{sid}",
b.get("detail", "")[:300], task["runde"]):
n += 1
return engine.Ergebnis(daten={"befunde": n})
def befund_merken(run_id: int, knoten: str, art: str, item: str, detail: str,
runde: int) -> bool:
"""Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe
Befund offen oder final (eskaliert/freispruch), KEINE neue Zeile — sonst
startet die Eskalations-Kette (geklaert-Flag!) jede Runde von vorn."""
kern = detail[:400]
if db.one("SELECT id FROM befunde WHERE art=? AND item=? AND "
"substr(detail,1,400)=? AND status IN ('offen','eskaliert',"
"'freispruch')", art, item, kern):
return False
db.insert("befunde", run_id=run_id, stufe="guide", knoten=knoten, art=art,
item=item, detail=detail[:500], runde=runde)
return True
def _rewrites(topic: str, bid) -> int:
return db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND knoten='writer' "
"AND art='reparatur' AND item LIKE ?", topic,
f"%:baustein:{bid}")["c"]
@engine.worker("guide.gate")
async def gate(task: dict) -> engine.Ergebnis:
topic, runde = task["topic"], task["runde"]
befunde, neue = [], []
bausteine = db.query("SELECT b.id FROM bausteine b WHERE b.topic=?", topic)
# Final entschiedene det-Befunde (Klärung/Eskalation) blocken nicht erneut
final = {(r["art"], r["item"], r["detail"][:400]) for r in db.query(
"SELECT art, item, detail FROM befunde WHERE status IN "
"('freispruch','eskaliert') AND run_id IN (SELECT id FROM runs WHERE "
"topic=?)", topic)}
alle_marker: list[int] = []
for b in bausteine:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", b["id"])
if sec is None or not sec["text"]:
befunde.append({"art": "section_fehlt", "item": f"baustein:{b['id']}",
"detail": ""})
continue
alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])]
for f in _det_befunde(topic, b["id"], sec["text"]):
if not arten.BEFUND_ARTEN.get(f["art"], {"gate_blockiert": True})[
"gate_blockiert"]: # z. B. laenge: nur Kennzahl
continue
if (f["art"], f["item"], f["detail"][:400]) in final:
continue
befunde.append(f)
for m in {m for m in alle_marker if alle_marker.count(m) > 1}:
befunde.append({"art": "marker_global_doppelt", "item": f"atom:{m}",
"detail": "Atom in mehreren Sections gelehrt"})
# Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×!
# sonst resettet jedes Neuschreiben den Fix-Cap → Endlos-Karussell)
# NUR Section-Befunde (item baustein:*) — marker_global_doppelt trägt
# item atom:<marke>, dessen Zahl ist KEINE Baustein-Id (latenter Bug)
for f in [f for f in befunde if f["art"].startswith("marker")
and f["item"].startswith("baustein:")]:
bid = f["item"].split(":")[-1]
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
if sec and (sec["fix_versuche"] or 0) >= config.FIX_MAX_VERSUCHE \
and _rewrites(topic, bid) < 1:
neue.append({"knoten": "writer", "item": f"r{runde + 1}:baustein:{bid}",
"art": "reparatur", "runde": runde + 1,
"payload": {"baustein_id": int(bid), "neu": True}})
# Inhaltliche Befunde (LLM/Redundanz): Fix, danach 3er-Klärung
offene = db.query(
"SELECT * FROM befunde b WHERE b.run_id IN (SELECT id FROM runs WHERE "
"topic=?) AND b.stufe='guide' AND b.status='offen' AND b.art IN "
f"({','.join('?' * len(arten.FIX_ARTEN))})", topic, *arten.FIX_ARTEN)
for b in offene:
befunde.append({"art": b["art"], "item": b["item"],
"detail": b["detail"][:120]})
bid = b["item"].split(":")[-1]
if not bid.isdigit():
continue
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
versuche = (sec or {}).get("fix_versuche") or 0
if versuche < config.FIX_MAX_VERSUCHE:
neue.append({"knoten": "fix", "item": f"r{runde + 1}:fix:{bid}",
"art": "reparatur", "runde": runde + 1,
"payload": {"baustein_id": int(bid)}})
elif not b["geklaert"]:
neue.append({"knoten": "fix", "item": f"r{runde + 1}:klaerung:{bid}",
"art": "klaerung", "runde": runde + 1,
"payload": {"baustein_id": int(bid)}})
elif _rewrites(topic, bid) < 1:
neue.append({"knoten": "writer", "item": f"r{runde + 1}:baustein:{bid}",
"art": "reparatur", "runde": runde + 1,
"payload": {"baustein_id": int(bid), "neu": True}})
else:
# Exit nach Fix-Cap + 3er-Klärung + 1 Neuschreiben: eskalieren
# statt ewig blocken — 3 Instanzen haben geprüft, Rest ist
# dokumentierte Grenze (sichtbar in den Kennzahlen)
db.update("befunde", "id=?", (b["id"],), status="eskaliert")
befunde.pop() # zählt nicht mehr als gate-blockierend
if befunde:
# Re-Prüfung der gefixten Sections in der nächsten Runde
for bid in {b["item"].split(":")[-1] for b in befunde
if b["item"].startswith("baustein:")}:
if bid.isdigit():
neue.append({"knoten": "det_pruefung",
"item": f"r{runde + 1}:det:{bid}", "art": "reparatur",
"runde": runde + 1, "payload": {"baustein_id": int(bid)}})
neue.append({"knoten": "redundanz", "item": f"r{runde + 1}:redundanz",
"art": "reparatur", "runde": runde + 1})
return engine.Ergebnis(gate_status="rot", befunde=befunde, neue_tasks=neue)
db.update("topics", "name=?", (topic,), status="guide_fertig")
db.execute("UPDATE sections SET stage='fertig' WHERE baustein_id IN "
"(SELECT id FROM bausteine WHERE topic=?)", topic)
return engine.Ergebnis(gate_status="gruen", neue_tasks=[
{"knoten": "montage", "item": "montage"}])