From 0c109afccda186415fc9154b40458e25cf9accee Mon Sep 17 00:00:00 2001 From: bolbol Date: Tue, 11 Aug 2026 16:54:04 +0000 Subject: [PATCH] runbook: nyora-notes-tt -- message_id invalide, dedup par cle stable + comptage (11/08/2026) --- ...tt-message-id-dedup-comptage-11-08-2026.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 hermes-tt/nyora-notes-tt-message-id-dedup-comptage-11-08-2026.md diff --git a/hermes-tt/nyora-notes-tt-message-id-dedup-comptage-11-08-2026.md b/hermes-tt/nyora-notes-tt-message-id-dedup-comptage-11-08-2026.md new file mode 100644 index 0000000..cdf8a7e --- /dev/null +++ b/hermes-tt/nyora-notes-tt-message-id-dedup-comptage-11-08-2026.md @@ -0,0 +1,211 @@ +# 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` : + +```python +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= data-convid= 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 + +```python +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 `subject` dépend de + `idx_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 + +```sql +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_scan` dans `_parse_item`, `MESSAGE_TIMEOUT_S` +- `pipeline_processor.py` : comptage N/M avant la boucle, repli des lignes en double, INSERT étendu +- `setup_db.py`, `schema.sql` : colonnes + index +- `migrate_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`.