hermes-tt: 4 bugs extraction OWA (A+B corriges, C+D diagnostiques)

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).
This commit is contained in:
2026-08-10 17:24:17 +01:00
parent ac2a1b641e
commit c5f25684f7
3 changed files with 308 additions and 0 deletions
+1
View File
@@ -100,6 +100,7 @@
| [16 dossiers RAG error — CORRIGE : clic d'expansion non idempotent go_to_folder_path (pas un etat de session degrade), + 2 bugs annexes /folders inner_text() et absence de verrou page partagee, verifie 22/22](hermes-tt/mail-o365-toggle-expand-non-idempotent-fix.md) | 2026-08-02 | mail, o365, owa, playwright, hermes-mail-browser, nyora-notes-tt, rag, checkpoint, error, race-condition, fix |
| [onedrive-sync -- doublons non recatalogues (is_duplicate_of, pas un bug) + extensions inconvertibles ajoutees a UNCONVERTIBLE_EXT, verifie bad_ext_still_present=0](hermes-tt/nyora-notes-tt-onedrive-sync-doublons-extensions.md) | 2026-08-06 | nyora-notes-tt, onedrive-sync, catalog, doublons, extensions |
| [SharePoint delta-sync (hermes-mail-browser -> archive locale) : mise en place complète](hermes-tt/sharepoint-delta-sync.md) | 2026-08-06 | sharepoint, sync, hermes-mail-browser, n8n, onedrive |
| [4 bugs extraction OWA — A+B CORRIGES (glyphe zone privée U+E000-U+F8FF décalant le découpage positionnel, 295/399 dates corrompues ; plafond /search silencieux à 20) · C dossiers hors Réception inatteignables · **D portée /search non respectée = BLOQUANT backfill**](hermes-tt/nyora-notes-tt-parsing-owa-pua-10-08-2026.md) | 2026-08-10 | mail, o365, owa, playwright, hermes-mail-browser, nyora-notes-tt, rag, parsing, unicode, pua, message-id, troncature, scope, fix |
## hermes-nyora
+58
View File
@@ -240,3 +240,61 @@ est vide de facon inattendue, retry UNIQUE apres `time.sleep(4)` avant d'abandon
backoff). Piege de parsing associe : `attrs` (string pipe-delimitee) reinjecte `aria-label` en fin de
chaine (texte libre non echappe, `|` et sauts de ligne possibles) -> NE JAMAIS `split('|')` global ;
extraire par regex ciblee (`data-convid=([^|]+)`, base64 sans `|`). Detail : meme runbook ci-dessus.
## REGLE -- OWA : un glyphe d'icone (zone privee Unicode) ou un emoji casse tout matching de texte naif (2026-08-10)
Le texte rendu par OWA est fait pour l'oeil, pas pour servir de structure de donnees. OWA insere des
glyphes d'icone issus de la ZONE PRIVEE Unicode (lu/non-lu, importance, piece jointe) directement dans
le texte scrape -- et les libelles de dossiers portent des emoji. Ces caracteres ne sont PAS des
espaces : `.strip()` ne les retire pas et ils survivent a un filtre `if l.strip()`.
Le meme phenomene a maintenant casse TROIS surfaces differentes :
1. Parsing d'une ligne de message (`_parse_item`, nyora-notes-tt) : un glyphe seul sur la ligne 0 de
`raw_text`, present sur une partie seulement des messages, decale le decoupage positionnel d'un
cran -- expediteur, objet ET date faux ENSEMBLE. Resultat mesure : 295/399 notes avec un
`received_date` qui n'est pas une date (74%), dont 90 contenant un glyphe.
2. Clic sur le volet dossiers (`resolve_folder_treeitem`, hermes-mail-browser) : `inner_text()` peut
renvoyer vide ou reduit au seul glyphe d'icone -> aucun dossier ne matche.
3. Resolution de chemin de dossier (deja documente le 30-31/07/2026).
PLAGE A RETENIR : la PUA COMPLETE U+E000-U+F8FF. La plage "Fluent UI" habituelle (U+E700-U+ECFF) est
INSUFFISANTE -- `U+E1B7` a ete observe en production hors de cette plage, a cote de `U+E73E`.
REFLEXES :
- Lire des ATTRIBUTS STRUCTURES (`data-folder-name`, `data-convid`, `aria-*`) plutot que du texte rendu.
- Nettoyer la plage PUA AVANT tout decoupage, jamais champ par champ en aval (c'est un decalage
d'indice unique qui fausse tous les champs d'un coup).
- Ne jamais lire une donnee par POSITION dans du texte rendu : chercher par MOTIF, et prevoir une
sentinelle explicite si rien ne matche -- ne jamais retomber sur une ligne arbitraire.
Detail : hermes-tt/nyora-notes-tt-parsing-owa-pua-10-08-2026.md.
## REGLE -- une valeur par defaut cote serveur non transmise = troncature silencieuse (2026-08-10)
`hermes_mail_client.search_folder()` n'a jamais transmis `limit` a `/search` : il l'appliquait en
tranche cliente (`[:limit]`) sur une reponse DEJA tronquee. `hermes-mail-browser` plafonne `/search`
a 20 items par defaut, SANS erreur ni avertissement -- le dossier passe ensuite en statut `done`
comme s'il avait ete traite entierement. Temoin `00 2026|Veille` : 14 notes + 6 doublons = exactement
20 en base pour 37 messages reels.
REFLEXE : tout plafond cote serveur doit etre TRANSMIS EXPLICITEMENT, jamais laisse a sa valeur par
defaut. Une troncature silencieuse est indetectable en aval -- corollaire : quand un compteur colle
pile a une valeur ronde (20, 50, 100), suspecter un plafond avant de conclure que la source est vide.
Detail : meme runbook ci-dessus.
## REGLE -- verifier la PORTEE d'un resultat scope, pas seulement son code HTTP (2026-08-10)
`GET /search?folder_path=X` de hermes-mail-browser renvoie parfois le contenu de la BOITE DE
RECEPTION au lieu du dossier demande -- en HTTP 200, sans aucune erreur. Deux chemins differents
renvoyaient 77 items dont 63 communs, toutes les lignes portant le libelle "Boite de reception".
Deterministe et lie au chemin (un dossier connu-bon reteste apres un connu-mauvais redonne le bon
resultat) : ce n'est ni une derive d'etat ni de la contention. Cause probable : le segment est
trouve dans l'arbre mais le clic ne change pas reellement de dossier, et la recherche s'execute
alors dans la portee heritee -- `go_to_folder_path` ne leve une 404 que si un segment est
INTROUVABLE, jamais si la navigation echoue silencieusement apres un clic accepte.
REFLEXE : un 200 ne prouve pas que la portee demandee a ete respectee. Quand une API navigue avant
de lire, verifier dans la REPONSE une marque de la portee obtenue. Ici c'est gratuit : la derniere
ligne de `raw_text` porte le libelle du dossier du message -- s'il ne correspond pas au dossier
demande, la navigation a echoue. Corollaire : un audit de completude bati sur une source non
verifiee produit des chiffres qui ont l'air precis et ne veulent rien dire.
Detail : hermes-tt/nyora-notes-tt-parsing-owa-pua-10-08-2026.md (bug D).
@@ -0,0 +1,249 @@
# 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+E700U+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+E700U+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+E000U+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+E000U+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;
```