This commit is contained in:
team3
2026-07-23 16:07:36 +02:00
commit cb68cd671b
88 changed files with 10029 additions and 0 deletions

189
backend/dedup.py Normal file
View 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)