# 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`.