Garde-fou : /search renvoie parfois le contenu de la Boite de reception pour un folder_path donne, en HTTP 200 sans erreur (bug D, cause non corrigee cote serveur). Verifie avant d'implementer qu'AUCUN signal structure n'existe dans la reponse (top-level query/count/items ; par item raw_text/aria_label/elem_id/attrs) -- en exposer un imposerait de modifier hermes-mail-browser, donc session dediee. Signal retenu faute de mieux : la derniere ligne de raw_text porte le libelle du dossier. Fiabilite mesuree sur 5 dossiers de types differents avant implementation : uniforme a 100% dans les 5 cas (81/81, 37/37, 6/6, 84/84, 77/77), correspondant dans les 4 cas sains, divergent dans le seul cas verole -- fiable ET discriminant, ce qui justifie un blocage dur plutot qu'un simple avertissement. Conception conservatrice : ne bloque que sur preuve positive de mauvaise portee ; journalise et laisse passer quand il ne peut pas conclure (aucun item, libelles heterogenes, noyau comparable vide). Un faux positif bloquerait une ingestion legitime, ce qui serait pire que le defaut couvert. HermesFolderScopeError herite de HermesMailClientError -> comportement correct chez les deux appelants de production sans les modifier (checkpoint 'error' cote pipeline, None cote download_attachments). Verifie en conditions reelles depuis le container : dossier sain 81 items ingeres, dossier verole bloque. Non-regression du mock de test verifiee. Timeout : effet de bord du fix B (plafond 20 -> 200). La duree de /search croit avec le volume demande (scroll progressif) -- 40 a 240 s mesures, contre timeout=60 code en dur. Les gros dossiers echouaient en timeout. SEARCH_TIMEOUT_S = 300. + 1 regle PROTOCOL-INFRA (plafond de pagination et timeout sont couples).
309 lines
17 KiB
Markdown
309 lines
17 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** — garde-fou de portée déployé en mitigation (erreur visible au lieu de corruption silencieuse)
|
||
**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. Cause NON RÉSOLUE (session dédiée) — mitigé côté client par un garde-fou de portée, cf plus bas.**
|
||
|
||
### 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.
|
||
|
||
### Mitigation déployée : garde-fou de portée côté client (10/08/2026)
|
||
|
||
Le correctif de fond reste à faire côté `hermes-mail-browser`. En attendant, un contrôle a été
|
||
câblé dans `hermes_mail_client.search_folder` pour transformer la **corruption silencieuse en
|
||
erreur visible**.
|
||
|
||
**Aucun signal structuré n'existe** — vérifié avant d'implémenter, conformément à la règle
|
||
« préférer un attribut structuré au texte rendu ». La réponse `/search` expose au top-level
|
||
`query`, `count`, `items` ; par item `raw_text`, `aria_label`, `elem_id`, `attrs`. **Aucun** ne
|
||
porte le dossier réellement atteint. En exposer un imposerait de modifier `hermes-mail-browser`,
|
||
donc relève de la session dédiée au bug D. Faute de mieux, le signal retenu est la **dernière
|
||
ligne de `raw_text`**, qui porte le libellé du dossier du message.
|
||
|
||
**Fiabilité mesurée avant implémentation** (même méthode que le recensement PUA), 5 dossiers de
|
||
types différents :
|
||
|
||
| Type | Items | Libellé uniforme | Correspond au dossier demandé |
|
||
|---|---|---|---|
|
||
| normal Réception (`00 2026\|⏱️ suivi`) | 81 | 81/81 | ✅ `⏱️ Suivi` |
|
||
| veille/notif (`00 2026\|🤖 veille`) | 37 | 37/37 | ✅ `🤖 Veille` |
|
||
| profond niveau 5 (`…\|🤖 projet auto`) | 6 | 6/6 | ✅ `🤖 Projet Auto` |
|
||
| ancien 2024 (`02 2024\|DR Zone Sud\|Tozeur`) | 84 | 84/84 | ✅ `Tozeur` |
|
||
| **vérolé** (`00 2026\|Relance marchés RLA`) | 77 | 77/77 | ❌ `Boîte de réception` |
|
||
|
||
Signal **uniforme à 100 % dans les 5 cas**, correspondant dans les 4 cas sains, divergent dans le
|
||
seul cas vérolé : fiable **et** discriminant. Le blocage dur est donc justifié par la mesure.
|
||
|
||
**Conception — volontairement conservatrice.** Le contrôle ne bloque que sur une *preuve positive*
|
||
de mauvaise portée. Quand il ne peut pas conclure (aucun item, libellé absent, libellés
|
||
hétérogènes sous le seuil de 90 %, noyau comparable vide), il **journalise et laisse passer** : un
|
||
faux positif bloquerait une ingestion légitime, ce qui serait pire que le défaut couvert. La
|
||
comparaison se fait sur un « noyau » alphanumérique en minuscules (glyphes, emoji et ponctuation
|
||
retirés), car la même entité s'écrit de trois façons : chemin stocké (`00 2026|Suivi`), chemin réel
|
||
(`⏱️ suivi`), libellé rendu (`⏱️ Suivi`).
|
||
|
||
`HermesFolderScopeError` hérite de `HermesMailClientError`, ce qui lui donne le bon comportement
|
||
chez les **deux appelants de production sans les modifier** : `pipeline_processor.process_folder`
|
||
la reçoit dans son `except Exception` → checkpoint en statut `error` (visible, re-tentable) au lieu
|
||
d'ingérer ; `download_attachments_phase1._fetch_folder_or_none` renvoie `None` (« on ne sait pas, à
|
||
retenter »), sans rien marquer.
|
||
|
||
**Vérifié en conditions réelles**, depuis le container après rebuild :
|
||
```
|
||
SAIN '00 2026|⏱️ suivi' -> 81 items INGERES
|
||
VEROLE '00 2026|Relance marchés RLA' -> BLOQUE
|
||
Portee /search non respectee : dossier demande '00 2026|Relance marchés RLA',
|
||
mais les messages recus appartiennent a 'Boîte de réception' (bug D).
|
||
Ingestion refusee pour eviter un classement errone.
|
||
```
|
||
Non-régression vérifiée sur les `raw_text` du mock de test (`test_api_mock_server.py`), qui ne
|
||
portent pas de libellé de dossier : le contrôle les laisse passer.
|
||
|
||
### Effet de bord du fix B : le timeout de 60 s devenait trop court
|
||
|
||
Constaté en testant le garde-fou. `/search` accumule ses résultats par **scroll clavier progressif**
|
||
dans une liste virtualisée : la durée croît avec le nombre d'items demandé. Passer le plafond de 20
|
||
à 200 (fix B) a fait passer les appels de ~30 s à **40–240 s** selon le dossier, alors que
|
||
`search_folder` gardait `timeout=60` codé en dur — les gros dossiers échouaient donc en timeout,
|
||
et le pipeline les aurait tous marqués `error`.
|
||
|
||
`SEARCH_TIMEOUT_S = 300` désormais. **Leçon** : relever un plafond de pagination change le profil
|
||
temporel des appels — vérifier les timeouts en même temps, ils sont dimensionnés pour l'ancien
|
||
volume.
|
||
|
||
---
|
||
|
||
## 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;
|
||
```
|