330 lines
16 KiB
Python
330 lines
16 KiB
Python
"""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, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach
|
|
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
|
|
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
import database as db
|
|
import qa
|
|
from agents import run_agent
|
|
from blocks import _blocks_files, _evidence_pack, source_folder
|
|
from fsutil import atomic_write_json
|
|
from jsonio import parse_json_text, read_json_file as _json_file
|
|
from pipeline import _yesno_schema
|
|
from textkit import _norm_title, _title, clean_title
|
|
|
|
log = logging.getLogger("creator.repair")
|
|
|
|
JUDGE_TIMEOUT = 600
|
|
from config import EVIDENCE_PER_BLOCK, JUDGE_CHUNK # noqa: E402 — zentral tunebar
|
|
|
|
|
|
async def repair_befunde(topic: str) -> dict:
|
|
tdir = qa.QA_DIR / topic
|
|
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
|
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
|
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}
|
|
|
|
hygiene = await _fix_hygiene(topic, report, by_norm, files)
|
|
merges = await _merge_dubletten(topic, report, by_norm, files)
|
|
sub_merges = await _merge_sub_dubletten(topic, report, files)
|
|
entfernt = await _entferne_fremd_unecht(topic, report, by_norm, files)
|
|
aufgeraeumt = await _raeume_waisen(topic)
|
|
|
|
# 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)
|
|
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
|
|
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
|
|
|
|
|
|
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
|
|
"""No-Tool-Judge-Wellen über alle Items (fail-open: Fehler → leeres Verdikt = behalten)."""
|
|
verdicts: dict[int, str] = {}
|
|
for lo in range(0, len(items), JUDGE_CHUNK):
|
|
chunk = items[lo:lo + JUDGE_CHUNK]
|
|
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(chunk, 1))
|
|
try:
|
|
rc, out, _err = await run_agent(
|
|
f"repair-{topic}-{key}-{lo}", qa._qa_prompt(template, topic=topic, extra="", **{slot: listing}),
|
|
JUDGE_TIMEOUT, role="judge", capabilities="none", scope=topic, label=f"Repair {key}")
|
|
v = (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {}
|
|
except Exception:
|
|
log.exception("[%s] Repair-Judge %s fehlgeschlagen — Befunde bleiben", topic, key)
|
|
v = {}
|
|
verdicts.update({lo + k: urteil for k, urteil in v.items()})
|
|
return verdicts
|
|
|
|
|
|
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 — bleiben Befund."""
|
|
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_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
|
"""Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja.
|
|
Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped."""
|
|
paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja"
|
|
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
|
|
if not paare:
|
|
return []
|
|
v = await _judge("QA-Dubletten", topic, "dubletten", "pairs",
|
|
[f"A: {p['a']}\nB: {p['b']}" for p in paare])
|
|
merged = []
|
|
for i, p in enumerate(paare, 1):
|
|
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
|
|
if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]:
|
|
continue
|
|
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)
|
|
merged.append(f"{lp.get('title')} → {wp.get('title')}")
|
|
return merged
|
|
|
|
|
|
_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):
|
|
try:
|
|
kp = len((json.loads(r.get("facts") or "{}")).get("key_points") or [])
|
|
except ValueError:
|
|
kp = 0
|
|
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) -> 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 = await _judge("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])
|
|
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
|
|
|
|
|
|
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) -> list[str]:
|
|
out = []
|
|
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 = await _judge("QA-Repair-Beleg", topic, "fremd", "blocks", lines)
|
|
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 = await _judge("QA-Bausteine", topic, "unecht", "blocks", lines)
|
|
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
|
|
|
|
|
|
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)
|