"""Befund-Repair: arbeitet den jüngsten QA-Report gezielt ab — ohne Flow, ohne Board-Rebuild. Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub, Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche). Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren.""" import logging import re import database as db import qa from blocks import _blocks_files, _evidence_pack, material_folder, source_folder from config import EVIDENCE_PER_BLOCK from fsutil import atomic_write_json from jsonio import read_json_file as _json_file from textkit import _norm_title, _title, clean_title, parse_facts log = logging.getLogger("creator.repair") async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict: """ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte. luecken_urteil: offene Lücken/Konzept-Lücken dem Stichentscheid vorlegen (Freispruch oder bestätigt) — der QA-Gate-Watch schaltet es aus, solange seine Lücken-Recherche noch Runden hat. Misst am Ende IMMER beide Noten neu. `aktionen` zählt alles, was die Runde getan hat (Fixes+Merges+Freisprüche) — der Auto-Loop stoppt erst bei 0.""" reports = qa.report_paths(topic) report = _json_file(reports[-1]) if reports else None if not report: return {"fehler": "kein QA-Report — erst QA laufen lassen"} files = _blocks_files(topic) cards = await db.kanban_cards(topic, board="inventory", stage="done_block") by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards} inv = ebene in (None, "inventory") art = ebene in (None, "artefacts") hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else [] hygiene += (await _fix_beschreibungen(topic, report, by_norm)) if inv else [] hygiene += (await _fix_suffixe(topic, report, by_norm, files)) if inv else [] merges, frei_paare = (await _merge_dubletten(topic, report, by_norm, files)) if inv else ([], []) sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], []) entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], []) frei_luecken = (await _pruefe_luecken(topic, report, by_norm)) if inv and luecken_urteil else [] beleg_fix = (await _belege_nachfassen(topic, report, files)) if art else [] aufgeraeumt = (await _raeume_waisen(topic)) if art else 0 # llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report # blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln neu = await qa.qa_report(topic, llm=True) if neu: await qa.write_report(neu) na = neu.get("note_artefakte") if neu else None freigesprochen = frei_paare + frei_subs + frei_bloecke + frei_luecken lk_rest, kl_rest = qa.offene_luecken(neu or {}) return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt, "beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen, "aktionen": (len(hygiene) + len(merges) + len(sub_merges) + len(entfernt) + len(beleg_fix) + aufgeraeumt + len(freigesprochen)), "braucht_research": len(lk_rest) + len(kl_rest), # offene Lücken NACH dieser Runde "note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note "note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer async def _judge(template: str, topic: str, key: str, slot: str, items: list[str], extra: str = "") -> dict[int, str]: """No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten).""" return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair", extra=extra) def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None: d = qa.lade_freispruch(topic) alt = set(d.get(kategorie) or []) d[kategorie] = sorted(alt | set(keys)) qa.freispruch_pfad(topic).parent.mkdir(parents=True, exist_ok=True) atomic_write_json(qa.freispruch_pfad(topic), d, indent=1) async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str, lines: list[str], befund: str, kategorie: str = "", ids: list[str] | None = None, extra: str = "") -> tuple[dict[int, str], list[str]]: """Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte die Note dauerhaft unter 10 ohne Fix-Pfad (gemessen: aak-fremd 9.2, kanban-smoke- Dublette 9.4 — „keine behebbaren Befunde" trotz Befund). Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht (fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen).""" v = await _judge(template, topic, key, slot, lines, extra=extra) strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund] frei: list[str] = [] if strittig: v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig], extra=extra) gegen = "nein" if befund == "ja" else "ja" frei_keys: list[str] = [] for pos, i in enumerate(strittig, 1): if v3.get(pos) == befund: v[i] = befund # 2:1 für den QA-Befund → handeln elif v3.get(pos) == gegen and kategorie and ids: frei_keys.append(ids[i - 1]) frei.append(lines[i - 1].splitlines()[0][:80]) if frei_keys: _speichere_freispruch(topic, kategorie, frei_keys) log.info("[%s] Repair %s: %d Befund(e) per 2:1 freigesprochen", topic, kategorie, len(frei_keys)) return v, frei async def _fix_beschreibungen(topic: str, report: dict, by_norm: dict) -> list[str]: """Hygiene-Resttyp „leere-beschreibung": EINE Beschreibung aus Material-Auszügen generieren (Blocks-Sanierung-Template; der Titel bleibt — Repair ist flow-los und hat keinen Korpus-Anker-Kontext für Renames). Ohne Material/Auszüge bleibt der Befund.""" from agents import run_agent from pipeline import _prompt, _timeout from jsonio import parse_json_text from board_inventory import _sanierung_schema items = [h for h in report.get("hygiene", []) if "leere-beschreibung" in (h.get("probleme") or [])] folder = source_folder(topic) or material_folder(topic) if not items or not folder: return [] out: list[str] = [] for h in items: norm = _norm_title(h.get("titel", "")) card = by_norm.get(norm) if not card or (card["payload"].get("description") or "").strip(): continue titel = card["payload"].get("title", "") ev = _evidence_pack(folder, card["payload"].get("sources") or None, [titel], budget=4000) if not ev: continue try: rc, txt, _err = await run_agent( f"repair-{topic}-sanierung-{norm[:24]}", _prompt("Blocks-Sanierung", topic=topic, title=titel, description="(leer)", excerpts=ev), _timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label="Repair Beschreibung") except Exception: log.exception("[%s] Beschreibungs-Sanierung fehlgeschlagen — Befund bleibt", topic) continue verdict = _sanierung_schema(parse_json_text(txt)) if rc == 0 else None if not verdict or not verdict[1].strip(): continue p = dict(card["payload"]) p["description"] = verdict[1].strip() await db.kanban_set_payload(topic, "inventory", card["card_id"], p) await db.set_block_status(topic, norm, "consensus", description=p["description"]) card["payload"] = p out.append(f"beschrieben: {titel}") return out _SUFFIX_RE = re.compile(r"\s*\(\d+\)\s*$") async def _fix_suffixe(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]: """Hygiene-Resttyp „kollisions-suffix" („Titel (2)", entstanden beim Spiegeln): Basis-Norm frei (Partner inzwischen gemerged/verworfen) → Rename auf den Basistitel inkl. Re-Key aller angehängten Tabellen (db.rename_block_norm) + Board-2-Karte + Sidecars. Basis besetzt → Judge entscheidet Dublette (Merge); sonst bleibt der Befund sichtbar (zwei echte gleichnamige Konzepte sind ein Naming-Problem).""" items = [h for h in report.get("hygiene", []) if "kollisions-suffix" in (h.get("probleme") or [])] out: list[str] = [] for h in items: alt = h.get("titel", "") basis = _SUFFIX_RE.sub("", alt).strip() alt_norm, basis_norm = _norm_title(alt), _norm_title(basis) card = by_norm.get(alt_norm) if not card or not basis or basis_norm == alt_norm: continue if basis_norm in by_norm: # Basis lebt noch → Dubletten-Frage statt Rename merged, _frei = await _merge_dubletten( topic, {"dubletten": [{"a": basis, "b": alt, "llm": "?"}]}, by_norm, files) out += [f"suffix-merge: {z}" for z in merged] continue await db.rename_block_norm(topic, alt_norm, basis_norm, basis) b2 = await db.kanban_get_card(topic, "artefacts", alt_norm) if b2: p2 = dict(b2["payload"]) p2["title"] = basis await db.kanban_upsert_card(topic, "artefacts", basis_norm, b2["kind"], b2["stage"], p2) await db.kanban_delete_card(topic, "artefacts", alt_norm) p = dict(card["payload"]) p["title"] = basis if p.get("mirrored_norm"): p["mirrored_norm"] = basis_norm await db.kanban_set_payload(topic, "inventory", card["card_id"], p) card["payload"] = p _rename_in_files(files, alt_norm, basis) by_norm.pop(alt_norm, None) by_norm[basis_norm] = card out.append(f"suffix: {alt} → {basis}") return out async def _pruefe_luecken(topic: str, report: dict, by_norm: dict) -> list[str]: """Lücken-Urteil (2:1): offene luecken-Fundstellen und konzept_luecken-Namen dem Stichentscheid vorlegen — 2:1-„keine echte Lücke" wird als Freispruch persistiert, bestätigte bleiben als braucht_research sichtbar (Gate-Watch recherchiert, der Nutzer klickt „+ Recherche"). Das Inventar geht als Kontext in den {extra}-Slot, damit die Judges Abdeckung gegen den Bestand prüfen können.""" lk, kl = qa.offene_luecken(report) if not lk and not kl: return [] extra = ("\n\nEXISTING INVENTORY BLOCKS (judge coverage against these):\n" + "\n".join(f"- {c['payload'].get('title', '')}" for c in by_norm.values())) frei: list[str] = [] if lk: _v, f = await _mit_stichentscheid( "QA-Luecken", topic, "luecken", "sections", [f"[{x.get('datei')} #{x.get('abschnitt')}] {x.get('vorschau', '')}" for x in lk], "ja", kategorie="luecken", ids=[qa.luecken_key(x) for x in lk], extra=extra) frei += f if kl: _v, f = await _mit_stichentscheid( "QA-Konzept-Luecken", topic, "konzept-luecken", "results", list(kl), "ja", kategorie="konzept_luecken", ids=[_norm_title(n) for n in kl], extra=extra) frei += f return frei async def _belege_nachfassen(topic: str, report: dict, files: dict) -> list[str]: """subs_ohne_beleg (mentions=0): Beleg-Nachfass je Sub mit Material-Auszügen. Judge+QA einig „nein" (kein Beleg) → Sub verwerfen (Artefakte/Fragen räumt das nachlaufende _raeume_waisen); 2:1-„ja" → mentions=1 nachtragen — Datenfix am gemessenen Datum selbst, kein Freispruch nötig, Detektor unverändert. Produktiv ist der Detektor derzeit inaktiv (alle Schreiber setzen mentions=1) — Pfad ist Robustheit. Ohne Quellordner kein Urteil möglich → Befund bleibt.""" eintraege = (report.get("beleg") or {}).get("subs_ohne_beleg") or [] folder = source_folder(topic) or material_folder(topic) if not eintraege or not folder: return [] rows = {f"{r['block']} · {r['sub_title']}": r for r in await db.list_subblocks(topic) if r["status"] != "variant"} items = [(e, rows[e]) for e in eintraege if e in rows and not rows[e]["mentions"]] if not items: return [] lines = [] for e, r in items: ev = _evidence_pack(folder, None, [r["block"], r["sub_title"]], budget=EVIDENCE_PER_BLOCK) lines.append(f"{e}\n{ev or '(keine Treffer im Material)'}") v1 = await _judge("QA-Repair-Beleg", topic, "beleg", "blocks", lines) strittig = [i for i in range(1, len(items) + 1) if v1.get(i) == "ja"] v3 = (await _judge("QA-Repair-Beleg", topic, "beleg-st", "blocks", [lines[i - 1] for i in strittig])) if strittig else {} out: list[str] = [] async def _verwerfen(e: str, r: dict): await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], status="discarded") _entferne_sub_in_files(files, r["block_norm"], r["sub_norm"]) out.append(f"entfernt: {e[:60]}") for i, (e, r) in enumerate(items, 1): if v1.get(i) == "nein": # QA (mentions=0) + Judge einig → weg await _verwerfen(e, r) elif v1.get(i) == "ja": # Dissens → Stichentscheid pos = strittig.index(i) + 1 if v3.get(pos) == "ja": await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], mentions=1) out.append(f"belegt: {e[:60]}") elif v3.get(pos) == "nein": await _verwerfen(e, r) return out async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]: """Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung ändern die Norm bzw. brauchen Inhalt — eigene Handler (_fix_suffixe/_fix_beschreibungen).""" fixed = [] for h in report.get("hygiene", []): alt = h.get("titel", "") neu = clean_title(alt) if neu == alt or _norm_title(neu) != _norm_title(alt): continue norm = _norm_title(alt) card = by_norm.get(norm) if not card: continue p = dict(card["payload"]) p["title"] = neu await db.kanban_set_payload(topic, "inventory", card["card_id"], p) await db.set_block_status(topic, norm, "consensus", title=neu) _rename_in_files(files, norm, neu) fixed.append(f"{alt} → {neu}") return fixed async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None: """Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None.""" a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"])) if not a or not b or a["card_id"] == b["card_id"]: return None win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""), len(c["payload"].get("title") or "")), reverse=True) wp, lp = dict(win["payload"]), dict(lose["payload"]) wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or [])) wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or [])) lp.update(reason="merged", merged_into=wp.get("title", "")) await db.kanban_set_payload(topic, "inventory", win["card_id"], wp) await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp) await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped") await _purge_block(topic, lp.get("title", ""), files) by_norm.pop(_norm_title(lp.get("title", "")), None) return f"{lp.get('title')} → {wp.get('title')}" async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]: """QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten" persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht). Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein" → Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil), Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts).""" alle = [p for p in report.get("dubletten", []) if not p.get("freispruch") and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm] ja = [p for p in alle if p.get("llm") == "ja"] offen = [p for p in alle if p.get("llm") != "ja"] merged: list[str] = [] frei: list[str] = [] if ja: v, f = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs", [f"A: {p['a']}\nB: {p['b']}" for p in ja], "ja", kategorie="dubletten", ids=[qa._paar_key(p["a"], p["b"]) for p in ja]) frei += f for i, p in enumerate(ja, 1): if v.get(i) == "ja" and (zeile := await _merge_paar(topic, p, by_norm, files)): merged.append(zeile) if offen: ids = [qa._paar_key(p["a"], p["b"]) for p in offen] v1 = await _judge("QA-Dubletten", topic, "dubletten-v", "pairs", [f"A: {p['a']}\nB: {p['b']}" for p in offen]) frei_keys = [ids[i - 1] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"] frei += [f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"] strittig = [i for i in range(1, len(offen) + 1) if v1.get(i) == "ja"] if strittig: v3 = await _judge("QA-Dubletten", topic, "dubletten-v-st", "pairs", [f"A: {offen[i - 1]['a']}\nB: {offen[i - 1]['b']}" for i in strittig]) for pos, i in enumerate(strittig, 1): if v3.get(pos) == "ja" and (zeile := await _merge_paar(topic, offen[i - 1], by_norm, files)): merged.append(zeile) elif v3.get(pos) == "nein": frei_keys.append(ids[i - 1]) frei.append(f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80]) if frei_keys: _speichere_freispruch(topic, "dubletten", frei_keys) log.info("[%s] Repair dubletten: %d Verdacht(e) freigesprochen", topic, len(frei_keys)) return merged, frei _SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S) def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]: """Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung).""" def score(r): kp = len(parse_facts(r.get("facts")).get("key_points") or []) return (kp, len(r.get("sub_title") or "")) return (a, b) if score(a) >= score(b) else (b, a) async def falte_sub(topic: str, files: dict, win: dict, lose: dict) -> None: """Verlierer-Sub falten: Status variant, Fragen/Artefakte zum Gewinner umhängen (oder löschen, wenn der Typ dort existiert), Sidecar-Dateien bereinigen. Gemeinsamer Kern von QA-Repair und Cross-Block-Dedup (Board 2, Run-Ende) — win/lose sind subblocks-Rows.""" await db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant") w_fragen = {r["sub_norm"] for r in await db.list_question_pattern(topic, win["block_norm"])} for r in await db.list_question_pattern(topic, lose["block_norm"]): if r["sub_norm"] != lose["sub_norm"]: continue if win["sub_norm"] not in w_fragen: await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"], win["block"], win["sub_title"], r["question"]) await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"]) w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"]) if r["sub_norm"] == win["sub_norm"]} for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]): if r["sub_norm"] != lose["sub_norm"]: continue if r["type"] not in w_typen: await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"], r["data"], win["block"], win["sub_title"]) await db.delete_artefakt_row(topic, lose["block_norm"], lose["sub_norm"], r["type"]) _entferne_sub_in_files(files, lose["block_norm"], lose["sub_norm"]) async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> tuple[list[str], list[str]]: """QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant, seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat). Repair hatte dafür keinen Handler — die Paare überlebten jeden Repair-Zyklus.""" rows = {(r["block_norm"], r["sub_norm"]): r for r in await db.list_subblocks(topic) if r["status"] == "consensus"} def _row(eintrag: str): m = _SUB_PAAR.match(eintrag or "") return rows.get((_norm_title(m.group(1)), _norm_title(m.group(2)))) if m else None paare = [(a, b) for p in report.get("sub_dubletten", []) if p.get("llm") == "ja" and (a := _row(p.get("a"))) and (b := _row(p.get("b"))) and (a["block_norm"], a["sub_norm"]) != (b["block_norm"], b["sub_norm"])] if not paare: return [], [] v, frei = await _mit_stichentscheid( "QA-Sub-Dubletten", topic, "sub-dubletten", "pairs", [f"A: [{a['block']}] {a['sub_title']}\nB: [{b['block']}] {b['sub_title']}" for a, b in paare], "ja", kategorie="sub_dubletten", ids=[qa._paar_key(f"[{a['block']}] {a['sub_title']}", f"[{b['block']}] {b['sub_title']}") for a, b in paare]) merged: list[str] = [] gone: set[tuple] = set() for i, (a, b) in enumerate(paare, 1): win, lose = _sub_gewinner(a, b) wk, lk = (win["block_norm"], win["sub_norm"]), (lose["block_norm"], lose["sub_norm"]) if v.get(i) != "ja" or wk in gone or lk in gone: continue await falte_sub(topic, files, win, lose) gone.add(lk) merged.append(f"{lose['sub_title'][:40]} → {win['sub_title'][:40]}") return merged, frei def _entferne_sub_in_files(files: dict, bnorm: str, sub_norm: str) -> None: """Verlierer-Sub aus den Sidecar-JSONs nehmen (Legacy-Lesepfad von Guide/Frontend); die DB trägt die umgehängten Fragen/Artefakte.""" for key, feld in (("sidecar", "title"), ("sub_roh", None), ("question_pattern", "subblock")): d = _json_file(files[key]) if not isinstance(d, dict): continue changed = False for bt, eintraege in d.items(): if _norm_title(bt) != bnorm or not isinstance(eintraege, list): continue neu = [e for e in eintraege if _norm_title(e if feld is None else str((e or {}).get(feld, ""))) != sub_norm] if len(neu) != len(eintraege): d[bt] = neu changed = True if changed: atomic_write_json(files[key], d, indent=1) art = _json_file(files["artefakte"]) if isinstance(art, dict): neu = {t: [e for e in (es if isinstance(es, list) else []) if not (_norm_title(_title(str(e.get("block", "")))) == bnorm and _norm_title(str(e.get("subblock", ""))) == sub_norm)] for t, es in art.items()} if neu != art: atomic_write_json(files["artefakte"], neu, indent=1) async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]: out = [] frei_alle: list[str] = [] fremd = [t for t in report.get("fremd", []) if _norm_title(t) in by_norm] if fremd: folder = source_folder(topic) lines = [] for t in fremd: srcs = by_norm[_norm_title(t)]["payload"].get("sources") or None ev = _evidence_pack(folder, srcs, [t], budget=EVIDENCE_PER_BLOCK) if folder else "" lines.append(f"{t}\n{ev or '(keine Treffer im Material)'}") v, frei = await _mit_stichentscheid("QA-Repair-Beleg", topic, "fremd", "blocks", lines, "nein", kategorie="fremd", ids=[_norm_title(t) for t in fremd]) frei_alle += frei for i, t in enumerate(fremd, 1): if v.get(i) == "nein": await _reject(topic, t, by_norm, files, "qa-fremd") out.append(t) unecht = [t for t in report.get("unecht", []) if _norm_title(t) in by_norm] if unecht: lines = [f"{t} — {by_norm[_norm_title(t)]['payload'].get('description') or '(ohne Beschreibung)'}" for t in unecht] v, frei = await _mit_stichentscheid("QA-Bausteine", topic, "unecht", "blocks", lines, "nein", kategorie="unecht", ids=[_norm_title(t) for t in unecht]) frei_alle += frei for i, t in enumerate(unecht, 1): if v.get(i) == "nein": await _reject(topic, t, by_norm, files, "qa-unecht") out.append(t) return out, frei_alle async def _raeume_waisen(topic: str) -> int: """Artefakte/Fragen mit totem Ziel löschen (Sub verworfen oder weg) — inert, der Übungs-Join spielt sie nie aus, aber sie drücken die Artefakt-Note. Mehrdeutige Präfix-Treffer bleiben (könnten lebend sein — Löschen wäre riskanter als behalten).""" lebt = {(r["block_norm"], r["sub_norm"]) for r in await db.list_subblocks(topic) if r["status"] != "discarded"} def tot(bn: str, sn: str) -> bool: if (bn, sn) in lebt: return False return not any(b == bn and s.startswith(sn + ":") for b, s in lebt) n = 0 for r in await db.get_sub_artefakte(topic): if tot(r["block_norm"], r["sub_norm"]): await db.delete_artefakt_row(topic, r["block_norm"], r["sub_norm"], r["type"]) n += 1 for r in await db.list_question_pattern(topic): if tot(r["block_norm"], r["sub_norm"]): await db.delete_frage_row(topic, r["block_norm"], r["sub_norm"]) n += 1 return n async def _reject(topic: str, title: str, by_norm: dict, files: dict, grund: str) -> None: norm = _norm_title(title) card = by_norm.pop(norm, None) if not card: return p = dict(card["payload"]) p["reason"] = grund await db.kanban_set_payload(topic, "inventory", card["card_id"], p) await db.kanban_advance(topic, "inventory", card["card_id"], "rejected") await _purge_block(topic, title, files) async def _purge_block(topic: str, title: str, files: dict) -> None: """Abgeleitete Daten eines Blocks gezielt entfernen (DB-Spiegel, Board-2-Karte, Sidecars).""" norm = _norm_title(title) await db.set_block_status(topic, norm, "discarded") await db.delete_subblocks(topic, norm) await db.delete_question_pattern(topic, norm) await db.delete_sub_artefakte(topic, norm) await db.kanban_delete_card(topic, "artefacts", norm) for key in ("sidecar", "facts", "question_pattern", "sub_roh"): d = _json_file(files[key]) if isinstance(d, dict): hits = [k for k in d if _norm_title(k) == norm] if hits: for k in hits: d.pop(k) atomic_write_json(files[key], d, indent=1) art = _json_file(files["artefakte"]) if isinstance(art, dict): neu = {t: [e for e in (es if isinstance(es, list) else []) if _norm_title(_title(str(e.get("block", "")))) != norm] for t, es in art.items()} if neu != art: atomic_write_json(files["artefakte"], neu, indent=1) def _rename_in_files(files: dict, norm: str, neu: str) -> None: """Titel-Keys der Sidecar-JSONs + artefakte-`block`-Felder auf den bereinigten Titel.""" for key in ("sidecar", "facts", "question_pattern", "sub_roh"): d = _json_file(files[key]) if isinstance(d, dict): hits = [k for k in d if _norm_title(k) == norm and k != neu] if hits: for k in hits: d[neu] = d.pop(k) atomic_write_json(files[key], d, indent=1) art = _json_file(files["artefakte"]) if isinstance(art, dict): changed = False for es in art.values(): for e in es if isinstance(es, list) else []: if _norm_title(_title(str(e.get("block", "")))) == norm and e.get("block") != neu: e["block"] = neu changed = True if changed: atomic_write_json(files["artefakte"], art, indent=1)