192 lines
8.8 KiB
Python
192 lines
8.8 KiB
Python
"""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)
|