"""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 belege, config, db, engine, llm, panels, textkit _MARKER_RE = re.compile(r"") 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 re.sub(r"[^a-z0-9äöüß-]", "", s) 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(f"- {f['titel']} (im 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"\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=["autor", "deutsch-praezise", "guide-lesbarkeit", "writer"], 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*\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 " "status='offen' AND art IN ('marker_fehlend','marker_fremd'," "'marker_doppelt','vorwaerts','laenge','format_leak'," "'link_im_text','atom_echo','h_ebene')", f"baustein:{bid}"): 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=["richter", "deutsch-praezise", "guide-pruefung"], 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, "OK") 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=["autor", "deutsch-praezise", "guide-lesbarkeit", "guide-fix"], 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=["richter", "deutsch-praezise", "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 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 f["art"] == "laenge": # Länge blockiert nicht hart (nur Befund) 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) for f in [f for f in befunde if f["art"].startswith("marker")]: 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 " "('falsch','luecke','redundanz','vorwaerts','format_leak')", topic) 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"}])