"""Dedup-Barriere (R11): 1) Anker-Overlap ≥0.5 = einziger Auto-Merge. 2) Kandidaten-Kanäle (Union): Titel-Kern, Jaccard gestemmt ≥0.3, Titel-Fuzzy ≥85 (nur ≥2 Inhaltstoken), Akronym-Kanal, Gleicher-Soll-Punkt. 3) 2er-Panel, Merge NUR einstimmig; geprüfte Paare als 'verwandt'-Kante — nie zweimal Tokens.""" from rapidfuzz import fuzz from . import config, db, engine, graph, llm, panels, textkit def _aktive(topic: str) -> list[dict]: return db.query("SELECT * FROM atome WHERE topic=? AND status='aktiv'", topic) def _anker(atom_id: int) -> list[dict]: return db.query("SELECT * FROM anker WHERE atom_id=? AND start>=0", atom_id) def _merge(topic: str, gewinner: dict, verlierer: dict) -> None: """Gewinner = längere Definition; Anker wandern, Kanten hängen um.""" if len(verlierer["definition"]) > len(gewinner["definition"]): gewinner, verlierer = verlierer, gewinner db.update("atome", "id=?", (verlierer["id"],), status="verworfen", merged_into=gewinner["id"]) db.update("anker", "atom_id=?", (verlierer["id"],), atom_id=gewinner["id"]) db.execute("UPDATE OR IGNORE kanten SET von_atom=? WHERE von_atom=?", gewinner["id"], verlierer["id"]) db.execute("UPDATE OR IGNORE kanten SET zu_atom=? WHERE zu_atom=?", gewinner["id"], verlierer["id"]) db.execute("DELETE FROM kanten WHERE von_atom=zu_atom") if verlierer["soll_id"] and not gewinner["soll_id"]: db.update("atome", "id=?", (gewinner["id"],), soll_id=verlierer["soll_id"]) def _auto_merge_anker(topic: str) -> int: """Gleiche Quellstelle = gleiches Atom (deterministisch, kein Judge).""" n = 0 atome = _aktive(topic) for i, a in enumerate(atome): if a["status"] != "aktiv": continue spans_a = [(x["quelle_id"], x["start"], x["ende"]) for x in _anker(a["id"])] for b in atome[i + 1:]: if b["status"] != "aktiv": continue spans_b = [(x["quelle_id"], x["start"], x["ende"]) for x in _anker(b["id"])] if any(qa == qb and textkit.ueberlappung((sa, ea), (sb, eb)) >= config.ANKER_OVERLAP_MERGE for qa, sa, ea in spans_a for qb, sb, eb in spans_b): _merge(topic, a, b) b["status"] = "verworfen" n += 1 return n def _kandidaten(topic: str) -> list[tuple[dict, dict, str]]: atome = _aktive(topic) geprueft = {(k["von_atom"], k["zu_atom"]) for k in db.query( "SELECT von_atom, zu_atom FROM kanten WHERE topic=?", topic)} paare = [] for i, a in enumerate(atome): for b in atome[i + 1:]: key = (min(a["id"], b["id"]), max(a["id"], b["id"])) if key in geprueft: continue # verwandt-Kante: nie zweimal Tokens if textkit.negations_menge(a["definition"]) != \ textkit.negations_menge(b["definition"]): continue # Antonyme messen 0.91-0.95 — harte Vorbedingung kanal = _kanal(a, b) if kanal: paare.append((a, b, kanal)) if len(paare) >= config.DEDUP_KANDIDATEN_CAP: return paare return paare def _signatur(text: str) -> set: """Zahlen + großgeschriebene Wörter — dieselbe Studie/Zahl in Paraphrase teilt sie (a20≈a78: 'Biwer', '2023'). Unicode-Groß statt [A-ZÄÖÜ]-Klasse (Lektion 106); Schriften ohne Großschreibung liefern nur Zahlen.""" import re zahlen = set(re.findall(r"\b\d{2,4}\b", text)) namen = {w for w in re.findall(r"\w{4,}", text, re.UNICODE) if w[0].isupper() and not w[0].isdigit()} return zahlen | namen def _kanal(a: dict, b: dict) -> str: if textkit.titel_kern(a["titel"]) == textkit.titel_kern(b["titel"]): return "titel_kern" if textkit.ist_akronym_von(a["titel"], b["titel"]) or \ textkit.ist_akronym_von(b["titel"], a["titel"]): return "akronym" # Lektion 28 — fehlte in creator2 if a["soll_id"] and a["soll_id"] == b["soll_id"]: return "gleicher_soll" # nach Verdichtung wenige Paare — Panel gatet if len(_signatur(a["definition"]) & _signatur(b["definition"])) >= 2: return "zahlen_namen" if textkit.jaccard(a["definition"], b["definition"]) >= config.DEDUP_JACCARD: return "jaccard" ta, tb = textkit.tokens(a["titel"]), textkit.tokens(b["titel"]) if len(ta) >= 2 and len(tb) >= 2 and \ fuzz.token_set_ratio(a["titel"], b["titel"]) >= config.DEDUP_TITEL_FUZZY: return "titel_fuzzy" # nur ≥2 Inhaltstoken (Subset-100-Falle) return "" async def _chunk_urteilen(task: dict, chunk: list, tag: str) -> tuple[int, list]: """Ein Panel über ≤ DEDUP_PAARE_PRO_CALL Paare. Entscheidet nur Paare, die in ALLEN gültigen Stimmen vorkommen; fehlende → Rückgabe zum Nachprüfen (nie still als „keine Dublette" werten).""" topic = task["topic"] liste = textkit.paar_liste( [(f"[{a['id']}] {a['titel']}: {a['definition'][:200]}", f"[{b['id']}] {b['titel']}: {b['definition'][:200]}") for a, b, _ in chunk]) async def ruf(p): text = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="dedup", item=f"{task['item']}:{tag}:p{p}", role="judge", n=len(chunk), skill_namen=graph.skills_von(task["knoten"]), werte={"paare": liste}) if text is None: return None urteile = {} for b in textkit.bloecke(text, "URTEIL"): if b.get("paar", "").isdigit(): urteile[int(b["paar"])] = b.get("urteil", "UNSICHER") return urteile stimmen = await panels.panel(ruf, config.MERGE_PANEL) gueltig = [s for s in stimmen if s is not None] merges, unvollstaendig = 0, [] for j, (a, b, kanal) in enumerate(chunk): if len(gueltig) < config.MERGE_PANEL or any((j + 1) not in s for s in gueltig): unvollstaendig.append((a, b, kanal)) continue urteile = [s[j + 1] for s in gueltig] if all(u in ("dublette", "enthalten") for u in urteile): a_neu = db.one("SELECT * FROM atome WHERE id=?", a["id"]) b_neu = db.one("SELECT * FROM atome WHERE id=?", b["id"]) if a_neu["status"] == "aktiv" and b_neu["status"] == "aktiv": _merge(topic, a_neu, b_neu) # Gewinner = längere Definition merges += 1 else: # kein einstimmiges Ja → Gedächtnis-Kante, nie wieder prüfen db.insert("kanten", ignore=True, topic=topic, von_atom=min(a["id"], b["id"]), zu_atom=max(a["id"], b["id"]), art="verwandt") return merges, unvollstaendig @engine.worker("dedup.dedup") async def dedup(task: dict) -> engine.Ergebnis: topic = task["topic"] auto = _auto_merge_anker(topic) paare = _kandidaten(topic) merges, offen = 0, [] for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL): chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL] m, unvollstaendig = await _chunk_urteilen(task, chunk, f"c{i}") merges += m offen += unvollstaendig rest = [] for i in range(0, len(offen), config.DEDUP_PAARE_PRO_CALL): # EIN Nach-Call nur mit den unentschiedenen Paaren m, unvollstaendig = await _chunk_urteilen( task, offen[i:i + config.DEDUP_PAARE_PRO_CALL], f"nach{i}") merges += m rest += unvollstaendig for a, b, _ in rest: # immer noch ohne Urteil → sichtbar, KEINE Kante item = f"paar:{min(a['id'], b['id'])}:{max(a['id'], b['id'])}" if not db.one("SELECT id FROM befunde WHERE run_id=? AND art='parse' " "AND item=?", task["run_id"], item): db.insert("befunde", run_id=task["run_id"], stufe="inventar", knoten="dedup", art="parse", item=item, detail="Paar-Urteil fehlt auch nach Nach-Call", runde=task["runde"]) stand = (db.one("SELECT COALESCE(MAX(id),0) m FROM atome WHERE topic=? AND " "status='aktiv'", topic))["m"] # Zuordnungs-Tasks nur für UNGEPRÜFTE Atome ohne Soll-Punkt — KEINER-Atome # werden nicht erneut gefragt (Endlosschleifen-Lektion, Echtlauf 2) ohne = [a["id"] for a in db.query( "SELECT id FROM atome WHERE topic=? AND status='aktiv' AND " "soll_id IS NULL AND soll_geprueft=0", topic)] neue = [{"knoten": "soll_zuordnung", "item": f"{task['item']}:z{i}", "payload": {"atom_ids": ohne[i:i + config.ZIELE_CHUNK_ATOME]}} for i in range(0, len(ohne), config.ZIELE_CHUNK_ATOME)] return engine.Ergebnis(daten={"auto": auto, "panel_merges": merges, "kandidaten": len(paare), "stand": stand}, neue_tasks=neue)