init
This commit is contained in:
189
backend/dedup.py
Normal file
189
backend/dedup.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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, 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 + Eigennamen — dieselbe Studie in Paraphrase teilt sie
|
||||
(a20≈a78: 'Biwer', '2023', '25', '35'), auch über Soll-Grenzen."""
|
||||
import re
|
||||
zahlen = set(re.findall(r"\b\d{2,4}\b", text))
|
||||
namen = {w for w in re.findall(r"\b[A-ZÄÖÜ][a-zäöü]{3,}\b", text)}
|
||||
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 = "\n\n".join(
|
||||
f"PAAR {j + 1}:\nA[{a['id']}] {a['titel']}: {a['definition'][:200]}\n"
|
||||
f"B[{b['id']}] {b['titel']}: {b['definition'][:200]}"
|
||||
for j, (a, b, _) in enumerate(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=["richter", "deutsch-praezise", "dedup-urteil"],
|
||||
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)
|
||||
Reference in New Issue
Block a user