"""Rückwärts-Redundanz-Check (Kernziel Länge minimieren, R11): Satzpaare aus VERSCHIEDENEN Kapiteln mit Jaccard ≥0.6 → 2er-Panel entscheidet „redundant vs. didaktisch gewollt" (Recap ist keine Dopplung). Befunde behebt der Fix-Knoten per Ein-Satz-Refresher + Anker-Link.""" from . import config, db, engine, graph, llm, panels, textkit from .dedup import _signatur def kandidaten_paare(topic: str) -> list[dict]: saetze = [] for row in db.query( "SELECT s.baustein_id bid, s.text, b.ord bord, k.ord kord FROM " "sections s JOIN bausteine b ON b.id=s.baustein_id " "JOIN kapitel k ON k.id=b.kapitel_id WHERE b.topic=? AND s.text!=''", topic): for satz in textkit.satz_split(row["text"]): toks = textkit.stamm_tokens(satz) if len(toks) >= config.REDUNDANZ_SATZ_MIN_TOKEN: saetze.append({"satz": satz, "toks": toks, "bid": row["bid"], "pos": (row["kord"], row["bord"])}) def shingles(satz: str) -> set: woerter = textkit.norm(satz).split() n = config.REDUNDANZ_SHINGLE return {" ".join(woerter[i:i + n]) for i in range(len(woerter) - n + 1)} paare = [] for i, a in enumerate(saetze): for b in saetze[i + 1:]: if a["bid"] == b["bid"]: continue # innerhalb einer Section: Sache des Prüfers, nicht hier schnitt = len(a["toks"] & b["toks"]) jaccard_treffer = schnitt and schnitt / len(a["toks"] | b["toks"]) \ >= config.REDUNDANZ_JACCARD # Shingle-Kanal: identische Wort-Folge in längeren Sätzen — # Jaccard übersieht sie, wenn der Restsatz verschieden ist wortgleich = bool(shingles(a["satz"]) & shingles(b["satz"])) # Fakten-Kanal: dieselbe Studie/Zahl in Paraphrase teilt Zahlen + # Eigennamen (Audit: „Ariga & Lleras 2011" stand 3× im Guide, # Wort-Jaccard nur 0.21) fakten = len(_signatur(a["satz"]) & _signatur(b["satz"])) \ >= config.FAKTEN_SIGNATUR_MIN if jaccard_treffer or wortgleich or fakten: paare.append({"a": a, "b": b, "wortgleich": wortgleich}) return paare @engine.worker("redundanz.pruefen") async def pruefen(task: dict) -> engine.Ergebnis: paare = kandidaten_paare(task["topic"]) befunde = 0 for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL): chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL] liste = textkit.paar_liste( [(q["a"]["satz"], q["b"]["satz"]) for q in chunk]) async def ruf(p, liste=liste, n=len(chunk)): text = await llm.call( run_id=task["run_id"], stufe="guide", knoten="redundanz", item=f"{task['item']}:c{i}:p{p}", role="judge", n=n, skill_namen=graph.skills_von(task["knoten"]), werte={"paare": liste}) if text is None: return None return {int(b["paar"]): b.get("urteil", "UNSICHER") for b in textkit.bloecke(text, "URTEIL") if b.get("paar", "").isdigit()} stimmen = await panels.panel(ruf, config.PRUEF_PANEL) for j, p in enumerate(chunk): def urteil(s, j=j): if s is None or (j + 1) not in s: return None u = s[j + 1] return True if u == "redundant" else ( False if u == "didaktisch_gewollt" else None) # Fast wörtliche Paare (gemeinsame 5-Wort-Folge) sind NIE # „didaktisch gewollt" — das Panel darf sie nicht freisprechen # (Audit: 94-Studierende-Satz stand 2× nahezu wortgleich im Text) if p.get("wortgleich") or panels.einstimmig( stimmen, config.PRUEF_PANEL, urteil): # Der SPÄTERE Baustein trägt den Befund (dort wird verwiesen) spaeter = max(p["a"], p["b"], key=lambda x: x["pos"]) schon = db.one( "SELECT id FROM befunde WHERE item=? AND art='redundanz' AND " "detail=? AND status IN ('offen','eskaliert','freispruch')", f"baustein:{spaeter['bid']}", spaeter["satz"][:400]) if not schon: db.insert("befunde", run_id=task["run_id"], stufe="guide", knoten="redundanz", art="redundanz", item=f"baustein:{spaeter['bid']}", detail=spaeter["satz"][:400], runde=task["runde"]) befunde += 1 neue = [] if not db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' " "AND status='fertig'", task["topic"]): neue = [{"knoten": "lektorat", "item": f"r{task['runde']}:lektorat"}] return engine.Ergebnis(daten={"paare": len(paare), "befunde": befunde}, neue_tasks=neue)