Skip to content

Commit 35b0a79

Browse files
THIOYE97claude
andcommitted
Dédoublonnage : inclusion des graphies, non plus simple comptage
Le critère précédent comparait le NOMBRE de libellés. Deux copies peuvent en porter autant tout en couvrant des graphies différentes : la copie aurait été supprimée et ses graphies perdues. Une copie n'est désormais supprimée que si toutes ses graphies sont déjà couvertes par la version rattachée à une source. Une seule graphie manquante suffit à la conserver. Sur la production, le comptage avait déjà écarté 3 276 copies sur 26 191. L'inclusion en écartera davantage — et surtout, elle rend la garantie démontrable plutôt que probable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 630ceb6 commit 35b0a79

2 files changed

Lines changed: 63 additions & 33 deletions

File tree

app/scripts/dedupe_entities.py

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -69,29 +69,31 @@ def _sql_candidats(db) -> str:
6969
FROM entities e
7070
WHERE NOT ({ref})
7171
),
72-
-- Pour chaque nom, le nombre de libellés que porte la version rattachée
73-
-- à une source : c'est lui qui détermine le pouvoir de rapprochement.
72+
-- Libellés portés par chaque entité, sous forme d'ensemble normalisé.
73+
-- Compter ne suffit pas : deux copies peuvent porter autant de libellés tout
74+
-- en couvrant des graphies différentes. Seule l'INCLUSION garantit qu'aucune
75+
-- graphie ne disparaît.
76+
libelles AS (
77+
SELECT entity_id, ARRAY_AGG(DISTINCT name_normalized) AS jeu
78+
FROM entity_names GROUP BY entity_id
79+
),
7480
officielles AS (
75-
SELECT UPPER(TRIM(e.primary_name)) AS cle, MAX(k.n) AS n_libelles
81+
SELECT UPPER(TRIM(e.primary_name)) AS cle,
82+
ARRAY_AGG(DISTINCT g) AS jeu
7683
FROM entities e
7784
JOIN source_records sr ON sr.entity_id = e.id
78-
JOIN (SELECT entity_id, COUNT(*) n FROM entity_names GROUP BY entity_id) k
79-
ON k.entity_id = e.id
85+
JOIN libelles l ON l.entity_id = e.id
86+
CROSS JOIN LATERAL UNNEST(l.jeu) AS g
8087
GROUP BY UPPER(TRIM(e.primary_name))
81-
),
82-
richesse AS (
83-
SELECT o.id, COUNT(en.id) AS n_libelles
84-
FROM orphelines o LEFT JOIN entity_names en ON en.entity_id = o.id
85-
GROUP BY o.id
8688
)
8789
SELECT o.id, o.primary_name, o.entity_type
8890
FROM orphelines o
8991
JOIN officielles f ON f.cle = o.cle
90-
JOIN richesse r ON r.id = o.id
91-
-- Une copie qui porte PLUS de libellés que sa jumelle est conservée :
92-
-- la supprimer ferait cesser de reconnaître des graphies aujourd'hui
93-
-- couvertes. Un alias perdu, c'est une personne qu'on ne détecte plus.
94-
WHERE r.n_libelles <= f.n_libelles
92+
LEFT JOIN libelles lo ON lo.entity_id = o.id
93+
-- La copie n'est supprimée que si TOUTES ses graphies sont déjà couvertes
94+
-- par la version rattachée à une source. Une seule graphie manquante suffit
95+
-- à la conserver : un alias perdu, c'est une personne qu'on ne détecte plus.
96+
WHERE COALESCE(lo.jeu, ARRAY[]::text[]) <@ f.jeu
9597
"""
9698

9799

@@ -113,30 +115,30 @@ def analyser(db) -> dict:
113115
# Contrôle de non-régression du filtrage : supprimer une copie qui porte
114116
# PLUS de libellés que sa jumelle ferait perdre du pouvoir de rapprochement.
115117
# Un nom d'alias en moins, c'est une personne qu'on cesse de reconnaître.
116-
# Copies plus riches que leur jumelle : exclues de la suppression par la
117-
# requête ci-dessus, comptées ici pour être signalées.
118-
ref2 = ref
118+
# Copies conservées parce qu'elles couvrent des graphies absentes de la
119+
# version rattachée à une source.
119120
plus_riches = db.execute(text(f"""
120-
WITH orph AS (
121-
SELECT e.id, UPPER(TRIM(e.primary_name)) AS cle
122-
FROM entities e WHERE NOT ({ref2})
121+
WITH libelles AS (
122+
SELECT entity_id, ARRAY_AGG(DISTINCT name_normalized) AS jeu
123+
FROM entity_names GROUP BY entity_id
123124
),
124-
n_orph AS (
125-
SELECT o.id, o.cle, COUNT(en.id) AS n
126-
FROM orph o LEFT JOIN entity_names en ON en.entity_id = o.id
127-
GROUP BY o.id, o.cle
125+
orph AS (
126+
SELECT e.id, UPPER(TRIM(e.primary_name)) AS cle
127+
FROM entities e WHERE NOT ({ref})
128128
),
129-
n_off AS (
130-
SELECT UPPER(TRIM(e.primary_name)) AS cle, MAX(k.n) AS n
129+
off AS (
130+
SELECT UPPER(TRIM(e.primary_name)) AS cle, ARRAY_AGG(DISTINCT g) AS jeu
131131
FROM entities e
132132
JOIN source_records sr ON sr.entity_id = e.id
133-
JOIN (SELECT entity_id, COUNT(*) n FROM entity_names GROUP BY entity_id) k
134-
ON k.entity_id = e.id
133+
JOIN libelles l ON l.entity_id = e.id
134+
CROSS JOIN LATERAL UNNEST(l.jeu) AS g
135135
GROUP BY UPPER(TRIM(e.primary_name))
136136
)
137-
SELECT COUNT(*) FROM n_orph o
138-
JOIN n_off f ON f.cle = o.cle
139-
WHERE o.n > f.n
137+
SELECT COUNT(*)
138+
FROM orph o
139+
JOIN off f ON f.cle = o.cle
140+
LEFT JOIN libelles lo ON lo.entity_id = o.id
141+
WHERE NOT (COALESCE(lo.jeu, ARRAY[]::text[]) <@ f.jeu)
140142
""")).scalar()
141143
return {"total": total, "sans_source": sans_source, "protegees": protegees,
142144
"candidats": candidats, "plus_riches": plus_riches,
@@ -174,7 +176,7 @@ def main(argv: list[str]) -> int:
174176
print(f" sans enregistrement source: {a['sans_source']:>7}")
175177
print(f" dont référencées ailleurs : {a['protegees']:>7} (conservées)")
176178
print(f" doublons supprimables : {a['candidats']:>7}")
177-
print(f" dont plus riches en alias : {a['plus_riches']:>7}"
179+
print(f" dont graphies non couvertes: {a['plus_riches']:>7}"
178180
+ (" (conservées : elles couvrent des graphies que la version"
179181
" rattachée à une source ignore)" if a["plus_riches"] else ""))
180182
if a["exemples"]:

tests/test_lbcft_modules.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,3 +1063,31 @@ def test_dedupe_epargne_une_copie_plus_riche_en_alias(db):
10631063
dedupe_entities.supprimer(db)
10641064
assert db.execute(text("SELECT COUNT(*) FROM entities WHERE id = CAST(:i AS uuid)"),
10651065
{"i": copie}).scalar() == 1
1066+
1067+
1068+
@pytest.mark.integration
1069+
def test_dedupe_epargne_une_copie_aux_graphies_differentes(db):
1070+
"""Compter les libellés ne suffit pas : deux copies peuvent en porter
1071+
autant tout en couvrant des graphies différentes. Seule l'inclusion
1072+
garantit qu'aucune graphie ne disparaît."""
1073+
from sqlalchemy import text
1074+
from app.scripts import dedupe_entities
1075+
from app.services.matching import normalize_name, tokenize
1076+
1077+
def alias(eid, valeur):
1078+
n = normalize_name(valeur)
1079+
db.execute(text("""
1080+
INSERT INTO entity_names (entity_id, name_raw, name_normalized,
1081+
name_tokens, is_primary, name_type)
1082+
VALUES (CAST(:i AS uuid), :r, :n, :t, false, 'ALIAS')
1083+
"""), {"i": eid, "r": valeur, "n": n, "t": tokenize(n)})
1084+
1085+
officielle = _entite(db, "SEKOU TRAORE", avec_source=True)
1086+
alias(officielle, "SEKOU T") # 2 libellés au total
1087+
copie = _entite(db, "Sekou Traore")
1088+
alias(copie, "SEIKOU TRAORE") # 2 aussi, mais AUTRE graphie
1089+
db.commit()
1090+
1091+
dedupe_entities.supprimer(db)
1092+
assert db.execute(text("SELECT COUNT(*) FROM entities WHERE id = CAST(:i AS uuid)"),
1093+
{"i": copie}).scalar() == 1

0 commit comments

Comments
 (0)