A - glyphe de zone privee Unicode seul en ligne 0 de raw_text decalant tout le
decoupage positionnel : sender, subject ET date faux ensemble. 295/399 notes
avaient un received_date qui n'est pas une date. 11 points de code PUA distincts
recenses sur 40 dossiers, dont 7 hors de la plage Fluent UI habituelle -> retenir
U+E000-U+F8FF. Correctif : nettoyage en amont du decoupage, date par motif,
normalisation ISO 8601 au jour, message_id sur champs normalises + conversation_id
comme discriminant. Corrige aussi checkpoint.last_processed_date (meme source).
B - search_folder() ne transmettait pas limit a /search -> plafond serveur silencieux
a 20 items, dossier marque done comme s'il etait complet.
C - dossiers hors Reception inatteignables : 3 chemins de navigation, 3 defauts deja
resolus ailleurs dans le meme fichier. Diagnostique, non corrige.
D - NON RESOLU, BLOQUANT BACKFILL : /search renvoie le contenu de la Boite de reception
pour certains folder_path, en HTTP 200. Invalide la methode d'audit de completude
et remet en question le classement des 399 notes existantes.
+ 3 regles generales dans PROTOCOL-INFRA.md (glyphe/emoji cassant tout matching de
texte naif sur OWA ; valeur par defaut serveur non transmise = troncature silencieuse ;
verifier la portee d'un resultat scope, pas seulement son code HTTP).
250 lines
13 KiB
Markdown
250 lines
13 KiB
Markdown
# nyora-notes-tt : 4 bugs de la chaîne d'extraction OWA — glyphe de zone privée, plafond /search silencieux, dossiers hors Réception inatteignables, portée /search non respectée
|
||
|
||
**Date** : 10/08/2026
|
||
**Instance** : hermes-tt
|
||
**Statut** : A et B CORRIGÉS + déployés · C diagnostiqué (correctif non appliqué) · **D NON RÉSOLU — bloquant backfill**
|
||
**Fichiers** : `/volume1/docker/nyora-notes-tt/hermes_mail_client.py` (`_parse_item`, `search_folder`) · `/volume1/docker/hermes-mail-browser/app/main.py` (bug C, non modifié)
|
||
|
||
Quatre défauts distincts de la même chaîne d'extraction, découverts ensemble. Ils partagent une
|
||
racine commune côté OWA : **du texte rendu pour l'œil humain, utilisé comme structure de données**.
|
||
|
||
---
|
||
|
||
## Bug A — un glyphe de zone privée Unicode décale tout le découpage positionnel
|
||
|
||
### Symptôme
|
||
`received_date` contenait des objets de mail, des noms d'expéditeur, ou un caractère invisible.
|
||
Mesure sur la base au moment du diagnostic :
|
||
|
||
| Mesure | Résultat |
|
||
|---|---|
|
||
| `received_date` ne correspondant à **aucun** format de date | **295 / 399 (74 %)** |
|
||
| dont contenant un caractère de zone privée | 90 |
|
||
| Groupes (subject, sender, folder) portant des `message_id` multiples | 52 |
|
||
| `checkpoint.last_processed_date` corrompu | oui (même source) |
|
||
|
||
### Cause
|
||
`_parse_item` découpait `raw_text` par position : `lines[0]`=expéditeur, `lines[1]`=objet,
|
||
`lines[2]`=date. Or OWA insère **un glyphe d'icône seul sur sa propre ligne, en tête de
|
||
`raw_text`, sur une partie seulement des messages** (état lu/non-lu, importance, pièce jointe).
|
||
|
||
Ce caractère n'est **pas un caractère d'espacement** : `.strip()` ne le retire pas, et la ligne
|
||
survit au filtre `if l.strip()`. Tout est décalé d'un cran — expéditeur, objet **et** date
|
||
deviennent faux **ensemble**.
|
||
|
||
Preuve, même dossier, même expéditeur, deux messages consécutifs :
|
||
|
||
```
|
||
idx 6 : 'Dr. Nexum Veille\n🔬 Veille IA…\nSam 25/07\n…' → lines[2] = date OK
|
||
idx 7 : '\nDr. Nexum Veille\n🔬 Veille IA…\nVen 24/07\n…' → lines[2] = objet FAUX
|
||
```
|
||
|
||
⚠️ **La plage Unicode « Fluent UI » habituelle (U+E700–U+ECFF) est INSUFFISANTE.**
|
||
Recensement sur 40 dossiers réels — **11 points de code distincts**, dont 7 hors de cette plage :
|
||
|
||
| Point de code | Occurrences | Dans U+E700–U+ECFF ? |
|
||
|---|---|---|
|
||
| `U+E73E` | 1095 | oui |
|
||
| `U+E1B7` | 612 | **non** |
|
||
| `U+E135` | 61 | **non** |
|
||
| `U+E0FF` | 31 | **non** |
|
||
| `U+EA4C` | 24 | oui |
|
||
| `U+E7B9` | 9 | oui |
|
||
| `U+E350` | 9 | **non** |
|
||
| `U+E89E` | 3 | oui |
|
||
| `U+E340` / `U+E0B1` / `U+E45A` | 2 / 2 / 1 | **non** |
|
||
|
||
Coder en dur un ou deux points de code ne tient pas. Retenir la **PUA complète : U+E000–U+F8FF**.
|
||
|
||
### Cause aggravante : OWA réécrit la date du même message au fil du temps
|
||
|
||
Trois formats, choisis selon l'ancienneté du message :
|
||
|
||
| Format | Exemple | Quand |
|
||
|---|---|---|
|
||
| Heure seule | `09:01` | message du jour |
|
||
| Jour + jj/mm | `Sam 25/07` | récent (année implicite) |
|
||
| Date complète | `09/07/2026` | ancien |
|
||
|
||
Comme `message_id = sha256(subject|date|sender)`, **le même message re-scrapé à deux moments
|
||
produisait deux identifiants** → doublon garanti dès qu'une synchronisation récurrente repasse
|
||
sur un dossier. Le glyphe faisait la même chose, en pire : sa simple apparition changeait l'id.
|
||
|
||
### Correctif
|
||
Ordre imposé, les trois étapes dépendent l'une de l'autre :
|
||
|
||
1. **Nettoyage en amont du découpage** — écarter les lignes entièrement composées de caractères
|
||
PUA, puis retirer les glyphes résiduels *à l'intérieur* des lignes conservées. Corriger
|
||
`sender`/`subject`/`date` séparément en aval ne marche pas : c'est le même décalage d'indice
|
||
qui les fausse tous les trois.
|
||
2. **Date trouvée par reconnaissance de motif, pas par position** — balayage des lignes à partir
|
||
de l'indice 1, exclusion de la dernière ligne (libellé de dossier). Sentinelle explicite
|
||
`date_inconnue` si rien ne matche : ne **jamais** retomber sur une ligne arbitraire, c'est
|
||
exactement ce qui a rempli 74 % de la base.
|
||
3. **Normalisation ISO 8601 à la granularité du JOUR** (`normaliser_date_owa`). Volontairement
|
||
pas l'heure : c'est ce qui rend les trois formats convergents. Année absente → occurrence
|
||
**passée** la plus récente, jamais dans le futur (un message du 28/12 lu le 3 janvier
|
||
appartient à l'année précédente).
|
||
4. **`message_id` = hash des champs normalisés**, `conversation_id` inclus comme discriminant.
|
||
|
||
### Piège découvert en corrigeant : la granularité au jour fait perdre un discriminant
|
||
Ramener la date au jour fait collisionner deux messages distincts de même objet, même expéditeur
|
||
et même jour (cas réel : newsletter émise plusieurs fois par jour sous un objet identique). Le
|
||
second aurait été **silencieusement sauté** comme déjà traité.
|
||
|
||
Parade : inclure `conversation_id` (`data-convid`, ConversationId Exchange natif et stable, cf
|
||
`nyora-notes-tt-message-id-elem-id-volatile-08-08-2026.md`) dans le hash. Mesure sur les 37
|
||
lignes de `00 2026|Veille` :
|
||
|
||
| Composition du hash | Identifiants distincts |
|
||
|---|---|
|
||
| subject + date_iso + sender | 25 / 37 |
|
||
| **+ conversation_id** | **34 / 37** ← 34 messages réellement distincts |
|
||
|
||
Les 3 lignes restantes sont le **même message capturé deux fois** par la liste virtualisée :
|
||
une paire aux `raw_text` identiques, deux paires ne différant que par le glyphe en tête. Les
|
||
fusionner est le comportement correct — et c'est la démonstration directe du fix.
|
||
|
||
### Vérification (avant / après, sur données réelles)
|
||
|
||
```
|
||
idx3 (sans PUA) AVANT sender='Dr. Nexum Veille' date='Ven 24/07' id=msg_ad9e71e655030f3a
|
||
idx7 (avec PUA) AVANT sender='' date='🔬 Veille IA Dr. Nexum…' id=msg_5fefeb296ba7ba15
|
||
→ DEUX ids pour UN message
|
||
|
||
idx3 APRÈS sender='Dr. Nexum Veille' date='2026-07-24' id=msg_5711010f8be85848
|
||
idx7 APRÈS sender='Dr. Nexum Veille' date='2026-07-24' id=msg_5711010f8be85848
|
||
→ id stable, doublon évité
|
||
```
|
||
|
||
Sur les 37 lignes : **37/37 dates ISO reconnues, 0 caractère PUA résiduel**. Les dates se
|
||
recoupent avec le contenu (`Saturday 25 july 2026` dans l'objet ↔ `received_date=2026-07-25`).
|
||
|
||
`checkpoint.last_processed_date` est corrigé **par la même modification** : `pipeline_processor.py`
|
||
consomme `item["received_date"]` aux trois emplacements d'écriture (l.376-377, 399-401, 417-418)
|
||
et ne recompose jamais de date ailleurs — vérifié.
|
||
|
||
---
|
||
|
||
## Bug B — `/search` plafonne silencieusement à 20 items
|
||
|
||
`search_folder()` n'a **jamais transmis** `limit` à l'appel HTTP : le paramètre n'était appliqué
|
||
qu'en tranche cliente (`[:limit]`) sur une réponse **déjà tronquée par le serveur**.
|
||
`hermes-mail-browser` plafonne `/search` à `limit=20` par défaut — **sans erreur ni
|
||
avertissement**. Le dossier passe ensuite en statut `done` comme s'il avait été traité en entier.
|
||
|
||
Cas témoin `00 2026|Veille` : 14 notes + 6 doublons vectoriels = **exactement 20** en base, pour
|
||
**37 messages réels** (vérifié avec `limit=60`).
|
||
|
||
```python
|
||
# AVANT — limit perdu en route
|
||
params = {"q": query, "folder_path": folder_path}
|
||
...
|
||
return processed_items[:limit] # tranche sur une réponse déjà tronquée
|
||
|
||
# APRÈS
|
||
params = {"q": query, "folder_path": folder_path, "limit": limit} # DEFAULT_SEARCH_LIMIT = 200
|
||
```
|
||
|
||
**Leçon** : une troncature silencieuse est indétectable en aval. Tout plafond côté serveur doit
|
||
être transmis explicitement, jamais laissé à sa valeur par défaut.
|
||
|
||
---
|
||
|
||
## Bug C — « Éléments envoyés » (et tout dossier hors Réception) inatteignable
|
||
|
||
Diagnostiqué, **non corrigé** (hors périmètre de la session). Ni limitation OWA ni problème de
|
||
permission : **trois** chemins de navigation, chacun avec un défaut déjà résolu ailleurs dans le
|
||
même fichier `main.py`.
|
||
|
||
| Paramètre | Mécanisme | Défaut | Constaté |
|
||
|---|---|---|---|
|
||
| `folder_path=` | `go_to_folder_path()` | **Ancre en dur sur Boîte de réception** (`cur = inbox_candidates[0]`, l.325-332) — ne peut jamais atteindre un frère de niveau 2 | 404 « introuvable sous 'boîte de réception' » |
|
||
| `folder=` | `resolve_folder_treeitem()` (l.207) | Utilise `inner_text()`, que le docstring de `snapshot_folders` (l.222-232) déclare lui-même non fiable — « peut laisser inner_text() vide ou réduit à un glyphe d'icône » | 404 sur `envoy`, `éléments envoyés` |
|
||
| `folder_index=` | `go_to_folder()` (l.416-423) | `Locator.click()` natif, exige la visibilité réelle — échoue sur les items masqués par Fluent UI, exactement le problème résolu l.304-311 par `page.evaluate("el => el.click()")` | HTTP 500, 11 retries « element is not visible » |
|
||
|
||
**Correctif proposé** (une ligne) : dans `go_to_folder_path`, résoudre le premier segment parmi
|
||
les enfants de niveau 2 de la racine du compte, avec repli sur la Boîte de réception pour la
|
||
rétrocompatibilité. Toute la machinerie fiable existe déjà (`snapshot_folders` + clic JS). Coût
|
||
réel : rebuild et retest Playwright d'un **second** container.
|
||
|
||
---
|
||
|
||
## Bug D — `/search` renvoie le contenu de la Boîte de réception pour certains `folder_path`, en HTTP 200
|
||
|
||
**Découvert en auditant le bug B. NON RÉSOLU — bloquant pour tout backfill.**
|
||
|
||
### Symptôme
|
||
Deux `folder_path` différents renvoient ~77 items dont 63 en commun, et **chaque ligne porte le
|
||
libellé de dossier « Boîte de réception »** au lieu du dossier ciblé. Aucune erreur : HTTP 200.
|
||
|
||
### Test discriminant (chemin-spécifique, pas dérive d'état)
|
||
Trois appels consécutifs, navigateur libre de toute autre charge :
|
||
|
||
| Appel | `folder_path` | Items | Libellé porté par les lignes |
|
||
|---|---|---|---|
|
||
| A | `00 2026\|⏱️ suivi` | 81 | `⏱️ Suivi` — **correctement scopé** |
|
||
| B | `00 2026\|Relance marchés RLA` | 76 | `Boîte de réception` — **NON scopé** |
|
||
| C | `00 2026\|⏱️ suivi` (re-test) | 81 | `⏱️ Suivi` — reproductible |
|
||
|
||
C reproduit A à l'identique après B : le défaut est **déterministe et lié au chemin**, ce n'est
|
||
pas une dérive d'état de l'UI ni un effet de contention.
|
||
|
||
### Hypothèse écartée
|
||
« Chemin stocké sans emoji vs libellé réel avec emoji » : `01 2025|Consommables 2025` a pour
|
||
libellé réel `consommables 2025`, **sans emoji**, et échoue quand même. Par ailleurs
|
||
`go_to_folder_path` matche en sous-chaîne insensible à la casse — un emoji dans le libellé ne
|
||
peut pas faire échouer le match à lui seul.
|
||
|
||
### Piste la plus probable (à confirmer)
|
||
Le segment est bien *trouvé* dans l'arbre, mais le **clic ne change pas réellement de dossier** ;
|
||
la page reste sur la Boîte de réception et `perform_search` s'exécute donc dans sa portée. Ça
|
||
expliquerait le HTTP 200 sans erreur : `go_to_folder_path` ne lève une 404 que si un segment est
|
||
introuvable, jamais si la navigation échoue **silencieusement** après un clic accepté.
|
||
|
||
### Conséquences (importantes)
|
||
1. **La méthode de l'audit de complétude est invalidée** pour les chemins touchés : le « compte
|
||
réel » mesuré est celui de la Boîte de réception, pas celui du dossier. Tout chiffre de
|
||
« messages manquants » calculé ainsi est un artefact.
|
||
2. **Intégrité des 399 notes existantes à réexaminer** : le pipeline ingère via ce même appel
|
||
(`search_folder` → `/search?q=*`). Des messages de la Boîte de réception ont pu être classés
|
||
sous des `folder_path` arbitraires. La dédup `message_id` étant **globale** (et non par
|
||
dossier), le premier dossier scanné absorbe les messages et les suivants ressortent quasi
|
||
vides — ce qui correspond au profil observé (beaucoup de dossiers à 0-3 notes).
|
||
3. **Bloquant pour le backfill** : ingérer à grande échelle avec une portée fausse polluerait la
|
||
base massivement.
|
||
|
||
### Contrôle de portée à systématiser
|
||
La dernière ligne de `raw_text` porte le libellé du dossier du message. C'est un **contrôle de
|
||
portée gratuit** : après un `/search` scopé, si ce libellé ne correspond pas au dossier demandé,
|
||
la navigation a échoué. À câbler dans `search_folder` avant toute ingestion.
|
||
|
||
---
|
||
|
||
## La leçon commune aux quatre bugs
|
||
|
||
Le glyphe qui casse `inner_text()` sur le volet dossiers (bug C, déjà documenté le 30-31/07/2026
|
||
dans `mail-o365-folder-path-disambiguation.md`) et celui qui décale `raw_text` sur les lignes de
|
||
messages (bug A) sont **le même phénomène**, jamais généralisé jusqu'ici.
|
||
|
||
> Sur OWA, un glyphe d'icône ou un emoji casse tout matching de texte naïf — qu'il soit
|
||
> positionnel ou par `inner_text()`. Lire des **attributs structurés**
|
||
> (`data-folder-name`, `data-convid`, `aria-*`) plutôt que du texte rendu, et nettoyer la plage
|
||
> PUA U+E000–U+F8FF avant tout découpage.
|
||
|
||
Ajouté à `PROTOCOL-INFRA.md` comme leçon générale (trois surfaces touchées : parsing de ligne de
|
||
message, clic sur le volet dossiers, résolution de chemin).
|
||
|
||
## Procédure de déploiement (rappel)
|
||
Le code est **cuit dans l'image** (`build: .`), aucun hot-reload :
|
||
```bash
|
||
cd /volume1/docker/nyora-notes-tt
|
||
/usr/local/bin/docker compose build --no-cache
|
||
/usr/local/bin/docker compose up -d --force-recreate # jamais `up -d` seul
|
||
```
|
||
Contrôle post-déploiement (les deux champs de date) :
|
||
```sql
|
||
SELECT DISTINCT received_date FROM notes; -- que de l'ISO ou la sentinelle
|
||
SELECT DISTINCT last_processed_date FROM checkpoint;
|
||
```
|