99 lines
4.9 KiB
Python
99 lines
4.9 KiB
Python
"""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)
|