"""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 DURCHGANG, FIX_MAX_VERSUCHE, 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 plus direkte braucht-Voraussetzungen. Der Writer knüpft an, statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen).""" rang = LEVEL_RANG.get(b["level"], 1) atome = _atome_von(b["id"]) ids = {a["id"] for a in atome} 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 LEVEL_RANG.get(a["level"], 1) < rang: kontext[a["id"]] = a if ids: marks = ",".join("?" * len(ids)) for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom" f" WHERE k.art='braucht' AND k.status='aktiv'" f" AND k.von_atom IN ({marks})", tuple(ids)): if a["id"] not in ids 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 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]}) # Rest-Aufträge eines gescheiterten Fix (stage bleibt „done", befunde nicht # geleert) sichtbar machen — sonst passiert eine bekannte Lücke das Gate. for auftrag in db.uj(sec["befunde"]): befunde.append({"art": "fix_offen", "item": str(b["id"]), "detail": str(auftrag)[: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 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 = [] # topic-scoped (nicht run-scoped): ein in Run N bestätigter Fakten-Fehler bleibt # nach Pause/Resume (Run N+1) sichtbar, sonst greift der qa_hash-Skip fälschlich. offene_falsch = {b["item"] for b in db.query( "SELECT b.item FROM befunde b JOIN runs r ON r.id=b.run_id" " WHERE r.topic=? AND b.ebene=? AND b.art='fachlich_falsch' AND b.status='offen'", (ctx.topic, EBENE))} 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"] and str(b["id"]) not in offene_falsch: return # unverändert und sauber — kein Re-Check # 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 claims = [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]}) 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] async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: """Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check); Stil-Befunde → direkt in den Fix (genau ein Rewrite). Rückgabe ist ehrlich: True nur, wenn sich Section-Text tatsächlich geändert hat — sonst erkennt auto_loop nie Stillstand und dreht alle 10 Iterationen wirkungslos durch.""" ctx.ebene = EBENE betroffen: dict[int, list[dict]] = {} for b in befunde: try: b_id = int(b["item"]) except (ValueError, TypeError): continue betroffen.setdefault(b_id, []).append(b) if not betroffen: return False geroutet: list[int] = [] for b_id, liste in betroffen.items(): arten = [x["art"] for x in liste] # ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch kritisch_detail = any(x["art"] == "fix_offen" and str(x["detail"]).startswith("KRITISCH") for x in liste) kritisch = any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail if not kritisch: # Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird # eingefroren — kein weiteres Routing der Stil-/Längen-/vorwaerts- # Befunde. Rest-Schuld (Gewicht 0.5) akzeptiert; bremst den 617-Churn. sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,)) if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE: continue if kritisch: if "section_fehlt" in arten: db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,)) else: # KRITISCH-Details in die Spalte schreiben, damit _stage_pruefer sie # sieht (der bestätigte Claim erreicht sonst weder Prüfer noch Fix) vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?", (b_id,))["befunde"]) neu = [f"KRITISCH ({x['art']}): {x['detail']}" for x in liste if x["art"] in KRITISCH] zusammen = list(dict.fromkeys(vorhanden + neu)) db.update("sections", "baustein_id", b_id, stage="pruefer", befunde=db.j(zusammen)) else: # bestehende Fix-Aufträge (fix_offen) behalten, Stil-Details anhängen vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?", (b_id,))["befunde"]) neu = [f"Behebe ({x['art']}): {x['detail']}" for x in liste if x["art"] != "fix_offen"] auftraege = list(dict.fromkeys(vorhanden + neu)) db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege)) db.update("bausteine", "id", b_id, status="repair") geroutet.append(b_id) vorher = {b_id: _text_sig(b_id) for b_id in geroutet} await bauen(ctx) bewegt = False for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1 if _text_sig(b_id) != 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