Files
nas-runbooks/hermes-tt/nyora-notes-tt-parsing-owa-pua-10-08-2026.md
T
Nabil.Derouiche c5f25684f7 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).
2026-08-10 17:24:17 +01:00

13 KiB
Raw Blame History

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).

# 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 ⏱️ Suivicorrectement scopé
B 00 2026|Relance marchés RLA 76 Boîte de réceptionNON 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 :

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) :

SELECT DISTINCT received_date FROM notes;              -- que de l'ISO ou la sentinelle
SELECT DISTINCT last_processed_date FROM checkpoint;