308 lines
10 KiB
Python
308 lines
10 KiB
Python
"""Deduplicación de entidades en Neo4j - Fase 1: Normalización de acentos.
|
|
|
|
Encuentra entidades duplicadas que difieren solo por acentos/tildes
|
|
y las mergea en un nodo canónico, transfiriendo todas las relaciones.
|
|
|
|
v2: Retry con backoff, batch Cypher por label, checkpoint para retomar.
|
|
|
|
CLI:
|
|
python dedup_entities.py # dry-run (solo reporta)
|
|
python dedup_entities.py --execute # ejecuta merge
|
|
python dedup_entities.py --label Patologia # solo un label
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import time
|
|
import unicodedata
|
|
|
|
# Fix Windows encoding
|
|
if sys.stdout.encoding != 'utf-8':
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
|
|
from db import run_query, run_write
|
|
|
|
ENTITY_LABELS = [
|
|
"Patologia", "EstructuraAnatomica", "Procedimiento", "Farmaco",
|
|
"GrupoFarmacologico", "Agente", "Signo", "Sintoma",
|
|
"MetodoDx", "Hallazgo", "Parametro",
|
|
]
|
|
|
|
CHECKPOINT_FILE = os.path.join(os.path.dirname(__file__), "dedup_checkpoint.json")
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY = 2 # seconds, doubles each retry
|
|
|
|
|
|
def strip_accents(s: str) -> str:
|
|
nfkd = unicodedata.normalize('NFKD', s)
|
|
return ''.join(c for c in nfkd if not unicodedata.combining(c))
|
|
|
|
|
|
def has_accents(s: str) -> bool:
|
|
return s != strip_accents(s)
|
|
|
|
|
|
def retry_query(func, *args, **kwargs):
|
|
"""Execute a DB function with retry + exponential backoff."""
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
try:
|
|
return func(*args, **kwargs)
|
|
except Exception as e:
|
|
if attempt == MAX_RETRIES:
|
|
raise
|
|
delay = RETRY_DELAY * (2 ** attempt)
|
|
print(f" Retry {attempt+1}/{MAX_RETRIES} in {delay}s: {str(e)[:60]}")
|
|
time.sleep(delay)
|
|
|
|
|
|
def pick_canonical(nodes: list) -> dict:
|
|
return max(nodes, key=lambda n: (
|
|
has_accents(n["nombre"]),
|
|
n.get("freq") or 0,
|
|
len(n.get("sinonimos") or []),
|
|
))
|
|
|
|
|
|
def load_checkpoint() -> dict:
|
|
if os.path.exists(CHECKPOINT_FILE):
|
|
with open(CHECKPOINT_FILE, "r") as f:
|
|
return json.load(f)
|
|
return {"completed_labels": [], "stats": {}}
|
|
|
|
|
|
def save_checkpoint(data: dict):
|
|
with open(CHECKPOINT_FILE, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
|
|
|
|
def fetch_entities(label: str) -> list:
|
|
return retry_query(run_query, f"""
|
|
MATCH (n:{label})
|
|
RETURN elementId(n) AS eid, n.nombre AS nombre,
|
|
n.freq AS freq, n.sinonimos AS sinonimos
|
|
""")
|
|
|
|
|
|
def find_accent_groups(entities: list) -> dict:
|
|
groups = {}
|
|
for ent in entities:
|
|
key = strip_accents(ent["nombre"].lower().strip())
|
|
groups.setdefault(key, []).append(ent)
|
|
return {k: v for k, v in groups.items() if len(v) > 1}
|
|
|
|
|
|
def merge_group_batch(canon: dict, duplicates: list, label: str) -> dict:
|
|
"""Mergea un grupo usando queries batch con retry."""
|
|
canon_eid = canon["eid"]
|
|
total = {"menciona": 0, "rels_out": 0, "rels_in": 0, "deleted": 0}
|
|
|
|
all_sinonimos = set(canon.get("sinonimos") or [])
|
|
total_freq = canon.get("freq") or 0
|
|
dup_eids = [d["eid"] for d in duplicates]
|
|
|
|
for dup in duplicates:
|
|
all_sinonimos.add(dup["nombre"])
|
|
for s in (dup.get("sinonimos") or []):
|
|
all_sinonimos.add(s)
|
|
total_freq += dup.get("freq") or 0
|
|
|
|
# 1. Batch: transferir MENCIONA de todos los dups al canon
|
|
menciona = retry_query(run_query, """
|
|
UNWIND $dup_eids AS deid
|
|
MATCH (c:Chunk)-[:MENCIONA]->(dup) WHERE elementId(dup) = deid
|
|
RETURN DISTINCT elementId(c) AS chunk_eid
|
|
""", {"dup_eids": dup_eids})
|
|
|
|
if menciona:
|
|
retry_query(run_write, """
|
|
UNWIND $chunks AS ceid
|
|
MATCH (c) WHERE elementId(c) = ceid
|
|
MATCH (canon) WHERE elementId(canon) = $canon_eid
|
|
MERGE (c)-[:MENCIONA]->(canon)
|
|
""", {"chunks": [m["chunk_eid"] for m in menciona], "canon_eid": canon_eid})
|
|
total["menciona"] = len(menciona)
|
|
|
|
# 2. Batch: transferir rels outgoing de todos los dups
|
|
rels_out = retry_query(run_query, """
|
|
UNWIND $dup_eids AS deid
|
|
MATCH (dup)-[r]->(target)
|
|
WHERE elementId(dup) = deid
|
|
AND elementId(target) <> $canon_eid
|
|
AND NOT elementId(target) IN $dup_eids
|
|
AND type(r) <> 'MENCIONA'
|
|
RETURN DISTINCT type(r) AS rtype, elementId(target) AS target_eid
|
|
""", {"dup_eids": dup_eids, "canon_eid": canon_eid})
|
|
|
|
if rels_out:
|
|
by_type = {}
|
|
for r in rels_out:
|
|
by_type.setdefault(r["rtype"], []).append(r["target_eid"])
|
|
for rtype, targets in by_type.items():
|
|
retry_query(run_write, f"""
|
|
UNWIND $targets AS teid
|
|
MATCH (canon) WHERE elementId(canon) = $canon_eid
|
|
MATCH (t) WHERE elementId(t) = teid
|
|
MERGE (canon)-[:{rtype}]->(t)
|
|
""", {"targets": targets, "canon_eid": canon_eid})
|
|
total["rels_out"] = len(rels_out)
|
|
|
|
# 3. Batch: transferir rels incoming de todos los dups
|
|
rels_in = retry_query(run_query, """
|
|
UNWIND $dup_eids AS deid
|
|
MATCH (source)-[r]->(dup)
|
|
WHERE elementId(dup) = deid
|
|
AND elementId(source) <> $canon_eid
|
|
AND NOT elementId(source) IN $dup_eids
|
|
AND type(r) <> 'MENCIONA'
|
|
RETURN DISTINCT type(r) AS rtype, elementId(source) AS source_eid
|
|
""", {"dup_eids": dup_eids, "canon_eid": canon_eid})
|
|
|
|
if rels_in:
|
|
by_type = {}
|
|
for r in rels_in:
|
|
by_type.setdefault(r["rtype"], []).append(r["source_eid"])
|
|
for rtype, sources in by_type.items():
|
|
retry_query(run_write, f"""
|
|
UNWIND $sources AS seid
|
|
MATCH (s) WHERE elementId(s) = seid
|
|
MATCH (canon) WHERE elementId(canon) = $canon_eid
|
|
MERGE (s)-[:{rtype}]->(canon)
|
|
""", {"sources": sources, "canon_eid": canon_eid})
|
|
total["rels_in"] = len(rels_in)
|
|
|
|
# 4. Batch: DETACH DELETE todos los dups de una vez
|
|
retry_query(run_write, """
|
|
UNWIND $dup_eids AS deid
|
|
MATCH (n) WHERE elementId(n) = deid
|
|
DETACH DELETE n
|
|
""", {"dup_eids": dup_eids})
|
|
total["deleted"] = len(duplicates)
|
|
|
|
# 5. Actualizar canónico
|
|
all_sinonimos.discard(canon["nombre"])
|
|
retry_query(run_write, """
|
|
MATCH (n) WHERE elementId(n) = $eid
|
|
SET n.sinonimos = $sins, n.freq = $freq
|
|
""", {"eid": canon_eid, "sins": sorted(list(all_sinonimos)), "freq": total_freq})
|
|
|
|
return total
|
|
|
|
|
|
def dedup_label(label: str, execute: bool = False) -> dict:
|
|
entities = fetch_entities(label)
|
|
groups = find_accent_groups(entities)
|
|
|
|
if not groups:
|
|
return {"groups": 0, "deleted": 0, "menciona": 0, "rels": 0}
|
|
|
|
total = {"groups": len(groups), "deleted": 0, "menciona": 0, "rels": 0}
|
|
processed = 0
|
|
|
|
for key, nodes in groups.items():
|
|
canon = pick_canonical(nodes)
|
|
dups = [n for n in nodes if n["eid"] != canon["eid"]]
|
|
|
|
if not execute:
|
|
dup_names = [d["nombre"] for d in dups]
|
|
print(f" {canon['nombre']} <- {dup_names}")
|
|
total["deleted"] += len(dups)
|
|
continue
|
|
|
|
try:
|
|
stats = merge_group_batch(canon, dups, label)
|
|
total["deleted"] += stats["deleted"]
|
|
total["menciona"] += stats["menciona"]
|
|
total["rels"] += stats["rels_out"] + stats["rels_in"]
|
|
processed += 1
|
|
|
|
if processed % 50 == 0:
|
|
print(f" [{processed}/{len(groups)}] {total['deleted']} eliminados")
|
|
|
|
except Exception as e:
|
|
print(f" ERROR en grupo '{key}': {str(e)[:80]}")
|
|
continue
|
|
|
|
return total
|
|
|
|
|
|
def main():
|
|
execute = "--execute" in sys.argv
|
|
target_label = None
|
|
if "--label" in sys.argv:
|
|
idx = sys.argv.index("--label")
|
|
target_label = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
|
|
|
labels = [target_label] if target_label else ENTITY_LABELS
|
|
mode = "EXECUTE" if execute else "DRY-RUN"
|
|
|
|
# Load checkpoint
|
|
checkpoint = load_checkpoint()
|
|
if execute and not target_label:
|
|
completed = checkpoint.get("completed_labels", [])
|
|
remaining = [l for l in labels if l not in completed]
|
|
if completed:
|
|
print(f" Retomando desde checkpoint. Ya completados: {completed}")
|
|
labels = remaining
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" DEDUP FASE 1 v2: Normalización de acentos [{mode}]")
|
|
print(f" Labels a procesar: {labels}")
|
|
print(f"{'='*60}\n")
|
|
|
|
grand_total = {"groups": 0, "deleted": 0, "menciona": 0, "rels": 0}
|
|
|
|
for label in labels:
|
|
print(f"--- {label} ---")
|
|
t0 = time.time()
|
|
|
|
try:
|
|
stats = dedup_label(label, execute=execute)
|
|
except Exception as e:
|
|
print(f" FATAL en {label}: {str(e)[:100]}")
|
|
print(f" Guardando checkpoint y saliendo...")
|
|
save_checkpoint(checkpoint)
|
|
sys.exit(1)
|
|
|
|
elapsed = time.time() - t0
|
|
|
|
for k in grand_total:
|
|
grand_total[k] += stats[k]
|
|
|
|
if stats["groups"] == 0:
|
|
print(" Sin duplicados por acentos")
|
|
else:
|
|
print(f" {stats['groups']} grupos, {stats['deleted']} nodos eliminados" +
|
|
(f", {stats['menciona']} MENCIONA, {stats['rels']} rels ({elapsed:.1f}s)" if execute else ""))
|
|
|
|
# Save checkpoint per label
|
|
if execute:
|
|
checkpoint["completed_labels"] = checkpoint.get("completed_labels", []) + [label]
|
|
checkpoint["stats"] = checkpoint.get("stats", {})
|
|
checkpoint["stats"][label] = stats
|
|
save_checkpoint(checkpoint)
|
|
print(f" Checkpoint guardado.")
|
|
|
|
print()
|
|
|
|
# Resumen
|
|
print(f"{'='*60}")
|
|
print(f" RESUMEN {'(ejecutado)' if execute else '(dry-run)'}")
|
|
print(f"{'='*60}")
|
|
print(f" Grupos duplicados: {grand_total['groups']}")
|
|
print(f" Nodos {'eliminados' if execute else 'a eliminar'}: {grand_total['deleted']}")
|
|
if execute:
|
|
print(f" MENCIONA transferidas: {grand_total['menciona']}")
|
|
print(f" Relaciones transferidas: {grand_total['rels']}")
|
|
|
|
# Limpiar checkpoint al terminar
|
|
if os.path.exists(CHECKPOINT_FILE):
|
|
os.remove(CHECKPOINT_FILE)
|
|
print(" Checkpoint limpiado (todo completado).")
|
|
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|