docs(refs): commit groupé docs de référence session 2026-05-08

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-09 11:32:52 +02:00
parent 5dc20cc85b
commit e0b47e4518
7 changed files with 2124 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
# Diagnostic — Replay `replay_free_68ca51ab` bloqué sur l'onglet Imagerie
Date : 8 mai 2026 (matin avant démo GHT Sud 95)
Workflow : `Urgence_aiva_demo` (`wf_a38aeebea5e6_1778162737`)
Replay : `replay_free_68ca51ab` (cancellé manuellement à 10:34, après pause supervisée step 18)
Auteur : ingénieur senior debug RPA / vision
> **TL;DR — il y a deux causes simultanées, l'une explique l'autre.**
>
> 1. **Cause primaire (réseau) :** le client Léa V1 Windows utilise un `read_timeout=5 s` sur `GET /replay/next`. Sur cette même connexion, le serveur exécute parfois un `extract_text` (57 s) PUIS dispatche un `click` dans le même appel. Le client coupe avant la réponse. L'action `click` était déjà *poppée* de la queue serveur (`_retry_pending`), donc **elle est perdue silencieusement** — pas de retry automatique, pas de re-dispatch.
> C'est ce qui s'est passé pour les steps 10, 12, 14 et 17 (clic Imagerie, Notes médicales, Synthèse Urgences, Codage). Le client a poursuivi son polling, mais quand il est revenu à l'écoute, le serveur en était déjà au step 18 (`Coller ou saisir le dossier patient`) — qui n'existe que sur la maquette aiva-vision (`codage.html`), donc échec `target_not_found` → pause supervisée.
>
> 2. **Cause aggravante (vision) :** la cascade `OCR-DIRECT` (`_resolve_by_ocr_text`) renvoie le **centre de la ligne entière** quand le `target_text` n'est qu'un sous-fragment (`score=0.8`). Pour la barre de tabs Easily, docTR détecte les 5 tabs comme une seule ligne. Conséquence : `Imagerie`, `Notes médicales` et `Synthèse Urgences` retournent quasiment les mêmes coordonnées (~0.23, 0.28) — c'est-à-dire le centre de la rangée de tabs (qui tombe sur Imagerie). Même si le client avait reçu chaque action, le clic aurait probablement raté la cible.
---
## 1. Reconstruction temporelle
Workflow `Urgence_aiva_demo` (steps issues de la DB SQLite, ordre 1→22) :
| Order | id (court) | type | label |
|-------|----------------|------------------|-----------------------------------------------|
| 1 | 43ab3c1417d3 | extract_table | Lire liste patients (IPP) |
| 2 | 1dada40f6a44 | pause_for_human | Confirmer démarrage |
| 3 | 288d0bceea90 | click_anchor | Ouvrir dossier MOREL (25003284) |
| 4 | 5388268582d6 | extract_text | Lire Motif d'admission (`t_motif`) |
| 5 | b18e530526bb | keyboard_shortcut| Scroll fin de page (End) |
| 6 | b425b17b37f6 | extract_text | Lire bas de Motif (`t_motif_bas`) |
| 7 | fc4cf0a78b65 | keyboard_shortcut| Retour haut (Home) |
| 8 | 45f5d7fb7456 | click_anchor | Onglet Examens cliniques |
| 9 | 4148c9e8caa4 | extract_text | Lire Examens cliniques (`t_examens`) |
| 10 | 4c0663941f22 | click_anchor | Onglet Imagerie |
| 11 | 93cf4c6651f3 | extract_text | Lire Imagerie (`t_imagerie`) |
| 12 | **3b13c973d737** | click_anchor | **Onglet Notes médicales** |
| 13 | a5840d6bf8ed | extract_text | Lire Notes médicales (`t_notes`) |
| 14 | 8767d8e2e221 | click_anchor | Onglet Synthèse Urgences |
| 15 | 835e5dd54bb7 | extract_text | Lire Synthèse (`t_synthese`) |
| 16 | fc5a9676af55 | t2a_decision | Décision T2A (LLM) |
| 17 | 156d7cd29ebb | click_anchor | Onglet « Codage > » (vers maquette aiva) |
| 18 | 36346c1c40b9 | click_anchor | Cliquer textarea DPI (sur codage.html) |
| ... | | | |
### Logs serveur — `journalctl --user -u rpa-streaming`
Filtrage `replay_free_68ca51ab` + `RESOLVE_*` + `REPORT` + `extract_text`. Extraits pertinents :
```
10:25:46 RESOLVE_ENTRY by_text='Examens cliniques' strict_mode=True screen=2560x1490 has_anchor=True
10:25:48 Strict resolve OCR-DIRECT : OK 'Examens cliniques' → (0.2305, 0.2676) score=0.80
10:25:48 RESOLVE_EXIT resolved=True method='hybrid_text_direct' coords=(0.2305, 0.2676) score=0.8
10:25:49 REPORT step_45f5d7fb7456 success=True actual_position=(0.2305, 0.2798)
10:25:55 extract_text → variable 't_examens' (1689 chars)
10:25:55 DISPATCH action_id=step_4c0663941f22 (click) by_text='Imagerie' ← client a déjà timeout
10:26:01 extract_text → variable 't_imagerie' (1084 chars)
10:26:01 DISPATCH action_id=step_3b13c973d737 (click) by_text='Notes médicales' ← perdu
10:26:08 extract_text → variable 't_notes' (1084 chars)
10:26:08 DISPATCH action_id=step_8767d8e2e221 (click) by_text='Synthèse Urgences' ← perdu
10:26:17 extract_text → variable 't_synthese' (1084 chars)
10:26:27 t2a_decision → variable 'dec' decision=FORFAIT_URGENCE (10.0s)
10:26:27 DISPATCH action_id=step_156d7cd29ebb (click) by_text='Codage' ← perdu (concurrence de polls)
10:26:27 DISPATCH action_id=step_36346c1c40b9 (click) by_text='Coller ou saisir le dossier patient'
10:26:28 RESOLVE_ENTRY by_text='Coller ou saisir le dossier patient' strict_mode=True
10:26:30 Strict resolve OCR-DIRECT : 'Coller ou saisir le dossier patient' non trouvé, passage VLM
10:26:36 RESOLVE_EXIT resolved=False method='strict_vlm_template_failed'
10:28:47 REPORT step_36346c1c40b9 success=False error='target_not_found' warning='visual_resolve_failed'
10:34:00 Replay annulé manuellement
```
**Ce qui ne figure PAS dans les logs serveur** : aucun `RESOLVE_ENTRY` pour `by_text='Imagerie'`, `'Notes médicales'`, `'Synthèse Urgences'` ou `'Codage'` côté replay live. La cascade de résolution n'a JAMAIS été appelée pour ces tabs. → Le client n'a jamais frappé `/resolve_target` ni reçu l'action.
### Logs client — `C:\rpa_vision\agent_debug.log`
```
10:25:44.710 Action de replay recue : click (id=step_45f5d7fb7456 — Examens cliniques)
10:25:47.448 Server resolve OK [hybrid_text_direct] score=0.80
10:25:48.008 Replay click [VISUAL] : (0.230, 0.280) -> (590, 447) sur (2560x1600)
10:25:48.324 Ecran change apres ~200ms
10:25:48.537 Resultat rapporte : replay_status=running, restant=14
10:25:53.771 WARNING : HTTPConnectionPool(host=192.168.1.40, port=5005): Read timed out (read timeout=5)
10:25:53.771 Replay termine - retour en mode capture
10:25:53.780 shared_state Replay termine
← 33 s de silence
10:26:26.608 Action de replay recue : click (id=step_36346c1c40b9 — Coller ou saisir...)
10:26:35.409 Server resolve échoué : vlm_and_template_all_failed
10:26:39.096 Server resolve échoué : no_target_criteria
10:26:44.178 Server resolve échoué : vlm_and_template_all_failed
10:26:45.585 ERROR [LEA] Léa a besoin d'aide: Je n'y arrive pas (« Coller ou saisir... »)
10:28:45.762 [APPRENTISSAGE] Timeout global → 0 actions capturées
10:28:46.231 Replay termine
```
→ Confirmation directe : le client a sauté **9 actions serveur+visuelles** entre l'OK Examens cliniques (10:25:48) et la réception de step 18 (10:26:26).
---
## 2. Diagnostic causal
### Chaîne de responsabilité
```
+---------------------------------------------------------------------------+
| Hyp #1 (cascade serveur foire) — INFIRMÉE |
| La cascade serveur n'est même jamais invoquée pour ces 4 tabs. |
| |
| Hyp #2 (cascade locale Léa V1 prend le relais) — INFIRMÉE |
| Le client n'a pas reçu d'action → rien à résoudre localement. |
| |
| Hyp #3 (coords brutes du record obsolètes) — INFIRMÉE |
| L'ancre `anchor_0438bd2d9bdd_1778161174` (« Notes médicales ») a |
| bbox (444, 424, 146, 48) qui dans l'image de référence pointe sur |
| « Imagerie » et NON Notes médicales (le crop le confirme : |
| /tmp/anchor_0438bd2d9bdd_1778161174_bbox.png montre « Imagerie »). |
| Pareil pour anchor_6a2591e7c51c (« Synthèse Urgences ») dont la |
| bbox (580, 423, 192, 47) crop « Notes médicales ». |
| → Les bboxes des tabs sont décalées d'un cran à gauche dans la DB, |
| mais ce n'est PAS la cause du blocage actuel : le mode strict + OCR- |
| DIRECT ignore la bbox et part de by_text. Anomalie cosmétique à |
| nettoyer hors-démo. |
| |
| Hyp #4 (offset écran live vs record) — PARTIELLEMENT VRAIE |
| Voir §3. |
| |
| Hyp #5 (event onclick JS) — INFIRMÉE |
| Voir §3. |
| |
| Hyp #6 (cache client/serveur) — INFIRMÉE |
| Aucun `from_memory=True` dans les logs ; TargetMemoryStore pas hit. |
| |
| Cause primaire = HTTP TIMEOUT 5 s côté client |
| + actions serveur lentes (extract_text 5-7 s, t2a_decision 10 s) |
| + pas de watchdog d'orphelins dans `_retry_pending` |
| |
| Cause aggravante = OCR-DIRECT center-of-line bug |
| score=0.8 → coords = centre de la ligne docTR entière, pas du span. |
+---------------------------------------------------------------------------+
```
### Mécanique exacte du timeout
`agent_v0/server_v1/api_stream.py` (`get_next_action`, lignes 2816-3083) :
1. Acquiert `_replay_lock` avec `acquire_timeout=4.5 s`. Sinon retourne `{server_busy: True}`**OK**.
2. Une fois le lock pris, boucle `while queue:` qui exécute toutes les actions « serveur » (`extract_text`, `extract_table`, `t2a_decision`, `pause_for_human` non bloquant) **dans le même appel HTTP**, jusqu'à tomber sur une action visuelle (`click`/`type`/`key_combo`) qu'il dispatch et retourne.
3. `extract_text` est wrappé dans `loop.run_in_executor(...)` (timeout 180 s) pour ne pas bloquer l'event loop FastAPI — bon design.
4. **Mais le client appelle ce endpoint avec `timeout=5` (executor.py:1786).** Si la chaîne `extract_text + dispatch_click` prend plus de 5 s, la réponse arrive après que le client ait fermé sa socket. La réponse contient le `click` action et est perdue.
5. Côté serveur (ligne 3209-3224), l'action est déjà *poppée* de la queue et stockée dans `_retry_pending[action_id]` au moment du dispatch. Pas de retry automatique tant que le client ne renvoie pas un report (qui ne viendra pas).
6. Le client repasse en `_replay_active=False` (`main.py:331`) — *cosmétique* — puis continue de poller. Au poll suivant, la queue est passée à l'action suivante (`extract_text`), idem boucle.
**Aucun watchdog ne ré-énonce `_retry_pending` au client.** L'unique chemin pour récupérer une action perdue serait que le client envoie un report avec `success=False` (jamais le cas ici puisqu'il n'a pas reçu l'action).
### Mécanique exacte de l'OCR-DIRECT center-of-line
`agent_v0/server_v1/resolve_engine.py:1447-1527` :
```python
# Match exact > contient > mot par mot
score = 0.0
if target_lower == line_lower: score = 1.0
elif target_lower in line_lower: score = 0.8
elif any(target_lower == w.value.lower() for w in line_obj.words): score = 0.9
if score > best_score:
box = line_obj.geometry # bbox de la LIGNE ENTIÈRE
cx = (box[0][0] + box[1][0]) / 2
cy = (box[0][1] + box[1][1]) / 2
```
Quand docTR voit la barre de tabs Easily comme **une seule ligne** : `"Motif d'admission Examens cliniques Imagerie Notes médicales Synthèse Urgences Codage >"`, il retourne pour CHAQUE `target` qui est un sous-fragment de cette ligne **le centre de la rangée entière** (~50 % en x, ~28 % en y). Les coords ne dépendent pas du tab demandé.
Preuve archivée dans les logs e2e_singleshot du 8 mai à 09:58 (single screenshot, donc résultats reproductibles) :
- `'Imagerie'``(0.2305, 0.2805)` score 0.8
- `'Notes médicales'``(0.2285, 0.2805)` score 0.8
- `'Synthèse Urgences'``(0.2285, 0.2805)` score 0.8
- (delta ~5 px, trois tabs visuellement à 4-5 cm d'écart les uns des autres)
→ Si le client avait reçu les 4 actions, il aurait cliqué 4 fois quasiment au même endroit (vers Imagerie). Bug latent indépendant du timeout.
---
## 3. Vérification des hypothèses 4 et 5
### Hyp #4 — offset écran live vs record
Géométrie réelle du rendu Easily Assure dans une fenêtre Edge fullscreen 2560×1600 :
- Edge title bar : ~40 px (offset_y de la fenêtre = 49 d'après le log « Grounding contraint à la fenêtre : 2560x1490 (0, 49) »)
- Edge tabs/URL/bookmarks : ~250 px
- `.app-header` Easily (bleu) : 36 px (padding 8 + font 18)
- `.menu-bar` (Patients/Planning/...) : 32 px
- `.patient-banner` (IPP MOREL...) : ~50 px
- `.tabs` (Motif/Examens/...) : 36 px (height CSS) → **y range ≈ 410-450 dans l'image 2560x1600**
Le crop de référence (ancre `anchor_0438bd2d9bdd_1778161174`) à y=420-480 montre exactement la rangée de tabs (cf. `/tmp/tabs_row_full.png`). Pas d'offset majeur entre record et live. Une éventuelle dérive ±10-30 px est gérable par un click au pixel central.
**Hypothèse 4 partiellement vraie** : il y a effectivement un offset, mais il n'est pas la cause du bug. Et il est dégradé par le bug OCR-DIRECT center-of-line (cause #2) puisque le centre de la ligne tombe au milieu de la barre, pas sur le tab demandé.
### Hyp #5 — event onclick JS de la maquette
`docs/clients/ght_sud_95/mockup_easily_assure/dossier.html:36-43` :
```html
<div class="tabs">
<a class="tab active" data-tab="motif">Motif d'admission</a>
<a class="tab" data-tab="examens">Examens cliniques</a>
<a class="tab" data-tab="imagerie">Imagerie</a>
<a class="tab" data-tab="notes">Notes médicales</a>
<a class="tab" data-tab="synthese">Synthèse Urgences</a>
<a class="tab" id="tab-vers-codage" href="codage.html">Codage &gt;</a>
</div>
```
`app.js:377-401` :
```js
function installTabs() {
const tabs = document.querySelectorAll('.tabs .tab[data-tab]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.getAttribute('data-tab');
history.replaceState(null, '', '#' + target + location.search);
activate(target);
window.scrollTo(0, 0);
});
});
}
```
→ Mécanisme propre. `addEventListener('click')` directement sur les `<a class="tab">`. Aucun overlay, aucun event swallow. Un MouseEvent (Win32 SendInput → Windows Edge → DOM) sur le pixel d'un tab DÉCLENCHE le listener. Le tab `Codage` est un lien `href="codage.html"` → navigation native. Aucun problème côté maquette.
**Hypothèse 5 infirmée**.
---
## 4. Reproduction en isolation
Données déjà disponibles via le test e2e_singleshot du 8 mai 09:58 (`session=e2e_singleshot_1778227119_1fe686`), qui appelle `/resolve_target` sur un screenshot fixe (probablement l'onglet Imagerie ouvert) :
| target_spec.by_text | Résolution | x_pct | y_pct | score |
|------------------------------|-----------------------|--------|--------|-------|
| `25003284` | hybrid_text_direct | 0.0312 | 0.3539 | 1.00 |
| `Examens cliniques` | hybrid_text_direct | 0.0610 | 0.3195 | 1.00 |
| `Imagerie` | hybrid_text_direct | 0.2305 | 0.2805 | 0.80 |
| `Notes médicales` | hybrid_text_direct | 0.2285 | 0.2805 | 0.80 |
| `Synthèse Urgences` | hybrid_text_direct | 0.2285 | 0.2805 | 0.80 |
| `Codage` | hybrid_text_direct | 0.1279 | 0.1641 | 0.80 |
| `Coller ou saisir le dossier patient` | hybrid_text_direct | 0.0630 | 0.4125 | 1.00 |
| `Justification de la décision`| template_matching | 0.5000 | 0.5000 | 1.00 |
→ Reproduction confirmée : trois tabs (Imagerie / Notes / Synthèse) renvoyés à coords pratiquement identiques. Le test du 10:01 (1920x1080) reproduit la même chose : `Notes médicales` → (0.2227, 0.1259), `Imagerie` → (0.2256, 0.1267), même row.
Pour reproduire en CLI sans flask :
```bash
cd /home/dom/ai/rpa_vision_v3 && source .venv/bin/activate
python -c "
from agent_v0.server_v1.resolve_engine import _resolve_by_ocr_text
img='/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/data/anchors/anchor_0438bd2d9bdd_1778161174_full.png'
for t in ['Imagerie','Notes médicales','Synthèse Urgences','Codage','Examens cliniques']:
r = _resolve_by_ocr_text(img, t, 2560, 1600)
print(f'{t:25s} -> {r}')
"
```
Ce smoke test (offline, ~30 s la 1re fois pour télécharger le modèle docTR) prouve la cause #2 sans dépendre du PC Windows.
---
## 5. Trois correctifs proposés (sans appliquer)
### Quick fix démo (510 min) — **passer le client en `timeout=30` pour `/replay/next`**
Fichier : `agent_v1/core/executor.py:1786` côté Windows.
Changer :
```python
timeout=5,
```
en :
```python
timeout=30,
```
Justification : la borne dure côté serveur est déjà 180 s par action serveur ; le serveur retourne aussi `server_busy=True` au plus tard à 4.5 s. Un timeout client à 30 s laisse passer un `extract_text` de 5-10 s + dispatch d'un click sans couper la connexion. Pas d'effet de bord majeur — au pire le client attend 30 s en cas de mort serveur, déjà couvert par la backoff.
**Effet** :
- Le client ne loupera plus aucun click même si extract_text/t2a_decision est devant.
- Le bug OCR-DIRECT center-of-line reste, mais `Examens cliniques` (score 1.0) et `Codage` (autre ligne) seront correctement résolus → la maquette aiva-vision finira par s'afficher.
- Imagerie/Notes/Synthèse cliqueront tous les trois sur le centre de la rangée (en pratique au-dessus d'Imagerie). C'est cosmétiquement faux mais **t_imagerie/t_notes/t_synthese seront tous identiques** ; il faut prévenir Amina qu'on n'aura qu'une seule lecture du DPI multi-onglets.
**Risque** : très bas. Modifier un seul littéral. Redéploiement SSH du fichier executor.py.
### Quick fix démo bis (1015 min) — **boost OCR-DIRECT pour ne renvoyer que le centre du span matché**
Fichier : `agent_v0/server_v1/resolve_engine.py:1486-1519`.
Idée : quand le score est 0.8 (substring match) ou 0.9 (mot exact dans la ligne), recalculer `cx, cy` à partir des **bboxes des words** qui composent le `target_text`, pas de la ligne entière.
Pseudo-patch (à appliquer après-démo) :
```python
elif target_lower in line_lower:
score = 0.8
# Recalculer la bbox du span uniquement
matched_words = [w for w in line_obj.words if w.value.lower() in target_lower]
if matched_words:
xs = [pt[0] for w in matched_words for pt in w.geometry]
ys = [pt[1] for w in matched_words for pt in w.geometry]
cx = (min(xs) + max(xs)) / 2
cy = (min(ys) + max(ys)) / 2
```
**Effet** : chaque tab résolu à son propre centre, plus de collision.
**Risque** : moyen — il faut tester avec docTR pour vérifier que les `geometry` des words sont normalisées dans le même repère que celui de la ligne. Possible que le nettoyage du substring matching soit tordu par les accents/casse. À NE PAS appliquer à la chaude pour la démo, mais pour le runner 2.
> **Combo conseillé pour la démo** : appliquer SEULEMENT le fix #1 (timeout 30 s). Le bug center-of-line tab fait que t_imagerie/t_notes/t_synthese seront tous = même contenu (Imagerie). Si Amina utilise déjà `t_imagerie t_notes t_synthese` dans le prompt T2A, ça reste exploitable (juste moins de variété). Le clic Codage > marchera (autre ligne docTR).
### Fix moyen terme (3060 min) — **watchdog `_retry_pending` côté serveur**
Ajout d'une boucle background dans `api_stream.py` qui scanne `_retry_pending` toutes les 10 s et :
- Si une action a été dispatchée il y a > 30 s sans `REPORT` → la repush en tête de queue (avec un `_resent=True` flag pour stats).
- Émission `[BUS] lea:dispatch_orphan_resent`.
Justification : aujourd'hui une action perdue (timeout, kill client, déconnexion réseau) est perdue silencieusement. C'est un trou de fiabilité indépendamment de la démo GHT. Le watchdog garantit la reprise sans intervention manuelle.
**Risque** : moyen — il faut bien gérer la concurrence avec le client qui pourrait finalement renvoyer le report tardivement. Idempotence des reports déjà gérée dans `report_action_result` (line 3356 : `_retry_pending.pop(action_id)`), donc resend = réponse éventuelle ignorée.
### Fix structurel (post-démo, refonte) — **Server-Sent Events (SSE) ou WebSocket pour le push d'actions**
Le pattern « pull avec long poll 5 s » est intrinsèquement fragile dès que les étapes serveur sont imprévisibles. Solutions architecturales :
1. **SSE** (`text/event-stream`) : connexion persistante, le serveur push chaque action quand prête. Pas de timeout client à régler. Reconnexion automatique gérée nativement par EventSource. Plus simple à implémenter que WebSocket en FastAPI.
2. **WebSocket** : full duplex, idéal pour heartbeat + actions + monitoring. Plus de code mais futur-proof.
3. **HTTP/2 server push + chunked responses** : entre les deux. Pas standard côté requests Python.
Bénéfices :
- Suppression du bug timeout pour de bon.
- `_retry_pending` devient quasi inutile (push ack-based).
- Réduction du trafic (pas de poll inutile ~1/s).
- Détection immédiate de déconnexion client → déclenche pause supervisée serveur.
Coût : 1-2 jours dev + tests E2E.
---
## 6. Notes annexes (à nettoyer hors démo)
1. **Anomalie d'ancrage DB** : les ancres `anchor_0438bd2d9bdd_1778161174` (Notes médicales label) et `anchor_6a2591e7c51c_1778229076` (Synthèse Urgences label) ont des bboxes pointant un cran à gauche du tab nommé. Ce n'est pas la cause du bug (mode strict + OCR-DIRECT bypass la bbox) mais c'est trompeur en debug. À reposer en VWB record session post-démo.
2. **`target_text` mal-OCRisé en DB** : le champ `target_text` de l'ancre Notes médicales contient `"ine Né(e) le 14/03/1947 I 77 ans es Imagerie Notes médical J scan, echograj phie"`. C'est un OCR brut de la zone capturée — utile en debug, à ne pas confondre avec un identifiant fiable.
3. **Pré-check OCR post-cascade désactivé** (`RPA_ENABLE_TEXT_PRECHECK=false`) : pour la démo c'est OK. Mais à activer post-démo car il aurait peut-être attrapé le cas (clic sur centre de rangée = OCR autour ne voit pas exactement le by_text demandé). À recalibrer (radius_px et min_token_ratio) pour ne pas faux-rejeter sur les tabs à 2 tokens.
4. **Pas de `RESOLVE_ENTRY` dans les logs serveur du replay live pour les tabs perdus** : confirme que `/resolve_target` n'est PAS appelé tant que le client n'a pas reçu l'action. Aucun chemin caché côté serveur.
5. **Concurrence de polls vue à 10:26:27** : deux DISPATCHes en 0.6 s pour 2 polls quasi-simultanés. C'est cohérent avec deux requêtes en attente sur l'acquire lock + une qui retourne `server_busy` puis une qui acquiert. Le bug fundamental reste le timeout client trop court, pas la concurrence.
---
## Synthèse (≤ 400 mots)
Le replay s'est bloqué non pas à cause d'un échec de résolution visuelle, mais à cause d'une **désynchronisation client-serveur silencieuse**.
À 10:25:48, le client Léa Windows a cliqué avec succès « Examens cliniques » et reporté `success=True`. Cinq secondes plus tard (10:25:53.771), il poste un nouveau `GET /replay/next` qui timeout à 5 s — parce que côté serveur l'appel commence par `extract_text` (~57 s pour récupérer `t_examens` 1689 chars) puis dispatche le click `Imagerie`. Le serveur a déjà *poppé* l'action de la queue et stocké dans `_retry_pending`, mais la réponse HTTP arrive après que le client ait fermé sa socket. **L'action est perdue.** Aucun watchdog côté serveur ne la republie. Le client repasse en mode capture cosmétique mais continue à poller. Pendant 33 s, à chaque /next il aspire de l'action serveur (extract Imagerie, dispatch Notes — perdu, extract Notes, dispatch Synthèse — perdu, extract Synthèse, t2a_decision 10 s, dispatch Codage — perdu) jusqu'à recevoir directement step 18 (« Coller ou saisir le dossier patient ») qui n'existe que sur la maquette `codage.html`. Échec `target_not_found` → pause supervisée → l'utilisateur cancel.
C'est pour ça que `t_examens`, `t_imagerie`, `t_notes`, `t_synthese` ont tous le même contenu (1689 puis 1084 chars répétés) : l'écran n'a jamais changé d'onglet ; le DPI envoyé à T2A est mutilé.
Bug aggravant **indépendant** : `_resolve_by_ocr_text` (resolve_engine.py:1447) renvoie le **centre de la ligne docTR entière** quand le `target_text` est un sous-fragment (score 0.8). docTR détecte la barre de tabs comme une ligne unique → Imagerie/Notes/Synthèse renvoient tous (0.23, 0.28). Confirmé par le test e2e_singleshot du même jour à 09:58. Même si le client recevait les actions, le clic raterait la cible. Latent dès que plusieurs tokens cibles partagent la même ligne docTR.
**Recommandation démo** : passer le `read_timeout` client de 5 s à 30 s (`agent_v1/core/executor.py:1786`). Quick win, zéro risque, suffit pour que le pipeline aboutisse à `codage.html` et que la maquette aiva-vision se remplisse. Accepter pour la démo que les 3 tabs Imagerie/Notes/Synthèse cliqueront tous au centre de la rangée (le DPI multi-onglets sera dégradé mais le t2a_decision restera exploitable car `t_motif` et `t_motif_bas` portent l'essentiel du diagnostic).
**Priorité post-démo** : (1) watchdog `_retry_pending`, (2) fix OCR-DIRECT center-of-span, (3) refonte SSE/WebSocket.