runbook: nyora-notes-tt -- message_id invalide, dedup par cle stable + comptage (11/08/2026)
This commit is contained in:
@@ -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`.
|
||||||
Reference in New Issue
Block a user