Files
creator3/backend/redundanz.py
2026-07-24 11:23:18 +02:00

99 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)