runbook: nyora-notes-tt -- message_id invalide, dedup par cle stable + comptage (11/08/2026)

This commit is contained in:
2026-08-11 16:54:04 +00:00
parent 32812c1172
commit 0c109afccd
@@ -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=<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
```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`.