hermes-tt: bug D resolu (deplier != selectionner) + bug C + bug E decouvert et bloque
Cause de fond du bug D : go_to_folder_path confondait l'expansion d'un dossier et sa selection. Seules les feuilles etaient reellement selectionnees ; tout dossier a enfants laissait le dossier courant sur la Boite de reception, d'ou un /search en HTTP 200 sur le mauvais perimetre. 63 dossiers sur 336 concernes. Correctif : segments intermediaires = expansion seule ; dernier segment = clic sur la ligne + confirmation par aria-selected, sinon 502 explicite. Bug C corrige dans le meme passage (premier segment resolu aussi parmi les freres de niveau 2, apres la Boite de reception pour retrocompatibilite). Pollution induite mesuree et purgee : 46 notes sur 399, sur 10 dossiers tous conteneurs. 399 -> 353 notes, 0 incoherence residuelle. Bug E decouvert par les tests : sur un dossier sans message direct, OWA elargit lui-meme la recherche a toute la boite. Politique du garde-fou inversee -- l'heterogeneite bloque desormais l'ingestion au lieu de la laisser passer.
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
# Bug D — « déplier » n'est pas « sélectionner » : `/search?folder_path=` renvoyait la Boîte de réception en HTTP 200
|
||||||
|
|
||||||
|
**Date** : 10/08/2026 (soir)
|
||||||
|
**Instance** : hermes-tt
|
||||||
|
**Statut** : **CAUSE TROUVÉE, CORRIGÉE ET VALIDÉE** · bug C corrigé dans le même passage · 46 notes
|
||||||
|
polluées purgées · **bug E découvert par les tests et bloqué** (recherche élargie par OWA)
|
||||||
|
**Fichiers** : `/volume1/docker/hermes-mail-browser/app/main.py` — `go_to_folder_path` (D, C) ·
|
||||||
|
`/volume1/docker/nyora-notes-tt/hermes_mail_client.py` — `_verifier_portee` (E)
|
||||||
|
**Sauvegardes** : `main.py.bak-20260810-212013-pre-fix-DC` ·
|
||||||
|
`hermes_mail_client.py.bak-20260810-*-pre-fix-E`
|
||||||
|
|
||||||
|
Suite directe de [`nyora-notes-tt-parsing-owa-pua-10-08-2026.md`](nyora-notes-tt-parsing-owa-pua-10-08-2026.md)
|
||||||
|
(bugs A/B corrigés, D mitigé par `HermesFolderScopeError`, C diagnostiqué). Ce runbook ne traite
|
||||||
|
que la **cause de fond** de D et le correctif de C. La désambiguïsation des chemins reste décrite
|
||||||
|
dans [`mail-o365-folder-path-disambiguation.md`](mail-o365-folder-path-disambiguation.md), et
|
||||||
|
l'anti-toggle du 02/08 dans
|
||||||
|
[`mail-o365-toggle-expand-non-idempotent-fix.md`](mail-o365-toggle-expand-non-idempotent-fix.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptôme
|
||||||
|
|
||||||
|
`/search?folder_path=X` renvoyait le contenu de la **Boîte de réception** pour certains chemins,
|
||||||
|
en **HTTP 200, sans erreur**. Déterministe, lié au chemin, indépendant des emojis :
|
||||||
|
|
||||||
|
| Chemin | Résultat |
|
||||||
|
|---|---|
|
||||||
|
| `00 2026\|⏱️ suivi` | 81 items correctement scopés ✅ |
|
||||||
|
| `00 2026\|Relance marchés RLA` | 76 items tous libellés « Boîte de réception » ❌ |
|
||||||
|
| re-test du premier | à nouveau correct |
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
**Sur cette UI, déplier un dossier et le sélectionner sont deux gestes distincts — et le code les
|
||||||
|
confondait.** La portée d'une recherche OWA est le **dossier courant** ; le dernier segment du
|
||||||
|
chemin doit donc être *sélectionné*, pas seulement *déplié*.
|
||||||
|
|
||||||
|
L'ancienne boucle de `go_to_folder_path` ne cliquait la **ligne** que par défaut, quand aucun
|
||||||
|
bouton d'expansion n'était trouvé :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if match["expanded"] != "true": # (1) si déjà déplié : aucun clic du tout
|
||||||
|
expand_btn = row.locator("button").first
|
||||||
|
if await expand_btn.count() > 0:
|
||||||
|
await expand_btn.evaluate("el => el.click()") # (2) chevron : déplie, ne sélectionne pas
|
||||||
|
else:
|
||||||
|
await row.evaluate("el => el.click()") # seul chemin qui sélectionne
|
||||||
|
```
|
||||||
|
|
||||||
|
D'où le comportement, **entièrement déterminé par la présence d'enfants** :
|
||||||
|
|
||||||
|
- **dossier feuille** → pas de chevron → repli sur le clic de la ligne → **sélection réelle → OK** ;
|
||||||
|
- **dossier avec enfants** → soit seul le chevron est cliqué (2), soit — s'il était déjà
|
||||||
|
`aria-expanded="true"`, l'état d'expansion persistant d'un appel à l'autre — **aucun clic** (1).
|
||||||
|
Le dossier n'est jamais sélectionné. `goto_owa_root()` ayant rechargé la racine, le dossier
|
||||||
|
courant reste la Boîte de réception, et la recherche s'y exécute.
|
||||||
|
|
||||||
|
Le branchement (1) est le fix anti-toggle du 02/08 : correct pour l'expansion, mais il a laissé la
|
||||||
|
**sélection sans aucun chemin de code**. Les deux cas discriminants s'expliquent exactement :
|
||||||
|
`⏱️ suivi` est une feuille, `🔁 relance marchés rla` un conteneur déplié (2 enfants).
|
||||||
|
|
||||||
|
Portée mesurée sur l'arbre réel : **63 dossiers sur 336** ont des enfants — tous inatteignables.
|
||||||
|
|
||||||
|
## Correctif
|
||||||
|
|
||||||
|
Séparer les deux responsabilités dans la boucle :
|
||||||
|
|
||||||
|
- **segments intermédiaires** → seule l'expansion compte (comportement du 02/08 inchangé, pour que
|
||||||
|
les enfants soient montés dans le DOM) ;
|
||||||
|
- **dernier segment** → clic **toujours sur la ligne**, jamais sur le chevron, puis **confirmation
|
||||||
|
de la sélection via `aria-selected`**, avec 3 tentatives et attente croissante. Sans
|
||||||
|
confirmation → `HTTPException(502)` nommant le dossier demandé *et* le dossier réellement
|
||||||
|
sélectionné.
|
||||||
|
|
||||||
|
`snapshot_folders()` lisait déjà `aria-selected` et l'exposait en `selected` : la machinerie de
|
||||||
|
vérification existait, elle n'était simplement pas utilisée.
|
||||||
|
|
||||||
|
## Bug C — dossiers hors Réception (même passage)
|
||||||
|
|
||||||
|
`cur = inbox_candidates[0]` ancrait **tout** chemin sous la Boîte de réception, rendant ses frères
|
||||||
|
de niveau 2 (`éléments envoyés`, `archive`, `brouillons`…) inatteignables. Le **premier segment**
|
||||||
|
est désormais cherché d'abord sous la Boîte de réception, puis — à défaut seulement — parmi les
|
||||||
|
dossiers de niveau 2 du compte. Cet ordre garantit que les chemins existants (`00 2026`,
|
||||||
|
`01 2025`, `02 2024`…) gardent exactement leur résolution : aucune régression possible.
|
||||||
|
|
||||||
|
## Pollution induite, et sa purge
|
||||||
|
|
||||||
|
La dédup `message_id` est **globale** (`SELECT id FROM notes WHERE message_id = ?`, sans filtre de
|
||||||
|
dossier). Les messages de la Réception ingérés sous un `folder_path` arbitraire y occupaient donc
|
||||||
|
l'identifiant du vrai message, ce qui **aurait empêché leur ré-ingestion correcte** au backfill.
|
||||||
|
La purge était un prérequis, pas un nettoyage cosmétique.
|
||||||
|
|
||||||
|
Détection : dernière ligne de `raw_excerpt` (= libellé du dossier réel, signal déjà utilisé par
|
||||||
|
`HermesFolderScopeError`) contredisant le `folder_path` assigné.
|
||||||
|
|
||||||
|
**46 notes sur 399 (11,5 %)**, réparties sur **10 dossiers, tous des conteneurs** — signature
|
||||||
|
exacte de la cause :
|
||||||
|
|
||||||
|
| Notes | Dossier |
|
||||||
|
|---|---|
|
||||||
|
| 18 | `00 2026\|Consultations` |
|
||||||
|
| 6 | `02 2024\|RLA` |
|
||||||
|
| 4 | `02 2024\|RLA\|E. Partielles` |
|
||||||
|
| 3 | `00 2026\|DR Zone Sud`, `01 2025\|Consommables 2025`, `02 2024\|Inventaire 2023`, `02 2024\|RLA\|AO 76/2024 E FO` |
|
||||||
|
| 2 | `00 2026\|Automated`, `02 2024\|DR Zone Sud`, `02 2024\|RLA\|AO 66/2024 E Curatif` |
|
||||||
|
|
||||||
|
Contenus manifestement étrangers à leur dossier : notifications de release logicielle sous
|
||||||
|
`Inventaire 2023`, veille infra sous `RLA`, newsletter mode sous `Consultations`.
|
||||||
|
|
||||||
|
Procédure (script `backups/purge_bugD.py`, dry-run par défaut, `--apply` pour exécuter) :
|
||||||
|
sauvegarde `VACUUM INTO` → re-mesure de la liste (jamais figée) → suppression `notes_vec`
|
||||||
|
(lié par `rowid`), `note_links`, `notes` → **rebuild de `notes_fts`** → checkpoints des 10 dossiers
|
||||||
|
remis à `pending` avec `last_processed_*` à NULL.
|
||||||
|
|
||||||
|
Résultat : 399 → **353 notes, 353 vecteurs, 0 incohérence résiduelle**.
|
||||||
|
|
||||||
|
### Deux pièges rencontrés pendant la purge
|
||||||
|
|
||||||
|
- `no such module: vec0` — `notes_vec` est une table virtuelle sqlite-vec : passer par
|
||||||
|
`setup_db.get_db_connection()`, pas par `sqlite3.connect()` brut.
|
||||||
|
- `database is locked` — le container garde une connexion ouverte. Faire `docker stop nyora-notes-tt`,
|
||||||
|
exécuter dans un container éphémère (`docker run --rm` sur la même image, mêmes montages), puis
|
||||||
|
`docker start`. Le code étant cuit dans l'image, le script se dépose dans `backups/` (monté rw).
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Batterie de 9 appels après rebuild (`--no-cache` + `--force-recreate`), garde-fou actif :
|
||||||
|
|
||||||
|
| Cas | Avant | Après |
|
||||||
|
|---|---|---|
|
||||||
|
| `00 2026\|suivi` (feuille, référence) | 81 items scopés | **81/81** — aucune régression |
|
||||||
|
| `00 2026\|Relance marchés RLA` (conteneur déplié) | 76 items « Boîte de réception » | **19/19** correctement scopés |
|
||||||
|
| `02 2024\|RLA` (conteneur profond) | — | **27/27** |
|
||||||
|
| `…\|AO 66/2024 E Curatif\|Notification` (homonyme) | — | **8/8** |
|
||||||
|
| `Éléments envoyés` (bug C, frère L2) | inatteignable | **40/40** |
|
||||||
|
| 3 appels consécutifs sur le même conteneur | alternance succès/échec (02/08) | stable, aucune alternance |
|
||||||
|
|
||||||
|
**Piège de méthode** : une première série de tests a tourné en double (un job `nohup` chaîné
|
||||||
|
derrière le build avait survécu à sa session SSH). Les deux séries tapant le même navigateur et
|
||||||
|
`@serialize_owa` sérialisant les appels côté serveur, leurs résultats s'entrelaçaient — un test
|
||||||
|
renvoyait la portée du test précédent. Toujours lancer ces batteries sous `flock`, et se méfier
|
||||||
|
d'un résultat qui « pointe vers le dossier d'à côté ».
|
||||||
|
|
||||||
|
## Bug E — OWA élargit la recherche sur un dossier sans message direct
|
||||||
|
|
||||||
|
Découvert par ces mêmes tests. `00 2026|Consultations` renvoyait 40 items dont le libellé
|
||||||
|
majoritaire était `Archive` à **10/40** — un mélange, **stable sur 3 appels**. Même profil sur
|
||||||
|
`01 2025|Cons° animation commerciale`.
|
||||||
|
|
||||||
|
Ce n'est **pas** un reliquat du bug D, et les deux effets ont été isolés : `/inbox` sur ces mêmes
|
||||||
|
dossiers répond « liste des messages introuvable » — c'est-à-dire **dossier vide** — au lieu du
|
||||||
|
contenu de la Boîte de réception. La navigation est donc correcte ; c'est la **recherche** qui
|
||||||
|
s'élargit. Quand un dossier ne contient aucun message *direct* (ses messages sont dans ses
|
||||||
|
sous-dossiers), OWA étend de lui-même la requête à toute la boîte aux lettres, en HTTP 200.
|
||||||
|
|
||||||
|
Correctif côté client (`_verifier_portee`) : **politique inversée**. L'hétérogénéité était traitée
|
||||||
|
comme une indétermination et laissait passer l'ingestion ; elle est désormais un motif de blocage
|
||||||
|
(`HermesSearchScopeWidenedError`). Justification empirique : une recherche réellement scopée est
|
||||||
|
uniforme **même sur un dossier à enfants** (`02 2024|RLA` → 27/27, `Relance marchés RLA` → 19/19).
|
||||||
|
L'uniformité est la norme d'une portée correcte ; son absence est une preuve suffisante.
|
||||||
|
|
||||||
|
Sans ce durcissement, le backfill aurait classé des messages de toute la boîte sous ces
|
||||||
|
`folder_path` — le mode de corruption exact que la purge ci-dessus venait de réparer.
|
||||||
|
|
||||||
|
## Leçon généralisable
|
||||||
|
|
||||||
|
> Sur une UI React/Fluent, **le succès d'un clic ne prouve pas son effet**. Un chevron d'expansion
|
||||||
|
> et la ligne qui le porte sont deux cibles distinctes aux effets distincts. Après toute
|
||||||
|
> navigation dont dépend la suite, confirmer l'état atteint par un **attribut structuré**
|
||||||
|
> (`aria-selected`) et échouer explicitement sinon — un 200 qui répond sur le mauvais périmètre
|
||||||
|
> coûte infiniment plus cher qu'une erreur franche.
|
||||||
|
|
||||||
|
Corollaire tiré du bug E : quand un garde-fou doit trancher sur des données bruitées, **le cas
|
||||||
|
« je ne peux pas conclure » mérite d'être mesuré avant d'être traité comme inoffensif**. Ici il
|
||||||
|
recouvrait un second mode de corruption, et la politique permissive choisie par prudence était
|
||||||
|
précisément ce qui l'aurait laissé passer.
|
||||||
Reference in New Issue
Block a user