9.9 KiB
nyora-notes-tt — le message_id ne pouvait pas être un identifiant : dédup par clé stable + comptage (11/08/2026)
Statut : RÉSOLU et validé en re-scan réel. Débloque le backfill Réception + Envoyés.
Suite de hermes-tt/mail-o365-bug-D-deplier-nest-pas-selectionner.md (commit f3b9cc1). Le
correctif du bug D était bon ; ce qui restait n'était pas un défaut de portée mais un vice de
conception de l'identifiant de dédup.
1. Le problème
message_id = sha256(subject|date_iso|sender|conversation_id) inclut received_date. Mesuré sur
le lot pilote du 10/08 : re-scanner 01 2025|Suivi|weekly report, déjà ingéré, créait 4 notes
au lieu de 0 (79 → 83).
2. Pourquoi fiabiliser le parsing de date ne pouvait pas marcher
hermes_mail_client.py, normaliser_date_owa :
if RE_HEURE_SEULE.match(jeton):
return reference.isoformat() # reference = date.today()
Un message affiché en heure seule (« 09:01 ») prend la date du jour de scrape. received_date
est donc, pour tout message récent, une fonction du moment de la collecte et non du message.
Aucun durcissement du parsing ne corrige cela — c'est pourquoi le fix A, qui s'y attaquait, ne
pouvait pas suffire.
Mesure de dérive sur les 468 notes (recalcul avec une date de référence décalée) :
| décalage du scrape | notes dont l'ancienne clé change |
|---|---|
| +1 jour | 1 / 468 |
| +30 jours | 1 / 468 |
| +1 an | 98 / 468 (20,9 %) |
Formes de date rencontrées : 354 JJ/MM/AAAA (stables), 97 JJ/MM sans année (année déduite →
dérivent), 16 non reconnues, 1 heure seule.
Règle générale : un identifiant de déduplication ne doit jamais dépendre d'un champ dérivé de l'instant de collecte.
3. Piste Message-ID RFC 5322 : fermée, et pour deux raisons
Attributs réels d'une ligne de liste OWA (relevés le 11/08) :
id=<GUID> data-convid=<base64> tabindex class aria-selected
aria-setsize aria-posinset role aria-label data-focusable-row
Ni Message-ID, ni ItemId Exchange. Seul data-convid est natif et stable — mais il est au niveau
thread. (id déjà écarté le 08/08 : GUID régénéré à chaque rendu.)
Surtout, l'en-tête RFC 5322 exigerait d'ouvrir chaque message. Or la dédup s'exécute
pipeline_processor.py avant Mimo et l'embedding : c'est toute son économie. La rendre
dépendante de l'ouverture rendrait un re-scan aussi coûteux qu'une première ingestion.
4. Ce qui a échoué en cours de route (à ne pas refaire)
Arbitrage par ouverture du message sur collision. Conçu pour trancher les ~2,6 % de collisions en comparant un hash du corps complet. À l'exécution, il se déclenchait sur toute correspondance — y compris le cas trivial d'une note déjà migrée — donc rouvrait chaque message à chaque re-scan (~37 s/message mesuré). Exactement le coût qui avait fait rejeter la piste Message-ID. Abandonné au profit du comptage.
5. La solution retenue
Niveau 1 — clé stable
lignes = raw_text nettoyé (lignes de glyphes écartées, PUA retiré)
corps = lignes[1:-1] moins toute ligne reconnue comme date (par MOTIF)
corps = corps sans marqueur de troncature (…)
dedup_key = "dk_" + sha256(lignes[0] + "\n" + corps[:120])[:24]
Deux exclusions, aussi nécessaires l'une que l'autre :
- pas de date (§2) ;
- pas de lecture par position au-delà de l'expéditeur. L'extraction de
subjectdépend deidx_date: un échec de reconnaissance de date déplace aussi l'objet. Une clé bâtie sur cet objet resterait indirectement dépendante de la date. Le retrait des dates se fait donc par motif, jamais par indice.
Le retrait par motif est sûr : RE_HEURE_SEULE / RE_JOUR_MOIS / RE_DATE_COMPLETE sont
strictement ancrées (^…$), donc une ligne d'objet contenant une date (« Veille marché du
10 Août 2026 ») n'est jamais prise pour une date.
La dernière ligne (libellé du dossier, celle du garde-fou de portée) est écartée : sinon le même message vu depuis deux dossiers donnerait deux clés alors que la dédup est globale.
Niveau 2 — décision par COMPTAGE, pas par identification
Pour chaque dedup_key : N = messages distincts dans le scan, M = notes déjà en base.
N <= M -> tout est déjà représenté, on saute le groupe
N > M -> (N - M) messages nouveaux, on ingère les (N - M) premières lignes
C'est le cœur du correctif. Aucune clé calculable depuis la ligne de liste ne peut séparer deux mails « gabarit » (SIRH, CIN Search, relances d'un même fil) : même expéditeur, même objet, même début de corps, seule la date les distingue — et elle est inutilisable. On renonce donc à les séparer par la clé, et on les compte. Résultat : les messages distincts sont conservés, sans jamais ouvrir un mail.
Contre-exemple mesuré (02 2024|…|Cons° 07/2024, deux mails SIRH) :
note_f500bee6c78b 'Validation de depot ... BS' 07/04/2025
note_eaf9ac875b7b 'Validation de depot ... BS' 10/01/2025 <- même corps sur ~230 car.
Une clé seule les fusionnerait (perte silencieuse). Le comptage les conserve : N=2, M=2.
Repli des lignes en double dans une passe
OWA renvoie parfois deux fois le même message dans /search (…|Litiges, items 0 et 6 :
identiques au caractère près, même data-convid). Sans repli, N surcompte et Mimo est relancé à
chaque re-scan. On replie sur signature_scan = texte nettoyé complet date comprise — la date
distingue ici deux lignes en double (date identique) de deux gabarits distincts (dates
différentes). Jamais persistée, jamais utilisée comme identifiant.
6. La troncature de prévisualisation d'OWA n'est PAS stable
Piège coûteux, à retenir. Une première mesure sur 00 2026|Veille (35 lignes, deux passes
entrecoupées d'une navigation) concluait à une stabilité parfaite — mais elle ne portait que sur
des messages anciens. Le même test sur un message du jour :
'... Extrait de commentaires Web :…' (passe A)
'... Extrait de commentaires Web :' (passe B)
Un seul caractère, et le hash change → doublon. D'où deux protections cumulées : retrait du marqueur de troncature, et bornage du corps à 120 caractères, en deçà de la zone qui varie.
Le bornage crée davantage de collisions de clé — sans danger ici, puisque la dédup tranche par comptage et non par unicité. Profil mesuré sur 472 notes :
| préfixe | clés | groupes en collision | dont doublons réels | dont messages distincts |
|---|---|---|---|---|
| 80 | 444 | 24 | 12 | 12 |
| 120 | 451 | 20 | 12 | 8 |
| 200 | 455 | 16 | 12 | 4 |
| entier | 459 | 13 | 12 | 1 |
Le nombre de doublons réels (12) est invariant quel que soit le préfixe : bon signe de robustesse.
7. Migration des notes existantes — hors ligne, sans un seul appel OWA
notes.raw_excerpt vaut raw_text[:2000] (pipeline_processor.py:82), troncature purement
mécanique, et le maximum observé en base est de 517 caractères : aucune note n'est tronquée.
La colonne contient donc le raw_text d'origine verbatim, et la clé se recalcule dessus à
l'identique. Aucun re-scan des 48 dossiers porteurs de notes n'a été nécessaire.
C'est un prérequis, pas un confort : sans retro-calcul, les 468 notes seraient invisibles à la dédup et toutes recréées au premier re-scan.
migrate_dedup_key.py (dry-run par défaut, --apply pour écrire) écrit dedup_key et ne
supprime rien : la fusion des doublons hérités est une opération distincte, soumise à validation.
8. Schéma
ALTER TABLE notes ADD COLUMN dedup_key TEXT; -- clé de dédup effective
ALTER TABLE notes ADD COLUMN body_hash TEXT; -- RÉSERVÉ, non alimenté
ALTER TABLE notes ADD COLUMN conversation_id TEXT; -- data-convid (niveau thread)
CREATE INDEX IF NOT EXISTS idx_notes_dedup_key ON notes(dedup_key);
message_id est conservé : traçabilité, et download_attachments_phase1.py s'en sert pour
réapparier une note à sa ligne dans le listing courant. Il n'est simplement plus la clé de dédup.
9. Correctif annexe — goto_owa_root ne rejouait pas les dépassements de délai
hermes-mail-browser/app/main.py. Le retry ne couvrait que interrupted by another navigation ; un TimeoutError était relancé tel quel → HTTP 500 → dossier classé error. Or ce
dépassement survient de façon reproductible sur le premier appel après inactivité (navigation
à froid), jamais sur les suivants — observé deux fois le 11/08, chaque fois sur le premier dossier
de la série. Sur un backfill de ~115 dossiers, cela marquerait error des dossiers parfaitement
sains. Corrigé : les dépassements sont rejoués, et la première tentative dispose de 60 s.
10. Validation
Critère : processed_new == 0 et delta base == 0, sur deux passes consécutives, sur des
dossiers déjà ingérés — dont 01 2025|Suivi|weekly report, celui-là même qui avait produit les
4 doublons.
Un dossier en erreur est explicitement compté NON CONCLUANT, jamais OK : processed_new y vaut
0 parce que rien n'a été lu, pas parce que la dédup a fonctionné. (Piège rencontré : la première
version du script comptait une panne du navigateur comme une validation.)
Coût du re-scan de weekly report (83 messages) : ~176 s, contre 64 min à l'ingestion
initiale — l'économie de la dédup est préservée.
11. Fichiers
hermes_mail_client.py:PREFIXE_CORPS_CLE,RE_ELLIPSE,lignes_utiles,calculer_dedup_key,signature_scandans_parse_item,MESSAGE_TIMEOUT_Spipeline_processor.py: comptage N/M avant la boucle, repli des lignes en double, INSERT étendusetup_db.py,schema.sql: colonnes + indexmigrate_dedup_key.py,valider_dedup.py(nouveaux)hermes-mail-browser/app/main.py:goto_owa_root
Sauvegardes : backups/nyora-notes-tt.db.pre-dedupkey-20260811-141616,
*.py.bak-20260811-*-pre-dedup-key, main.py.bak-20260811-*-pre-fix-goto-timeout.