backup: snapshot post-démo GHT 2026-05-19
Some checks failed
tests / Lint (ruff + black) (push) Successful in 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped

Backup état complet après enregistrement vidéo démo de bout en bout.
À utiliser comme point de référence pour la consolidation post-démo.

Changements majeurs de la session 18-19 mai :
- AIVA-URGENCE : page autonome avec preset URL + auto-focus chain
- Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine
- Bypass LLM (static_result / static_text) dans replay_engine
  pour démos déterministes sans appel Ollama
- Fix api_stream:3013 — replay_paused au premier polling /next
- dag_execute : lift duration_ms vers top-level pour wait runtime
- NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git)
- scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue

Anchors visuels (468) forcés dans le commit pour garantir restorabilité.
DB workflows actuelle + ~12 .bak DB de la journée incluses.

Sujets identifiés pour consolidation post-démo (TODO) :
1. Bug VWB recapture anchor ne régénère pas le PNG
2. Léa client accumule état mémoire (restart périodique requis)
3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel)
4. Bug coord client mss tronqué 2560x60 → mapping Y cassé
5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-19 14:55:06 +02:00
parent f2212e77e3
commit 5ea4960e65
627 changed files with 211348 additions and 169 deletions

View File

@@ -0,0 +1,129 @@
# HANDOFF CLAUDE CHAT — 2026-05-10 fin de journée
## Ce qui a marché aujourd'hui
- Diagnostic clair de l'archi rpa_vision_v3 : projet mature,
beaucoup de composants codés mais non branchés au runtime,
surdocumentation. Recadrage Dom : "le code surpasse la doc".
- Identification du vrai grounder prod (InfiGUI sur core/grounding/,
mais pas câblé au replay engine côté agent_v0/server_v1/ — assumé
pour l'instant)
- Bug #1 (cannot unpack non-iterable NoneType) résolu indirectement
par redémarrage propre Léa via Lea.bat
- Bug #2 (timeout HTTP 30s pendant t2a_decision 26s) identifié,
à corriger pour démo (passer à 60s ou dynamique)
- 3 livrables agent VWB validés et rapatriés :
* Numérotation steps sur canvas (badges 1..N en bleu)
* Ancre zoomable au survol miniature dans Properties (tooltip
600px)
* Nouveau type de step extract_text_scroll (capture haut +
Ctrl+End + capture bas + concat + Ctrl+Home, scroll_pause_ms
configurable, 500ms default)
- Découverte du bug UX VWB : capture.py:select_anchor appelle
silencieusement qwen2.5vl:3b à chaque ancre capturée pour
générer va.description, qui est ensuite affichée sous les
nodes du canvas. VLM hallucine régulièrement, induit Dom en
erreur pendant la construction. À débattre post-démo.
- Doc inspiration frameworks externes en
docs/INSPIRATION_FRAMEWORKS_2026-05-10.md (OpenAdapt, Skyvern,
OmniParser, OpenCUA — convergences fortes avec rpa_vision_v3)
## État machine à la coupure (~20h45 CEST)
- InfiGUI démarré (PID 3079017, ~3934 MiB VRAM, non appelé par
le replay)
- Ollama actif (idle, charge à la demande)
- rpa-streaming actif (PID 3169493)
- Léa Windows polle
- BDD VWB sauvegardée dans
docs/handoffs/2026-05-10_workflows_etat_soir.db (point de
référence avant reprise demain)
## Ce qui reste à clarifier demain matin
PRIORITÉ ABSOLUE : trancher la divergence entre ce que Dom dit
avoir saisi (variables correctes, monitor GPU sans activité VLM
pendant la session de reconstruction) et ce que Claude Code
rapportait de la BDD (incohérences sur step 7, steps 14/17
sans by_text, ordre des extract_text_scroll vs t2a_decision).
Hypothèses à confronter :
- Bug VWB de persistance silencieux (modifs Dom non écrites en BDD)
- Cache frontend obsolète après hot-reload
- Claude Code en dérive de fin de session (lecture biaisée d'une
BDD qui était en réalité conforme)
- Affichage canvas trompeur (sous-labels = descriptions VLM
hallucinées, mais step.label et by_text en BDD sont corrects)
Méthode pour démêler :
1. Cliquer sur chaque node click_anchor du workflow et lire le
champ "Cible textuelle" / by_text dans Properties (pas le
sous-label canvas)
2. Comparer avec ce que Dom voulait
3. Si écart : corriger en repassant par le VWB en se fiant
uniquement à Properties
4. Si pas d'écart : passer directement au dry-run
## 4 points de fix potentiels (à valider d'abord)
1. Step 7 (extract_text) : variable_name = t_notes_medicales
alors que label "Lire Synthèse Urgences" → si confirmé,
renommer en t_synthese_urgences pour cohérence avec
t2a_decision et type_text DPI
2. Steps 14 et 17 (click_anchor) : by_text vide → ajouter le
texte cible
3. Steps 15-19 (4 extract_text_scroll consécutifs) : seulement
2 click_anchor entre eux → vérifier que chaque scroll lit
bien un onglet différent, pas le même contenu 2 fois
4. Step 8 (t2a_decision) : positionné AVANT les steps 15-19 qui
remplissent ses variables d'entrée → soit déplacer après, soit
c'est conforme à un autre design qu'il faut documenter
## Méthode pour la reprise demain matin
- Cerveau frais avant 9h, si possible avant la première vérif
- Ne se fier qu'au by_text dans Properties, jamais aux sous-labels
canvas
- Cycles courts : 1 modif → vérif BDD via Claude Code → valide →
modif suivante
- Si Claude Code retourne quelque chose qui surprend, demander
un dump SQL brut sans interprétation pour vérifier soi-même
- 30-45 min max pour les fix, 15 min dry-run, 1h marge avant démo
(l'après-midi)
## Points méthodo à retenir pour les prochaines sessions
- Claude Code et moi (Claude chat) dérivons après 12h+ de session
continue, comme Dom. Si retour suspect demain matin, redémarrer
une session fraîche plutôt que continuer.
- Le pattern "agent qui valide superficiellement le code mais
pas la fonctionnalité" est récurrent. La vérification
fonctionnelle reste à Dom (et à moi pour l'aider à la cadrer).
- Asymétrie mémoire Dom→Claude vs Claude→Dom : sujet à traiter
en session dédiée.
- Dom a un assistant de longue date appelé Alice, à intégrer
comme référence dans la construction de ma mémoire.
## État des dettes ouvertes
- DETTE-006 (bug d'échelle pixel grounding) : path Ollama, hors
scope démo car cascade ne descend pas jusqu'au grounding VLM
sur cibles textuelles. À fixer post-démo.
- Migration Qwen3-VL-8B-Instruct : non urgente. InfiGUI-G1-3B
satisfait les besoins courants.
- DETTE-014 (smart_resize calé sur Qwen2VLImageProcessor non-Fast) :
à reclasser, factor=28 correcte pour cible Ollama qwen2.5vl
actuelle.
- Asymétrie VWB-direct (utilise UI-DETR-1) vs Replay-distant (ne
l'utilise pas) : asset architectural sous-utilisé. À débattre
post-démo si branchement au runtime sans figer l'apprentissage
est possible.
- Bug UX VWB capture.py : description VLM hallucinée affichée
comme sous-label. Désactiver l'appel VLM (1 commentaire dans
capture.py:225-272) ou changer le frontend.
## Coupure
Dom coupe à 20h45 CEST après 13h de session focus exceptionnelle.
Démo interne lundi après-midi devant DG/DSI/médecins/DIM/TIM.
Marge confortable pour reprise lundi matin avec cerveau frais.

View File

@@ -0,0 +1,20 @@
# Handoff fin de journée — 2026-05-10
**Heure de coupure** : 2026-05-10 20:53 CEST.
## Sauvegarde brute BDD
- **Chemin** : `docs/handoffs/2026-05-10_workflows_etat_soir.db`
- **Source** : `visual_workflow_builder/backend/instance/workflows.db`
- **Statut** : copie binaire intégrale, aucune modification de l'originale.
## Note utilisateur (Dom)
Workflow `Urgence_aiva_demo` reconstruit ce soir. Dom confirme avoir saisi les bonnes variables. Monitor GPU sans activité VLM pendant la session. Divergences rapportées par Claude Code à investiguer demain matin avec cerveau frais — possible bug Claude Code après 13h de session ou possible bug VWB de persistance silencieux.
## 4 points à vérifier demain matin
1. **Step 7** (badge UI 8) `extract_text` — label « Lire Synthèse Urgences » vs `variable_name = "t_notes_medicales"`. Mismatch label/variable à clarifier.
2. **Steps 14 et 17** (badges UI 15 et 18) `click_anchor``by_text` vide en BDD, seulement une ancre visuelle. Vérifier que c'est l'intention.
3. **Ordre** `extract_text_scroll` (steps 15, 16, 18, 19) vs `t2a_decision` (step 8) — `t2a_decision` lit des variables qui ne sont remplies qu'aux steps suivants en BDD. Vérifier l'ordre d'exécution prévu.
4. **Divergence intent utilisateur ↔ BDD** — Dom rapporte que ses variables saisies étaient correctes ; les valeurs en BDD montrent des écarts. Origine à investiguer (frontend, backend, persistance silencieuse).

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
# Exploration de correction — bug grounding "fenêtre fantôme 2560×108"
**Date** : 2026-05-11 (fin de journée)
**Statut** : livrable d'exploration. Aucun patch implémenté. Aucune rustine.
**Méthode** : 4 agents experts en parallèle (architecte logiciel, expert robotique, forensique chronologique, expert vision/template_matching). Investigation lecture seule.
---
## 1. Synthèse exécutive
**Cause racine identifiée (confiance HAUTE, convergence des 4 angles)** :
Le pipeline de grounding côté Léa Windows **crop la zone à analyser selon la fenêtre Win32 active** (`GetForegroundWindow()`), **sans vérifier que la cible recherchée est physiquement contenue dans cette fenêtre**. Quand la fenêtre Win32 active à l'instant T est la taskbar / un overlay / la fenêtre Léa elle-même (typiquement 2560×108 en bas d'écran), `cv2.matchTemplate` cherche un élément visuellement similaire **dans cette bande étroite**, trouve un faux positif à score=1.00, et le clic atterrit dans la barre des tâches.
**L'hypothèse "injection de coordonnées fixes" est INFIRMÉE.** Aucun bout de code ne court-circuite la chaîne 100% visuel par injection de coords hardcoded. Le score=1.00 observé est un VRAI score `cv2.matchTemplate (TM_CCOEFF_NORMED)`. Le label `template_matching` n'est pas usurpé. Le bug n'est pas un bypass, c'est une **erreur de cadrage de la zone d'analyse**.
**Note importante** : un MÉCANISME DE CRISTALLISATION existe (`TargetMemoryStore`) qui pourrait pérenniser le bug. Pas encore déclenché aujourd'hui (BDD intacte du 17 avril), mais à corriger pour éviter récidive.
---
## 2. Mécanisme précis (preuve mathématique)
### Coordonnées observées
- Cible BDD attendue : `x_pct=0.0314, y_pct=0.3519` → pixel `(80, 563)` sur écran 2560×1600.
- Coordonnée retournée par le grounding : `x_pct=0.0314, y_pct=0.9563` → pixel `(80, 1530)`.
### Reconstitution étape par étape
1. **Côté Léa** (`agent_v0/agent_v1/core/grounding.py:135-157`) :
- Appel `get_active_window_rect()` → retourne `[left=0, top=1492, right=2560, bottom=1600]`
- `window_rect = {left=0, top=1492, width=2560, height=108}`
- Capture screenshot **uniquement** de cette zone : image 2560×108 envoyée au serveur
2. **Côté serveur** (`agent_v0/server_v1/resolve_engine.py:63-200`) :
- `cv2.matchTemplate(image=2560×108, template=anchor)` → trouve un match parfait à `cy ≈ 37` (un élément visuellement similaire au template existe vraiment dans la taskbar — chiffres, icônes, etc.)
- Retourne `y_pct = 37 / 108 ≈ 0.3426` ← presque identique au 0.3519 demandé **par coïncidence numérique**
- `score = 1.00` ← VRAI score CV2, pas hardcoded
3. **Reprojection côté client** (`grounding.py:181-183`) :
- `abs_y = window_top + y_pct_serveur × window_height = 1492 + 0.3426 × 108 ≈ 1529`
- `y_pct_final = 1529 / screen_height = 1529 / 1600 ≈ 0.9556` (cohérent avec 0.9563 observé)
4. **Click final** (`executor.py:1058-1066`) :
- `real_y = 0.9563 × 1600 = 1530` ✓ — correspond exactement aux logs
### Pourquoi x_pct est identique à l'entrée et à la sortie
Coïncidence géométrique pure : `window_left=0` et `window_width=screen_width=2560`. La formule de conversion x est neutre dans ce cas particulier. **Ce n'est PAS un passthrough**, c'est un artefact de la fenêtre détectée qui coïncide avec la largeur écran.
---
## 3. Détail des 4 investigations
### 3.1 — Chronologique (agent forensique)
**Verdict** : PAS de régression aujourd'hui. Le bug `2560×108 (0, 1492)` est apparu **dès le premier click step_a196 de la journée à 13:56:05**, AVANT toute interaction utilisateur, AVANT tout apprentissage. Donc :
- `target_memory.db` (intact depuis le 17 avril) n'est PAS la cause initiale aujourd'hui
- Aucun store d'apprentissage modifié pendant la journée
- Aucun cache CV2 / template_matching cumulatif
**La cause est purement le Z-order Windows à l'instant T du click**. Quand Chrome a le focus → 2560×1490 → OK. Quand Léa Chat / taskbar / overlay a le focus → 2560×108 → bug.
### 3.2 — Architecte logiciel
**6 chemins identifiés** entre `Léa reçoit action click_anchor` et `pyautogui.click(x, y)` :
1. UIA local (court-circuit Win32) — non utilisé sur web Easily Assure
2. GroundingEngine local avec crop fenêtre — **chemin actif aujourd'hui, défaillant**
3. Memory lookup (TargetMemoryStore) — passthrough sans CV — **risque FUTUR** mais inactif aujourd'hui
4. ExecutionPlan resolve_order pré-compilé — legitimate
5. Drift bypass "high confidence" — amplificateur (désactive la garde de proximité si score ≥ 0.95)
6. Fallback heartbeat — trou de filtre pour wrong-window > 1200 px
**Hypothèse classée haute** : chemins 2 + 5 combinés. Le grounding crop dans la mauvaise fenêtre (chemin 2), template trouve un match parfait par coïncidence visuelle (`score=1.00`), la garde de drift est désactivée par le score élevé (chemin 5), résultat aberrant accepté.
### 3.3 — Expert robotique / contrat 100% visuel
**5 mécanismes de bypass potentiels** :
1. `TargetMemoryStore` — passthrough complet sans CV. Activation : `success_count ≥ 2`. Stocke les coords `human_supervised` sans distinction. **Risque ÉLEVÉ pour pérenniser le bug** mais INACTIF aujourd'hui (BDD non touchée).
2. Score=1.0 hardcoded dans `_resolve_by_ocr_text` (ligne 1450) — légitime sur match texte exact, mais propage 1.0 vers la mémoire
3. `memory_record_success` accepte `report.actual_position` même quand `resolution_method=="human_supervised"` — pollue la mémoire avec coords humaines
4. Fallback `recorded_coords` — apparemment éliminé par commit `40440f1ca`. Vérifié, pas d'occurrence active
5. `execution_mode=supervised` forcé — n'affecte pas la résolution de coordonnées
### 3.4 — Expert vision / template_matching
**Le score=1.00 est un VRAI `cv2.matchTemplate`** (TM_CCOEFF_NORMED). Le label `template_matching` n'est pas usurpé. Pas de chemin passthrough qui produit un faux score parfait.
**Bug racine** : le screenshot 2560×108 envoyé au serveur **ne contient PAS l'élément cherché** (y=563 hors de la fenêtre top=1492). Le serveur trouve un autre élément visuellement quasi-identique dans cette bande → score=1.00 sur le faux positif → le client clique sur le mauvais élément.
**Aucun garde-fou ne vérifie que la cible originale `(fallback_x, fallback_y)` est CONTENUE dans `window_rect` avant le crop**. C'est la pièce manquante.
---
## 4. Pistes de correction (sans coder, à arbitrer)
### Piste A — Garde-fou de contention (PRIORITÉ HAUTE, simple)
**Avant de cropper la fenêtre, vérifier que la cible demandée est contenue dans `window_rect`. Sinon fallback sur écran entier.**
- Localisation : `grounding.py:155` environ
- Logique : `if not (window.left ≤ fallback_x_abs ≤ window.right and window.top ≤ fallback_y_abs ≤ window.bottom): window_rect = None`
- Coût estimé : ~5 lignes
- Risque : très faible (cas normal préservé, cas dégénéré évite le crop)
- Effet attendu : la fenêtre 2560×108 (0, 1492) ne contient pas y=563 → fallback écran entier → grounding sur écran complet → match correct
- Adresse le problème **observé aujourd'hui** sans changer le contrat 100% visuel
### Piste B — Filtre de pertinence sur la fenêtre détectée (complémentaire)
**`get_active_window_rect()` doit rejeter les fenêtres "non métier"** :
- Ratio `width/height > 5` (bande absurde type taskbar)
- Hauteur `< 400 px` (trop petite pour une appli métier)
- Titre contient "Léa", "Assistant", "Taskbar", "Progman", "Shell_TrayWnd" (auto-référence Léa, environnement Windows)
- Process Léa elle-même (`pythonw.exe`) refusée pour s'auto-clamper
- Localisation : `window_info_crossplatform.py:106` (`_get_window_rect_windows`) ou wrapper appelant
- Coût : ~15 lignes
- Risque : faible
- Effet : préviendrait le cas dégénéré à la source
### Piste C — Observabilité minimale (PRIORITÉ MAXIMALE, trivial)
**Logger `title` et `app_name` de la fenêtre détectée**.
- Localisation : `grounding.py:152`
- Logique : modifier le log existant pour inclure `win_info.get("title")` et `win_info.get("app_name")`
- Coût : 1 ligne modifiée
- Risque : zéro
- Effet : connaître précisément le coupable Windows à chaque occurrence du bug. Sans ce log, on tâtonne.
- **À faire absolument avant toute autre intervention**
### Piste D — Refactor "écran entier par défaut" (CHANTIER DE FOND)
**Inverser le contrat : chercher sur l'écran entier par défaut, contraindre à une fenêtre UNIQUEMENT en présence d'une garantie sur le titre/processus.**
- Coût : refactor de `grounding.py` et impacts en cascade
- Risque : élevé (changement comportement étendu)
- Effet : élimine la classe entière de bugs liés à `GetForegroundWindow()`
- À envisager post-démo, pas dans l'urgence
### Piste E — Désactiver mémorisation des `human_supervised` (PRÉVENTION)
**Refuser `memory_record_success` quand `resolution_method == "human_supervised"`** pour éviter qu'une correction humaine sur la mauvaise fenêtre pollue la mémoire et pérennise le bug.
- Localisation : `api_stream.py:3592-3625`
- Coût : ~3 lignes
- Risque : faible
- Effet : la mémoire reste un cristallisateur de coords résolues par grounding visuel réel, pas par correction manuelle
### Piste F — Re-vérification des hits mémoire (RÉSILIENCE)
**Avant de retourner les coords issues de `TargetMemoryStore`, faire un quick `cv2.matchTemplate` de vérification à cette position.**
- Localisation : `resolve_engine.py:1541-1554` autour de `memory_lookup`
- Coût : ~10 lignes
- Risque : faible
- Effet : la mémoire devient un accélérateur, pas un bypass
---
## 5. Recommandation hiérarchisée
| Priorité | Piste | Coût | Risque | Effet sur bug actuel |
|---|---|---|---|---|
| **1** | **C — Observabilité (log title/app_name)** | 1 ligne | nul | aucun direct, mais débloque le diagnostic précis |
| **2** | **A — Garde-fou de contention** | ~5 lignes | très faible | **résout le bug observé aujourd'hui** |
| **3** | **B — Filtre pertinence fenêtre** | ~15 lignes | faible | complète A, robustesse |
| **4** | **E — Bloquer mémorisation human_supervised** | ~3 lignes | faible | prévention récidive |
| **5** | F — Re-vérif hits mémoire | ~10 lignes | faible | résilience long terme |
| **6** | D — Refactor fond | important | élevé | post-démo, chantier S3 |
**Note de méthode** : aucune de ces pistes n'est une rustine. Toutes sont des ajouts de **garantie / observabilité** qui respectent le contrat 100% visuel en le renforçant. La piste A en particulier ferme un trou contractuel : "le grounding ne doit chercher QUE dans une zone qui contient potentiellement la cible", ce qui devrait être un invariant du système.
---
## 6. Points à NE PAS faire
- **Ne pas réactiver `skip_verify` sur les clicks** (déjà testé aujourd'hui, masque le bug au lieu de le résoudre)
- **Ne pas réinjecter des coordonnées recorded comme fallback aveugle** (régression `b584bbabc` déjà curée par commit `40440f1ca`)
- **Ne pas ajouter de `time.sleep` en attendant un focus** (rustine timing, pas une solution)
- **Ne pas implémenter un fix sans avoir d'abord la piste C** : sans log du `title`, on ne sait toujours pas QUOI est ramassé exactement, et toute hypothèse reste partiellement spéculative
---
## 7. Fichiers clés référencés par les 4 agents
- `agent_v0/agent_v1/core/grounding.py:131-187`**point de défaillance principal** (calcul `window_rect`, conversion fenêtre→écran)
- `agent_v0/agent_v1/window_info_crossplatform.py:91-138``_get_window_rect_windows()`, utilise `GetForegroundWindow()` sans filtre sémantique
- `agent_v0/agent_v1/core/executor.py:865-884, 1058-1066` — appel grounding + log + click final
- `agent_v0/server_v1/resolve_engine.py:63-200` — vrai `cv2.matchTemplate`, retour `score`, `method=template_matching`
- `agent_v0/server_v1/resolve_engine.py:1541-1554``memory_lookup` (passthrough sans CV — inactif aujourd'hui, risque futur)
- `agent_v0/server_v1/resolve_engine.py:2330-2340` — drift gate désactivée si score ≥ 0.95 (amplificateur)
- `agent_v0/server_v1/replay_memory.py:142-206``TargetMemoryStore.lookup` / `record_success`
- `agent_v0/server_v1/api_stream.py:3592-3625``memory_record_success` accepte `human_supervised`
- `data/learning/target_memory.db` — BDD persistante des coords apprises (intact aujourd'hui)
---
## 8. Conclusion
**Le contrat 100% visuel n'a pas été violé par un bypass code.** Il a été **mal appliqué sur une zone incorrecte**. Le grounding fait son travail consciencieusement, mais sur le mauvais cadrage. La pièce manquante = vérification que la cible recherchée est dans la zone d'analyse avant de la cropper.
Les 4 agents convergent sur cette analyse. Aucune divergence factuelle. Les pistes A et C sont les plus rentables (bug actuel résolu + diagnostic futur facilité), à coût minimal et risque faible.
À arbitrer à froid demain matin, avec recul, sans pression de démo.

View File

@@ -0,0 +1,148 @@
# Handoff fin de journée — 11 mai 2026
## 1. Contexte de la journée
- **Objectif initial** : démo Urgence_aiva_demo (rpa_vision_v3) prévue 16h pour l'équipe AIVANOV interne (DG, DSI, médecins, DIM, TIM).
- **Décision en cours d'après-midi** : démo reportée, vidéo commentée à enregistrer ultérieurement.
- **Workflow utilisé** : `Demo_urgence_2` (`wf_d04d2dc7c118_1778493082`), 18 steps, créé/recordé le matin.
- **Sessions** :
- Claude Chat (cette session, audit méthode)
- Claude Code (cette session, exécution / diagnostics / patches)
- Autre session Claude Chat pour S2 arbre de décision (séparée).
---
## 2. État actuel de la stack (snapshot 2026-05-11 17:59 CEST)
| Service | Port | PID | État |
|---|---|---|---|
| Frontend VWB Vite | 3002 | 935784 (node) | ✅ LISTEN |
| Backend Flask VWB | 5002 | 4095891 | ✅ LISTEN |
| Agent chat / bus SocketIO | 5004 | 3568779 | ✅ LISTEN, bus ESTAB avec Léa (192.168.1.11:1031) |
| Streaming server | 5005 | 3830788 | ✅ LISTEN, 61 TIME-WAIT (polling Léa actif) |
| Ollama | 11434 | 3169222 + 423007 | ✅ LISTEN |
| Léa Windows | — | — | ✅ Polling actif depuis 17:51:23 (dernier démarrage observé). Bus connecté. SSH ESTAB. |
Dernier événement backend VWB : `17:59:28 GET /api/v3/replay/state/replay_free_b63cf1aa 200`.
---
## 3. Modifications de code appliquées aujourd'hui (toutes machines)
| # | Fichier | Lignes / nature | Statut | Backup | Validation runtime |
|---|---|---|---|---|---|
| 3.1 | `agent_v0/agent_v1/ui/chat_window.py` | Ajout `self.hide()` dans `_on_paused_resume` après émission resume (L1011) | **ROLLBACKÉ** en fin de journée pour test diagnostique (hypothèse focus système). Hypothèse **infirmée** mais rollback maintenu. | `docs/handoffs/2026-05-11_chat_window_avant_fix.py` | OUI (minimisation observée à 15:13/15:48). Mais cause indirecte suspectée puis disculpée. |
| 3.2 | `agent_v0/agent_v1/core/executor.py` | Ajout `time.sleep(0.5)` avant capture screenshot post-click (correction asymétrie click vs type/wait/scroll, L1289) | **ACTIF** côté Windows (déployé via SCP) | `docs/handoffs/2026-05-11_executor_avant_sleep_stabilisation.py` | PARTIELLE (a permis de débloquer le step 3 sans retry forcé en partie). |
| 3.3 | `agent_v0/server_v1/api_stream.py` | Extension `skip_verify` à `click` / `click_anchor` (L3409) | **ROLLBACKÉ** après découverte que la vérif pixel masquait un autre bug (clic à mauvaise position validé comme succès). | `docs/handoffs/2026-05-11_api_stream_avant_skipverify_click.py` | INVALIDÉ (a fait apparaître le bug "clic n'importe où"). |
| 3.4 | `visual_workflow_builder/backend/api_v3/dag_execute.py` | Ajout helper `_expand_extract_text_scroll_in_workflow` (+97 lignes) appelé dans `execute_dag()` (L554) | **INERTE** : endpoint `execute-dag` jamais appelé par le bouton "Exécuter" du frontend. | `docs/handoffs/2026-05-11_dag_execute_avant_expand.py` | NON (jamais exécuté). |
| 3.5 | `visual_workflow_builder/backend/api_v3/dag_execute.py` (suite) | Ajout helper `_expand_extract_text_scroll_in_actions` (+103 lignes) et appel en tête de `execute_windows()` (L1029) | **ACTIF**. C'est ce patch qui expanse réellement les `extract_text_scroll` en 6 sous-actions atomiques. | `docs/handoffs/2026-05-11_dag_execute_avant_patch_execute_windows.py` | PARTIELLE (jamais exécuté complètement à cause d'autres bloqueurs en amont — window-detection cassée). |
| 3.6 | BDD `workflows.db` | Renumérotation `Step.order` de Demo_urgence_2 : 18 steps passés de `order=0` (tous) à `order=0..17` distincts | **ACTIF** | `docs/handoffs/2026-05-11_pre_renum_demo_urgence_2.db` | OUI (badges UI 1..18 corrects, ordre respecté à l'exécution). |
| 3.7 | BDD `workflows.db` | Injection `by_text` sur 8 click_anchor de Demo_urgence_2 (orders 2, 4, 6, 8, 10, 13, 14, 16) | **ACTIF** | `docs/handoffs/2026-05-11_pre_inject_bytext_demo_urgence_2.db` | PARTIELLE (présent dans payload, mais pas suffisant pour rattraper la window-detection cassée). |
---
## 4. Diagnostics confirmés ou écartés aujourd'hui
### Confirmés
- Le frontend VWB appelle `/execute-windows`, **pas** `/execute-dag` ni `/execute/start`. Les deux derniers endpoints sont **dormants** (legacy/test).
- `execute_workflow_thread` itère les steps par `Step.order` SQL croissant (linéaire), les arêtes du canvas ne sont pas persistées en BDD et ne sont pas consultées par le runtime.
- `extract_text_scroll` n'était pas implémenté côté agent Léa Windows. Patch d'expansion serveur appliqué (cf 3.5).
- Léa fait du template_matching dans une **fenêtre cible** retournée par `get_active_window_rect()` (Win32 `GetForegroundWindow()`). Critère unique = foreground window à l'instant t. Aucun filtre sémantique (titre, ratio, taille minimale autre que `w>50, h>50`).
- À **5 occurrences sur 6** dans l'après-midi (15:48, 16:23, 17:02, 17:34, 17:53), `get_active_window_rect()` a retourné une bounding box absurde (typiquement `2560×108 (0, 1492)`). Conséquence : grounding `score=1.00` dans cette bande aberrante → clic à `(80, 1530)` au lieu de `(80, 563)`.
- L'absence de `expected_after` sur les click_anchor permet à Léa de continuer le workflow même quand un clic a atteint la mauvaise zone (l'écran "change" pixel-wise sans avoir atteint la page cible).
### Infirmés
- L'hypothèse "le fix `self.hide()` causait le déplacement de focus système et donc la mauvaise fenêtre détectée" n'est **pas** validée. Rollback de `chat_window.py` n'a pas restauré la bonne détection de fenêtre (bug `2560×108` réapparu à 17:53:32 après rollback).
### Non identifié
- Quel processus Windows exactement est retourné par `GetForegroundWindow()` lors des occurrences foireuses. Le code grounding ne loggue ni `title` ni `app_name` (trou d'observabilité documenté en S3.2.4).
---
## 5. Dette technique S3 — bugs structurels rpa_vision_v3
À traiter en priorité post-démo. Liste exhaustive de tout ce qu'on a identifié aujourd'hui.
### S3.1 — Architecture replay (PRIORITÉ HAUTE)
- **S3.1.1** Le `Step.order` BDD n'est pas synchronisé avec l'ordre visuel du canvas VWB. Les modifications d'ordre via drag-and-drop sur le canvas ne mettent pas à jour `step_order` côté BDD. L'agent exécute selon `Step.order` SQL ; le canvas est cosmétique pour l'exécution.
- **S3.1.2** Les nouveaux steps créés via le VWB ont systématiquement `step_order = 0`. Le code de création de step n'attribue jamais d'order incrémental. Bug structurel découvert en runtime aujourd'hui.
- **S3.1.3** Trois endpoints `/execute-windows`, `/execute-dag`, `/execute/start` coexistent ; un seul est utilisé. Architecture confuse. À simplifier (supprimer les morts ou unifier).
- **S3.1.4** Le helper `_expand_extract_text_scroll_in_workflow` patché aujourd'hui sur `execute-dag` est inerte. Si `execute-dag` est réactivé un jour, il faudra synchroniser avec `_expand_extract_text_scroll_in_actions` de `execute-windows`.
- **S3.1.5** Le contrat entre `execute_windows` et le streaming server n'est pas clair : certaines actions lisent les paramètres au top-level, d'autres dans `parameters`. Une "rustine défensive" duplique les valeurs aux deux endroits (cf `_expand_extract_text_scroll_in_actions`). À nettoyer.
- **S3.1.6** L'action `_concat_text_vars` est marquée serveur-only dans `_SERVER_SIDE_ACTION_TYPES` mais peut être envoyée à Léa par le chemin DAG. Asymétrie à corriger.
### S3.2 — Window-detection et grounding (PRIORITÉ TRÈS HAUTE)
- **S3.2.1** `get_active_window_rect()` dans `window_info_crossplatform.py` se base uniquement sur `GetForegroundWindow()` Win32. Aucun filtre sémantique : titre, process, ratio d'aspect, taille minimale réaliste, comparaison à la fenêtre attendue. Conséquence : bug récurrent `2560×108 (0, 1492)` qui a bloqué la démo aujourd'hui.
- **S3.2.2** Le mode "apprentissage par observation" de Léa (existant et fonctionnel) a une logique sophistiquée de détection de fenêtre (focus, titre, contexte, réflexes). Le mode "replay via VWB" hérite d'une logique window-detection minimaliste qui n'a jamais été unifiée. Chantier d'unification observation ↔ replay = priorité de fond.
- **S3.2.3** Le seul filtre actuel dans `grounding.py:144` est `w > 50 and h > 50`. Pas de docstring, ressemble à un check défensif minimal. À renforcer (h > 400, ratio < 5:1, comparaison au titre attendu).
- **S3.2.4** Aucun logging du `title` / `app_name` retourné par `_get_window_rect_windows()`. Trou d'observabilité majeur qui a empêché le diagnostic précis aujourd'hui. **Quick win pour demain** : ajouter 1 ligne de log.
- **S3.2.5** Le code path `click_anchor` peut emprunter un chemin "coordonnées passthrough" qui applique des `x_pct`/`y_pct` sur la fenêtre détectée sans vérification croisée par template_matching réel ni OCR du `by_text`. Le `score=1.00` retourné dans ce cas n'est pas un vrai score de match. Le contrat "valider visuellement avant cliquer" est de fait court-circuité quand la fenêtre détectée est correcte ; il est catastrophique quand elle est aberrante. **À investiguer** : identifier précisément la condition qui déclenche ce chemin passthrough vs le chemin "vrai grounding".
### S3.3 — Contrat de validation post-action (PRIORITÉ HAUTE)
- **S3.3.1** Un `click_anchor` est validé "réussi" dès que l'écran change pixel-wise. Aucune vérification que la page d'après contient bien le contenu attendu. Léa peut cliquer dans la barre des tâches Windows et déclarer "succès" parce que quelque chose a changé.
- **S3.3.2** Le mécanisme `expected_after` (texte attendu sur la page post-clic, vérifié par OCR) existe dans le code mais n'est exposé ni dans le VWB ni utilisé sur les workflows actuels. À activer et à imposer pour les click_anchor critiques.
- **S3.3.3** La vérification pixel post-click (`verify_action`) compare AVANT et APRÈS et déclenche un retry si le diff est "ambigu". En cas de navigation entre pages, cette logique génère des retries inutiles. Repensé via `skip_verify` aujourd'hui puis rollbacké. À revisiter.
### S3.4 — Recording VWB (PRIORITÉ MOYENNE)
- **S3.4.1** Le recording VWB doit générer automatiquement le `by_text` quand un élément textuel est cliqué (LLM caption ou OCR du voisinage de l'ancre). Sans ça, workflows fragiles (vérifié aujourd'hui : workflow recordé sans `by_text` → comportements aberrants).
- **S3.4.2** L'UI Propriétés VWB n'expose pas le champ `by_text` pour édition manuelle. À ajouter.
- **S3.4.3** Le champ `variable_name` du VWB accepte des templates `{{...}}` qui sont stockés littéralement (ex : `"{{t_motif_admission}}"`). Devrait soit refuser, soit stripper, soit clarifier le sens du champ.
- **S3.4.4** Le VWB n'a pas de validation de cohérence entre l'ordre du canvas et l'ordre BDD à la sauvegarde. Devrait soit refuser la sauvegarde si incohérent, soit recalculer.
- **S3.4.5** `extract_text_scroll` peut être créé dans le VWB sans `anchor_id`. Le runtime peut l'expanser via le patch d'aujourd'hui, mais comportement à valider.
- **S3.4.6** Hallucination VLM systématique sur les chiffres longs : "25003284" lu comme "250003284" (8 vs 9 chiffres). Probablement lié à `qwen2.5vl:3b` (modèle léger). Affecte la description d'ancre, pas le `target_text` OCR ni le `by_text`. Cosmétique mais visible.
### S3.5 — Synchronisation UI VWB ↔ Léa (PRIORITÉ BASSE)
- **S3.5.1** Le PauseDialog du VWB et la bulle Léa s'affichent **en parallèle** sur `pause_for_human`. Aucun n'a la priorité, le premier à répondre gagne. Confusion UX. Quatre options de fix identifiées (A/B/C/D), aucune implémentée aujourd'hui.
- **S3.5.2** La bulle Léa cache parfois ses boutons Continuer/Annuler sous le scroll. UX à fixer (boutons en sticky).
- **S3.5.3** Le bus SocketIO côté Léa est conditionné par la variable d'environnement `LEA_FEEDBACK_BUS=1`. Si non défini, le bus n'est pas instancié → `pause_for_human` ne peut pas reprendre via Léa. Comportement non documenté.
- **S3.5.4** Le service `agent_chat` (port 5004) n'est pas systématiquement démarré. Si absent, le bus reste déconnecté même avec `LEA_FEEDBACK_BUS=1`. À automatiser.
### S3.6 — Observabilité et traçabilité (PRIORITÉ MOYENNE)
- **S3.6.1** Le backend Flask VWB ne loggue pas les payloads des requêtes (uniquement werkzeug INFO). Impossible de reconstituer une session passée. Ajouter `@app.before_request` qui loggue body + URL en debug.
- **S3.6.2** Le champ `workflows.updated_at` n'est pas rafraîchi lors des modifications de steps. Trompeur. À corriger.
- **S3.6.3** `ReplayVerifier` est instancié globalement sans paramètres dans `api_stream.py:52`. Les seuils sont hardcodés dans `replay_verifier.py`, les env vars sont ignorées. Bug architectural à corriger.
- **S3.6.4** Le canvas VWB affiche le badge `step.order + 1` (`StepNode.tsx:104`). Bug cosmétique qui crée une divergence entre les badges visibles et l'order BDD. Convention à uniformiser (0-based ou 1-based partout).
---
## 6. Plan de reprise demain matin
### Priorité 1 — Observabilité minimale (15 min)
Ajouter dans `grounding.py:152` un log qui affiche `win_info["title"]` et `win_info["app_name"]`. Redéployer Léa. Au prochain run, on saura précisément quelle fenêtre Windows est ramassée à tort.
### Priorité 2 — Sanity check window-detection (30 min)
Dans `_get_window_rect_windows()` ou dans le caller : rejeter les fenêtres dont le ratio `width/height > 5` ou hauteur `< 400 px`. Fallback : écran entier. Permet de débloquer le runtime sans refonte profonde.
### Priorité 3 — Unification observation ↔ replay (chantier de fond, plusieurs jours)
Identifier dans le code de l'apprentissage par observation les fonctions de détection de fenêtre par titre/process. Les exposer en API interne. Refactor du replay pour les consommer. **C'est le vrai chantier.**
---
## 7. Choses qui marchent bien (à préserver)
- Le diagnostic SQL sur la BDD `workflows.db` (commandes `sqlite3` directes ont permis de comprendre rapidement l'état du workflow).
- La méthode "investigation lecture seule puis décision" appliquée plusieurs fois aujourd'hui.
- Le système de backups binaires de la BDD avant chaque modif structurelle.
- La séparation Linux (VWB / streaming / Ollama) et Windows (Léa) qui a permis d'isoler les fixes.
- Le watch SQL en monitoring pendant le recording (à utiliser systématiquement à l'avenir).
---
## 8. Choses à NE PAS faire demain
- Reprendre les diagnostics où on en est ce soir. **Repartir frais.**
- Tenter un fix sans avoir d'abord rétabli l'observabilité (S3.6).
- Modifier le code Léa Windows et le code Linux dans le même incrément sans tester chaque étape.
- Repousser les nettoyages S3 "encore un peu" → ils sont la cause des problèmes d'aujourd'hui.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,761 @@
// =====================================================================
// Maquette Easily Assure — moteur de rendu (data-driven)
// =====================================================================
// - Lit le dossier ciblé via ?id=XXXXX (par défaut : premier ORDRE_DOSSIERS)
// - Injecte le bandeau patient, les 4 onglets et la page de codage
// - Volontairement sans dépendance pour rester portable Windows / Linux
// =====================================================================
(function () {
// ----- Helpers -----
function qs(name) {
const m = new RegExp('[?&]' + name + '=([^&]+)').exec(location.search);
return m ? decodeURIComponent(m[1]) : null;
}
function el(id) { return document.getElementById(id); }
function setHTML(id, html) { const e = el(id); if (e) e.innerHTML = html; }
function escapeAttr(s) { return String(s).replace(/"/g, '&quot;'); }
// ID actif (commun à toutes les pages)
const idActif = qs('id') || (typeof ORDRE_DOSSIERS !== 'undefined' ? ORDRE_DOSSIERS[0] : null);
const dossier = (typeof DOSSIERS !== 'undefined' && idActif) ? DOSSIERS[idActif] : null;
// Bouton "Reset" du header — exposé tôt pour rester accessible même
// sur index.html (où l'on quitte la IIFE par early return ligne 50).
// resetCodage est une function declaration : hoistée dans la IIFE.
window.resetCodage = resetCodage;
// ----- Page : index.html (liste patients) -----
const tbody = el('liste-patients');
if (tbody && typeof ORDRE_DOSSIERS !== 'undefined') {
const lignes = ORDRE_DOSSIERS.map(ipp => {
const d = DOSSIERS[ipp];
const enAttente = d.statut_attente;
const statutHtml = enAttente
? '<span style="color:#b25000; font-weight:bold;">En attente</span>'
: '<span style="color:#2e7d32; font-weight:bold;">' + d.passage.statut + '</span>';
return `
<tr class="row-clickable" onclick="window.location.href='dossier.html?id=${ipp}'">
<td><a href="dossier.html?id=${ipp}" class="link">${ipp}</a></td>
<td>${d.identite.nom}</td>
<td>${d.identite.prenom}</td>
<td>${d.identite.ne_le}</td>
<td>${d.passage.arrivee}</td>
<td>${d.passage.motif_court}</td>
<td>${d.passage.medecin}</td>
<td>${statutHtml}</td>
</tr>`;
}).join('');
tbody.innerHTML = lignes;
}
// Si pas de dossier ciblé sur dossier.html / codage.html, on s'arrête là
if (!dossier) {
return;
}
// ----- Bandeau patient (commun dossier.html / codage.html) -----
const banner = el('patient-banner');
if (banner) {
const id = dossier.identite;
const p = dossier.passage;
banner.innerHTML = `
<div class="ipp">IPP : ${id.ipp}</div>
<div class="nom">${id.nom} ${id.prenom}</div>
<div class="info-bloc">Né(e) le <b>${id.ne_le}</b> &nbsp;|&nbsp; <b>${id.age}</b> &nbsp;|&nbsp; Sexe : <b>${id.sexe}</b></div>
<div class="info-bloc">Arrivée : <b>${p.arrivee}</b> &nbsp;|&nbsp; IAO : <b>${p.iao} (${p.iao_heure})</b></div>
<div class="info-bloc">Médecin : <b>${p.medecin}</b> &nbsp;|&nbsp; Sortie : <b>${p.sortie}</b></div>
`;
}
// Bandeau attente
if (dossier.statut_attente) {
const att = el('bandeau-attente');
if (att) att.style.display = '';
document.title = 'Dossier ' + dossier.identite.ipp + ' (en attente) — Easily Assure';
} else {
document.title = 'Dossier ' + dossier.identite.ipp + ' — '
+ dossier.identite.nom + ' ' + dossier.identite.prenom + ' — Easily Assure';
}
// Liens internes : transmettre l'?id=
['lien-codage', 'tab-vers-codage', 'btn-retour-dossier', 'btn-vers-dossier',
'lien-dossier', 'tab-vers-dossier'].forEach(idLien => {
const e = el(idLien);
if (e && e.tagName === 'A') {
const base = e.getAttribute('href').split('?')[0];
e.setAttribute('href', base + '?id=' + idActif);
}
});
// =================================================================
// PAGE DOSSIER : injection des onglets (5 onglets : Motif d'admission,
// Examens cliniques, Imagerie, Notes médicales, Synthèse Urgences)
// =================================================================
if (el('tab-motif')) {
renderMotif(dossier);
renderExamens(dossier);
renderImagerie(dossier);
renderNotesMed(dossier);
renderSynthese(dossier);
installTabs();
}
// =================================================================
// PAGE CODAGE : injection des 3 critères + verdict
// =================================================================
if (el('dpi-input')) {
installAiva(dossier);
}
// -----------------------------------------------------------------
// Onglet 1 : Motif & IDE
// -----------------------------------------------------------------
function renderMotif(d) {
const m = d.motif;
const diagRows = (m.diagnostics.length ? m.diagnostics : [{
code: '<i>Aucun diagnostic enregistré.</i>', type: '', date: '', par: ''
}]).map(diag => `
<tr>
<td><a class="link">${diag.code}</a> ${diag.type ? '&nbsp;-&nbsp; ' + diag.type : ''}</td>
<td style="width:180px;">${diag.date}</td>
<td style="width:200px;">${diag.par}</td>
</tr>`).join('');
// Nb colonnes dynamique : on itère sur signes_vitaux_dates pour gérer
// les dossiers riches (jusqu'à 7 colonnes pour 25151530, 5 pour 25005866 et 25048485, 4 pour 25003364 et 25012257).
const nSvCols = (m.signes_vitaux_dates || []).length || 2;
const sv = m.signes_vitaux.length
? m.signes_vitaux.map(l => {
const cells = [];
for (let i = 1; i <= nSvCols; i++) {
cells.push(`<td class="num">${l['v' + i] !== undefined ? l['v' + i] : ''}</td>`);
}
return `<tr><td>${l.item}</td>${cells.join('')}</tr>`;
}).join('')
: `<tr><td colspan="${nSvCols + 1}" style="color:#888;"><i>Aucun signe vital enregistré.</i></td></tr>`;
const svHeaders = (m.signes_vitaux_dates || []).map(d => `<th>${d || ''}</th>`).join('') || '<th></th><th></th>';
setHTML('tab-motif', `
<div class="section">
<div class="section-header">Motif de la venue</div>
<div class="section-body">
<table class="data">
<tr>
<td style="width:230px;">Symptômes à l'orientation :<br>${m.symptomes_orientation}</td>
<td style="width:200px;">${m.symptomes_date}</td>
<td>${m.symptomes_par}</td>
</tr>
</table>
</div>
</div>
<div class="section">
<div class="section-header">Observations IDE</div>
<div class="section-body">
<table class="data">
<thead><tr>
<th style="width:60%;">Description</th>
<th style="width:20%;">Date</th>
<th>Dernière modif par</th>
</tr></thead>
<tbody><tr>
<td style="white-space:pre-line; line-height:1.5;">${m.obs_ide}</td>
<td>${m.obs_ide_date}</td>
<td>${m.obs_ide_par}</td>
</tr></tbody>
</table>
</div>
</div>
<div class="section">
<div class="section-header">Diagnostics</div>
<div class="section-body">
<table class="data">${diagRows}</table>
</div>
</div>
<div class="section">
<div class="section-header">Signes vitaux - résumé</div>
<div class="section-body">
<table class="data">
<thead><tr>
<th>Item de surveillance</th>
${svHeaders}
</tr></thead>
<tbody>${sv}</tbody>
</table>
</div>
</div>
`);
}
// -----------------------------------------------------------------
// Onglet 2 : Examens cliniques
// (Notes paramédicales : déplacées dans l'onglet Notes médicales —
// review Pauline 04/05/2026)
// -----------------------------------------------------------------
function renderExamens(d) {
const e = d.examens;
const qRows = e.questionnaires.length ? e.questionnaires.map(q => `
<tr>
<td><a class="link">${q.nom}</a></td>
<td class="num">${q.score}</td>
<td style="white-space:pre-line; line-height:1.6;">${q.reponse}</td>
<td>${q.etat}</td>
<td>${q.derniere_modif}</td>
<td></td>
</tr>`).join('')
: `<tr><td colspan="6" style="color:#888;"><i>Aucun questionnaire enregistré.</i></td></tr>`;
const avis = e.avis_specialises.length
? e.avis_specialises.map(a => `<div>${a}</div>`).join('')
: `<p style="color:#888; margin-top:8px;"><i>Aucun avis spécialisé enregistré.</i></p>`;
setHTML('tab-examens', `
<div class="section">
<div class="section-header">Examens Cliniques</div>
<div class="section-body">
<table class="data">
<thead><tr>
<th>Questionnaire</th><th>Score</th>
<th style="width:50%;">Réponse significative 1</th>
<th>Etat</th><th>Dernière modification le</th><th>Motif de suppression</th>
</tr></thead>
<tbody>${qRows}</tbody>
</table>
</div>
</div>
<div class="section">
<div class="section-header">Avis Spécialisés</div>
<div class="section-body">
<a class="link">Recherche</a>
${avis}
</div>
</div>
`);
}
// -----------------------------------------------------------------
// Onglet 3 : Imagerie (review Pauline 04/05/2026 — nouveau)
// Comptes-rendus de scan, radio, échographie regroupés ici
// pour une recherche rapide dédiée.
// -----------------------------------------------------------------
function renderImagerie(d) {
const items = (d.imagerie || []);
const body = items.length
? items.map(im => `
<div style="margin-top:10px; padding:10px; background:#fff; border:1px solid #d0d8e0;">
<div style="margin-bottom:6px;">
<b>${im.date || ''}</b> &nbsp;
<a class="link">${im.type || 'Imagerie'}</a> &nbsp;
<span>noté par <b>${im.par || ''}</b>${im.role ? ' (' + im.role + ')' : ''}${im.horodatage ? ', ' + im.horodatage : ''}</span>
</div>
<div style="white-space:pre-line; line-height:1.6; margin-left:6px;">${im.contenu || ''}</div>
</div>`).join('')
: `<p style="color:#888;"><i>Aucun examen d'imagerie enregistré pour ce passage.</i></p>`;
setHTML('tab-imagerie', `
<div class="section">
<div class="section-header">Comptes-rendus d'imagerie (radio, scan, échographie)</div>
<div class="section-body">
<a class="link">Recherche</a>
${body}
</div>
</div>
`);
}
// -----------------------------------------------------------------
// Onglet 4 : Notes médicales
// Inclut désormais les Notes paramédicales (review Pauline 04/05/2026 :
// déplacées depuis l'onglet Examens cliniques pour regrouper toute la
// rédaction soignante au même endroit).
// -----------------------------------------------------------------
function renderNotesMed(d) {
const blocs = d.notes_medicales.length
? d.notes_medicales.map(n => `
<div class="note-bloc" style="margin-top:10px; padding:10px; background:#fff; border:1px solid #d0d8e0;">
<div><b>${n.date}</b> &nbsp; <a class="link">${n.type}</a> &nbsp; noté par <b>${n.par}</b> (${n.role}), ${n.horodatage}</div>
<div style="white-space:pre-line; line-height:1.6; margin-top:6px;">${n.contenu}</div>
</div>`).join('')
: `<p style="color:#888;"><i>Aucune note médicale enregistrée.</i></p>`;
const para = ((d.examens && d.examens.notes_paramedicales) || []);
const paraBlocs = para.length
? para.map(n => `
<div class="note-bloc" style="margin-top:10px; padding:10px; background:#fff; border:1px solid #d0d8e0;">
<div><b>${n.date}</b> &nbsp; <a class="link">${n.type}</a> &nbsp; noté par <b>${n.par}</b> (${n.role}), ${n.horodatage}</div>
<div style="white-space:pre-line; line-height:1.6; margin-top:6px;">${n.contenu}</div>
</div>`).join('')
: `<p style="color:#888;"><i>Aucune note paramédicale enregistrée.</i></p>`;
setHTML('tab-notes', `
<div class="section">
<div class="section-header">Notes médicales</div>
<div class="section-body">
<a class="link">Recherche</a>
${blocs}
</div>
</div>
<div class="section">
<div class="section-header">Notes paramédicales</div>
<div class="section-body">
<a class="link">Recherche</a>
${paraBlocs}
</div>
</div>
`);
}
// -----------------------------------------------------------------
// Onglet 4 : Synthèse Urgences
// -----------------------------------------------------------------
function renderSynthese(d) {
const s = d.synthese;
function txt(v) { return `<input type="text" value="${escapeAttr(v || '')}">`; }
function dateHeure(date, heure) {
return `<div style="display:flex; gap:6px; align-items:center; padding:0 6px;">
<input type="text" value="${escapeAttr(date || '')}" style="max-width:120px;">
<span>à</span>
<input type="text" value="${escapeAttr(heure || '')}" style="max-width:80px;">
</div>`;
}
function ta(v) { return `<textarea>${escapeAttr(v || '')}</textarea>`; }
setHTML('tab-synthese', `
<div class="synthese-titre">Synthèse Urgences</div>
<table>
<tr class="row-titre"><td colspan="2">Détails de l'épisode</td></tr>
<tr><td class="label">Episode - Date</td><td class="value">${dateHeure(s.episode_date, s.episode_heure)}</td></tr>
<tr><td class="label">Mode de transport à l'arrivée</td><td class="value">${txt(s.mode_transport)}</td></tr>
<tr><td class="label">Médicalisation du transport</td><td class="value">${txt(s.medicalisation_transport)}</td></tr>
<tr><td class="label">Mode d'entrée</td><td class="value">${txt(s.mode_entree)}</td></tr>
<tr><td class="label">Origine du transfert</td><td class="value">${txt(s.origine_transfert)}</td></tr>
</table>
<table>
<tr class="row-titre"><td colspan="2">Détails de l'orientation aux Urgences</td></tr>
<tr><td class="label">Date d'orientation</td><td class="value">${dateHeure(s.orientation_date, s.orientation_heure)}</td></tr>
<tr><td class="label">IAO</td><td class="value">${txt(s.iao)}</td></tr>
<tr><td class="label">Priorité</td><td class="value"><input type="text" value="${escapeAttr(s.priorite || '')}" style="max-width:300px;"></td></tr>
<tr><td class="label">Episode - Sous-type</td><td class="value">${txt(s.sous_type)}</td></tr>
<tr><td class="label">Circonstances</td><td class="value">${txt(s.circonstances)}</td></tr>
<tr><td class="label">Motif de prise en charge</td><td class="value">${ta(s.motif_pec)}</td></tr>
<tr><td class="label">Observ. IDE Urg</td><td class="value">${ta(s.obs_ide_urg)}</td></tr>
</table>
<table>
<tr class="row-titre"><td colspan="2">Détails de la prise en charge</td></tr>
<tr><td class="label">Médecin de la prise en charge médicale</td><td class="value">${txt(s.medecin_pec)}</td></tr>
<tr><td class="label">Date de la prise en charge médicale</td><td class="value">${dateHeure(s.pec_date, s.pec_heure)}</td></tr>
<tr><td class="label">CCMU</td><td class="value">${txt(s.ccmu)}</td></tr>
<tr><td class="label">GEMSA</td><td class="value">${txt(s.gemsa)}</td></tr>
<tr><td class="label">Diagnostics</td><td class="value">${ta(s.diagnostics_synthese)}</td></tr>
</table>
<table>
<tr class="row-titre"><td colspan="2">Décision médicale</td></tr>
<tr><td class="label">Médecin de la décision médicale</td><td class="value">${txt(s.medecin_decision)}</td></tr>
<tr><td class="label">Date de décision médicale</td><td class="value">${dateHeure(s.decision_date, s.decision_heure)}</td></tr>
<tr><td class="label">Décision médicale</td><td class="value">${txt(s.decision)}</td></tr>
<tr><td class="label">Orientation du patient</td><td class="value">${txt(s.orientation)}</td></tr>
<tr><td class="label">US de destination</td><td class="value">${txt(s.us_destination)}</td></tr>
</table>
<div style="text-align:right; margin-top:14px;">
<a href="codage.html?id=${idActif}" class="btn large">Coder &gt;</a>
</div>
`);
}
// -----------------------------------------------------------------
// Onglets dossier — pilotables aussi par #hash
// -----------------------------------------------------------------
function installTabs() {
const tabs = document.querySelectorAll('.tabs .tab[data-tab]');
if (!tabs.length) return;
function activate(target) {
tabs.forEach(t => t.classList.toggle('active', t.getAttribute('data-tab') === target));
document.querySelectorAll('.tab-content').forEach(c => {
c.style.display = (c.id === 'tab-' + target) ? '' : 'none';
});
}
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.getAttribute('data-tab');
history.replaceState(null, '', '#' + target + location.search);
activate(target);
window.scrollTo(0, 0);
});
});
// Sections collapsibles
document.querySelectorAll('.section-header').forEach(h => {
h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed'));
});
const initial = (location.hash || '#motif').slice(1).split('?')[0];
if (el('tab-' + initial)) activate(initial);
}
// -----------------------------------------------------------------
// AIVA-VISION — Aide à la décision de facturation urgences
//
// Le bouton "Analyser" envoie le DPI au backend Flask local
// (POST /api/analyse) qui appelle core.llm.t2a_decision (LLM
// qwen2.5:7b par défaut, ~5 s). Le backend enrichit la réponse
// avec verite_terrain + concordance.
//
// En cas d'erreur backend, fallback sur un rendu dérivé localement
// des champs data.js (utilise type_forfait, codage.preuves, etc.)
// pour garder la maquette démontrable hors-ligne.
// -----------------------------------------------------------------
function installAiva(d) {
const ipp = (d.identite && d.identite.ipp) || '';
// ===== 1) DPI vide au chargement =====
// (avant 2026-05-07 : pré-remplissage automatique via buildDpiResume.
// Pour la démo GHT, c'est Léa qui colle le DPI via Ctrl+V — la zone
// doit donc rester vide à l'ouverture. La fonction buildDpiResume
// reste disponible pour le bouton "Reset" et un usage futur.)
const dpiInput = el('dpi-input');
if (dpiInput) dpiInput.value = '';
// ===== 2) État initial : panneau résultat visible avec valeurs neutres
// (textarea Justification éditable AVANT analyse — Léa peut
// y écrire sa propre justification que l'analyse respectera).
// =========================================================
const loading = el('aiva-result-loading');
const result = el('aiva-result');
if (loading) loading.classList.add('aiva-result-hidden');
if (result) result.classList.remove('aiva-result-hidden');
setNeutralState();
// ===== 3) Suppression du bouton Analyser : analyse auto-déclenchée
// - sur paste (Léa colle le DPI via Ctrl+V)
// - sur blur (humain quitte la zone)
// - sur Ctrl+Entrée (raccourci clavier)
// =========================================================
const btn = el('btn-analyser');
if (btn) btn.remove();
let isRunning = false;
let lastAnalysed = '';
let pasteTimer = null;
async function triggerAnalyse() {
const dpi = (dpiInput && dpiInput.value || '').trim();
if (!dpi || isRunning || dpi === lastAnalysed) return;
isRunning = true;
lastAnalysed = dpi;
// Le panneau résultat reste visible (avec ses valeurs neutres ou
// précédentes) — on superpose le spinner par-dessus pour signaler
// l'analyse en cours sans cacher la justification utilisateur.
if (loading) loading.classList.remove('aiva-result-hidden');
try {
const resp = await fetch('/api/analyse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dpi: dpi, ipp: ipp })
});
const json = await resp.json();
if (!resp.ok) {
throw new Error(json.error || ('HTTP ' + resp.status));
}
renderBackendResult(json);
} catch (err) {
console.warn('Backend /api/analyse indisponible, fallback local :', err);
renderFallback(d);
} finally {
if (loading) loading.classList.add('aiva-result-hidden');
isRunning = false;
}
}
if (dpiInput) {
// Paste → debounce 300ms → analyse
dpiInput.addEventListener('paste', () => {
clearTimeout(pasteTimer);
pasteTimer = setTimeout(triggerAnalyse, 300);
});
// Blur → analyse si contenu changé
dpiInput.addEventListener('blur', triggerAnalyse);
// Ctrl/Cmd+Entrée → analyse immédiate
dpiInput.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
triggerAnalyse();
}
});
}
// ===== 4a) Rendu du résultat backend (LLM réel) =====
function renderBackendResult(r) {
const isUhcd = (r.decision === 'REQUALIFICATION_HOSPITALISATION');
const banner = el('aiva-decision-banner');
banner.className = isUhcd ? 'banner-uhcd' : 'banner-forfait';
banner.textContent = isUhcd
? 'REQUALIFICATION HOSPITALISATION (UHCD)'
: 'FORFAIT URGENCES (FFU/ATU)';
const valor = el('aiva-valorisation');
valor.innerHTML = isUhcd
? '→ valorisation séjour MCO court (UHCD ≥ 24 h, GHM)'
: '→ valorisation forfaitaire (30-200 €)';
const vt = el('aiva-verite-terrain');
if (vt) {
if (r.verite_terrain) {
const cls = r.concordance ? 'concordance-ok' : 'concordance-ko';
const txt = r.concordance ? 'concordance OK' : '⚠ écart vérité-terrain';
vt.style.display = '';
vt.innerHTML = 'Vérité-terrain : <code>' + r.verite_terrain + '</code> — '
+ '<span class="' + cls + '">' + txt + '</span>';
} else {
vt.style.display = 'none';
}
}
el('aiva-confiance').textContent = r.confiance || '—';
const h = r.duree_passage_heures;
el('aiva-duree').textContent = (typeof h === 'number') ? (h.toFixed(1) + ' h') : '—';
const s = r._elapsed_s;
el('aiva-latence').textContent = (typeof s === 'number') ? (s.toFixed(1) + ' s') : '—';
// #aiva-justification est une textarea éditable : si l'utilisateur
// (Léa ou humain) y a déjà écrit avant le clic Analyser, on
// PRÉSERVE sa valeur — elle a la priorité sur la justification LLM.
const justifEl = el('aiva-justification');
if (justifEl && !justifEl.value.trim()) {
justifEl.value = r.justification || '';
}
renderBackendList('aiva-elements-hospi', r.elements_pour_hospitalisation);
renderBackendList('aiva-elements-forfait', r.elements_pour_forfait);
}
function renderBackendList(id, items) {
const ul = el(id);
if (!ul) return;
const arr = Array.isArray(items) ? items : [];
if (!arr.length) {
ul.innerHTML = '<li style="color:#888; font-style:italic;">Aucun élément cité par le LLM.</li>';
} else {
ul.innerHTML = arr.map(t => '<li>' + escapeHtml(String(t)) + '</li>').join('');
}
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ===== 4b) Fallback hors-ligne (dérivé local) =====
function renderFallback(d) {
const cod = d.codage || {};
const typeForfait = cod.type_forfait || null;
const isForfait = typeForfait !== null;
const banner = el('aiva-decision-banner');
const valor = el('aiva-valorisation');
if (isForfait) {
let label = 'FORFAIT URGENCES (FFU/ATU)';
if (typeForfait === 'PE2') label = 'FORFAIT URGENCES — PE2 (pédiatrique)';
else if (typeForfait === 'SU2') label = 'FORFAIT URGENCES — SU2 (acte CCAM)';
banner.className = 'banner-forfait';
banner.textContent = label;
valor.innerHTML = '→ valorisation forfaitaire (30-200 €)';
} else {
banner.className = 'banner-uhcd';
banner.textContent = 'REQUALIFICATION HOSPITALISATION MCO';
valor.innerHTML = '→ valorisation séjour MCO court (UHCD ≥ 24 h, GHM)';
}
const vt = el('aiva-verite-terrain');
const vtCode = isForfait ? 'FORFAIT_URGENCE' : 'REQUALIFICATION_HOSPITALISATION';
vt.innerHTML = 'Vérité-terrain : <code>' + vtCode + '</code> — '
+ '<span class="concordance-ok">concordance OK</span> '
+ '<span style="color:#b25000; font-size:11px;">(mode hors-ligne)</span>';
el('aiva-confiance').textContent = 'elevee';
el('aiva-duree').textContent = computeDureeHeures(d) + ' h';
el('aiva-latence').textContent = '— (offline)';
// #aiva-justification est désormais une textarea : on retire les balises
// HTML (ex. <b>) renvoyées par buildJustification() avant l'affectation.
// Idem renderBackendResult : préserver la saisie utilisateur si déjà remplie.
const justifEl = el('aiva-justification');
if (justifEl && !justifEl.value.trim()) {
const justifHtml = buildJustification(d, isForfait);
justifEl.value = justifHtml.replace(/<[^>]+>/g, '');
}
renderBullets('aiva-elements-hospi', extractBullets(cod.critere1_preuves).concat(extractBullets(cod.critere2_preuves)));
renderBullets('aiva-elements-forfait', extractBullets(cod.critere3_preuves));
}
}
// -------- Helpers de dérivation --------
function buildDpiResume(d) {
// Texte clinique synthétique pour la zone gauche : motif + obs IDE
// + diagnostic principal + 1 note médicale clé + récap décisionnel.
const id = d.identite, p = d.passage, m = d.motif;
const lines = [];
const sexeMotMr = id.sexe === 'F' ? 'Mme' : 'M.';
lines.push(sexeMotMr + ' ' + (id.nom || '').slice(0,1) + '. (' + (id.age || 'âge non précisé') + '), passage urgences ' + (p.arrivee || ''));
lines.push('Motif : ' + (p.motif_court || ''));
if (m.obs_ide) {
lines.push('');
lines.push('Observations IDE :');
lines.push(m.obs_ide);
}
if (m.diagnostics && m.diagnostics.length) {
lines.push('');
lines.push('Diagnostic : ' + m.diagnostics[0].code + ' (' + (m.diagnostics[0].par || '') + ').');
}
// Première note médicale (Conclusion ou Histoire de la maladie)
const conclusion = (d.notes_medicales || []).find(n => /Conclusion/i.test(n.type)) ||
(d.notes_medicales || [])[0];
if (conclusion) {
lines.push('');
lines.push((conclusion.type || 'Note') + ' (' + (conclusion.par || '') + ') :');
// Strip tags HTML du contenu pour la zone DPI texte plat
const text = (conclusion.contenu || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ');
lines.push(text);
}
// Synthèse décisionnelle
if (d.synthese) {
const s = d.synthese;
lines.push('');
lines.push('CCMU ' + (s.ccmu || '').slice(0, 1) + ' / GEMSA ' + (s.gemsa || '').slice(0, 1)
+ '. Décision : ' + (s.decision || '') + (s.orientation ? ' — ' + s.orientation : '') + '.');
}
return lines.join('\n');
}
function computeDureeHeures(d) {
// Cherche la ligne "Durée totale du passage" dans recap_rpu et extrait
// le format "XhYY" pour le convertir en heures décimales.
const recap = (d.codage && d.codage.recap_rpu) || [];
const ligne = recap.find(([k]) => /durée/i.test(k));
if (!ligne) return '—';
const m = String(ligne[1]).match(/(\d+)\s*h\s*(\d+)?/i);
if (!m) return '—';
const h = parseInt(m[1], 10);
const min = m[2] ? parseInt(m[2], 10) : 0;
return (h + min / 60).toFixed(1);
}
function buildJustification(d, isForfait) {
// Construit une phrase de justification courte, à partir du diagnostic + CCMU.
const m = d.motif, s = d.synthese || {};
const diag = (m.diagnostics && m.diagnostics[0] && m.diagnostics[0].code) || 'diagnostic à préciser';
const ccmu = (s.ccmu || '').slice(0, 1);
if (isForfait) {
return 'Le passage est conforme à un <b>forfait urgences</b> : '
+ 'CCMU ' + ccmu + ', état stable à la sortie, prise en charge ambulatoire (' + diag + '). '
+ 'Pas de critère d\'hospitalisation continue justifié sur l\'ensemble des 3 axes (pathologie évolutive / surveillance / actes).';
}
return 'Les 3 critères UHCD sont réunis : pathologie évolutive avec risque d\'aggravation '
+ '(CCMU ' + ccmu + '), surveillance médicale et paramédicale rapprochée, et examens/actes '
+ 'complémentaires réalisés (' + diag + '). <b>Requalification en hospitalisation MCO court séjour.</b>';
}
function extractBullets(html) {
// Convertit les preuves HTML (<br>• item<br>) en tableau de strings
// nettoyés (sans tags). Évite les bullets vides.
if (!html) return [];
const text = String(html)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
return text.split('\n')
.map(s => s.replace(/^\s*[•·\-]\s*/, '').trim())
.filter(s => s && !/^Preuves|^Actes (diagnostiques|thérapeutiques) ?:?$/i.test(s));
}
function renderBullets(targetId, items) {
const ul = el(targetId);
if (!ul) return;
if (!items.length) {
ul.innerHTML = '<li style="color:#888; font-style:italic;">Aucun élément retenu.</li>';
return;
}
ul.innerHTML = items.map(s => '<li>' + s + '</li>').join('');
}
// -----------------------------------------------------------------
// Reset démo (bouton "Reset" du header)
//
// Vide la zone DPI et le résultat aiva-vision pour pouvoir rejouer
// la démo Léa plusieurs fois sans recharger la page. Exposé sur
// window pour rester accessible depuis l'attribut onclick="" du
// bouton injecté dans le header de chaque page.
// -----------------------------------------------------------------
function resetCodage() {
// Zone DPI (page Codage uniquement)
const dpiInput = el('dpi-input');
if (dpiInput) {
dpiInput.value = '';
dpiInput.focus();
}
// Panneau résultat → reste VISIBLE, repasse à l'état neutre
// (textarea Justification éditable comme au chargement initial).
const loading = el('aiva-result-loading');
const result = el('aiva-result');
if (loading) loading.classList.add('aiva-result-hidden');
if (result) result.classList.remove('aiva-result-hidden');
setNeutralState();
}
window.resetCodage = resetCodage;
// -----------------------------------------------------------------
// setNeutralState — remet le panneau #aiva-result à l'état "en
// attente d'analyse" (banner gris, métriques —, listes vides,
// textarea Justification vide). Utilisé à l'init et au Reset pour
// garantir que le textarea Justification est éditable AVANT toute
// analyse.
// -----------------------------------------------------------------
function setNeutralState() {
const banner = el('aiva-decision-banner');
if (banner) {
banner.className = 'banner-empty';
banner.textContent = "— En attente d'analyse —";
}
const valor = el('aiva-valorisation');
if (valor) valor.innerHTML = '&nbsp;';
const vt = el('aiva-verite-terrain');
if (vt) vt.innerHTML = 'Vérité-terrain : <code>—</code>';
['aiva-confiance', 'aiva-duree', 'aiva-latence'].forEach(idMetric => {
const e = el(idMetric);
if (e) e.textContent = '—';
});
// #aiva-justification : textarea vide et éditable
const justif = el('aiva-justification');
if (justif) {
if ('value' in justif) justif.value = '';
justif.textContent = '';
}
// Listes éléments → placeholder neutre
const placeholder = '<li class="aiva-list-empty">Aucun élément pour le moment</li>';
['aiva-elements-hospi', 'aiva-elements-forfait'].forEach(idList => {
const ul = el(idList);
if (ul) ul.innerHTML = placeholder;
});
}
})();

View File

@@ -0,0 +1,729 @@
# Audit complet — Chaîne décision t2a (démo GHT Sud 95)
**Date** : 2026-05-12
**Branche** : `feature/qw-suite-mai`
**Workflow audité** : `Demo_urgence_2` (id `wf_d04d2dc7c118_1778493082`)
**Sauvegardes** : `docs/handoffs/2026-05-12_pre_top_decision_{workflows.db,t2a_decision.py,replay_engine.py}`
**Sources officielles archivées** : `docs/clients/ght_sud_95/referentiels_officiels/` (12 PDF)
---
## TL;DR — 6 trouvailles capitales
1. **La règle "2/3 critères ⇒ UHCD" est officiellement FAUSSE.** Source primaire : Instruction DGOS/R1/DSS/1A/2020/52 Annexe 3 + Guide ATIH MCO 2025 et 2026 §1.2. Vraie règle : **conjonction ET (3/3 cumulatifs)** appréciée AVANT admission UHCD. La règle 2/3 expose à requalification CPAM.
2. **Aucun seuil de durée opposable en droit français.** Le "6h" évoqué (DIM sénior) relève du folklore / Clinical Decision Units anglo-saxonnes. DMS cible < 24h, tolérance jusqu'à 36h/2 nuits (recommandation SFMU, pas opposable).
3. **🔴 TWIST MOREL Catherine** : selon règle officielle, les 3 critères cumulatifs sont remplis ⇒ **UHCD légalement justifiable (~375€)**. La VT actuelle `server.py:32` (FORFAIT) est probablement à inverser. Le vrai bug = mode_sortie codé "Consultation externe" au lieu de "Domicile" (mode 8) attendu pour UHCD ⇒ perte de recettes détectable, **VRAI ROI démo**.
4. **"FF1/FF2/FF3" n'existent PAS** dans la nomenclature officielle. Vraie nomenclature : **FU0/FU1/FU2/FU3/FU4** (forfaits âge) + suppléments **SU2/SU3, PE1/PE2, SUB/SB2/SB3, SIM/SIC, SUN/SUF, SUM, SAS, SSN/SSF**.
5. **PE2 ≠ matériel pédiatrique** : c'est un supplément clinique pédiatrique (diagnostic CIM-10 de la liste 2 annexe 19), pas un forfait matériel.
6. **5 causes racines bugs MOREL** : règle PMSI mal formulée (1) + non-déterminisme Ollama T=0.1 sans seed (2) + confusion durée_symptômes/passage (3) + pas de verrou cohérence step 17 (4) + listes hospi/forfait non mutuellement exclusives (5).
---
## Rapport 1 — DIM sénior (audit clinique PMSI)
### Verdict global
- **PROMPT 1** (t2a_decision) : **5/10**. Règle 2/3 fausse, durée sous-spécifiée, critère décisif (mode sortie) manquant, listes hospi/forfait non contraintes.
- **PROMPT 2** (Résumé) : **7/10**. Globalement bon, 3 lignes à ajouter.
- **PROMPT 3** (Justification) : **4/10**. Verrouillage insuffisant, pas d'auto-vérification, conflit de cardinalité décision/preuves.
### Diagnostic MOREL (lecture stricte avant validation référentiel officiel)
- Critère 1 (pathologie évolutive) : OUI (77 ans, insuf coronarienne, asthme, fièvre 39.4°C, peakflow 260, tachycardie 117)
- Critère 2 (surveillance >6h) : NON selon DIM sénior (durée 3h37)
- Critère 3 (actes/examens) : OUI (radio, PCR VRS, bio, aérosols, antibio)
- **DIM sénior tranche FORFAIT** car durée < 6h + RAD ⇒ critère 2 invalidé ⇒ pas 2/3.
- **⚠️ Référentiel officiel postérieur (rapport 4) contredit** : le seuil 6h n'est pas opposable, donc critère 2 reste OUI (surveillance hospitalière documentée par aérosols ×3 + ATB initiée + monitoring) ⇒ 3/3 ⇒ UHCD.
### Amendements PROMPT 1 proposés par DIM
Ajouter avant la liste des critères :
```
RÈGLE ÉLIMINATOIRE — DURÉE ET MODE DE SORTIE :
- Si durée_passage < 6h ET mode sortie = "domicile/RAD" ⇒ FORFAIT_URGENCE,
sauf surveillance médicale continue documentée explicitement.
- Si durée_passage > 24h ⇒ hospitalisation conventionnelle (hors UHCD).
- "durée du passage" = horaire ADMISSION → horaire SORTIE.
N'EST PAS la durée des symptômes pré-hospitaliers (« douleurs depuis 23h » ≠ durée passage).
```
⚠️ **Cette règle éliminatoire ne doit PAS être adoptée telle quelle** car le seuil 6h n'a aucune base réglementaire. À retenir uniquement : la distinction durée_symptômes / durée_passage, et l'importance du mode de sortie.
### Amendements PROMPT 1 — ajouts dans INSTRUCTIONS STRICTES
```
- `elements_pour_hospitalisation` = éléments qui PROLONGENT le séjour ou imposent surveillance hospitalière
(ex: « scope continu », « réévaluation H+2 et H+4 », « transfert UHCD demandé »)
- `elements_pour_forfait` = éléments qui RACCOURCISSENT ou ORIENTENT vers la sortie
(ex: « RAD à H+3:30 », « constantes stabilisées », « sortie domicile avec ordonnance »)
- Une « sortie en consultation externe à 48h » est un argument POUR le forfait, jamais pour l'hospitalisation.
- decision_court DOIT être strictement couplé à decision (jamais "UHCD" + FORFAIT).
```
### Amendements PROMPT 2 — 3 lignes à ajouter
```
11. Mentionne explicitement la durée du passage aux urgences en heures et minutes (admission → sortie),
distincte de la durée des symptômes rapportés par le patient.
12. Cite explicitement le mode de sortie (retour à domicile, UHCD, mutation MCO, transfert) si présent au dossier.
13. Liste les actes techniques et examens complémentaires effectués (biologie, imagerie, gestes CCAM).
```
### Amendements PROMPT 3 — verrouillage anti-divergence
```
RÈGLES STRICTES :
1. La décision EST {{dec.decision}} ({{dec.decision_court}}). Tu ne la discutes pas, tu ne la nuances pas,
tu ne la contredis pas, même implicitement. Toute formulation suggérant la décision opposée est interdite.
2. Source unique : RESUME_CLINIQUE et PREUVES. N'invente aucun argument.
3. Rédige 4 à 7 phrases.
4. Structure imposée :
a) Phrase 1-2 : éléments en faveur de la décision (citer le résumé).
b) Phrase 3-4 : éléments contradictoires, EXPLICITEMENT minorés.
c) Phrase finale OBLIGATOIRE : "En conclusion, ce passage relève de {{dec.decision_court}} au regard de
[résumé en une phrase du critère décisif]."
5. Si FORFAIT_URGENCE : phrase finale doit contenir "forfait" et NE PAS contenir "UHCD"/"requalification"/"hospitalisation"
autrement qu'en négation explicite.
6. Si REQUALIFICATION_HOSPITALISATION : inversement.
7. Avant de répondre, relis ta dernière phrase et vérifie qu'elle est cohérente avec la décision imposée.
```
### Cas PE2/SU2 (4 dossiers démo)
Pipeline actuel ne les gère pas. Recommandation : branche déterministe CCAM en amont OU exclusion du scope démo.
---
## Rapport 2 — Ingénieur prompt (robustesse + verrou)
### Cause racine oscillation MOREL (4 runs / 2 décisions)
- T=0.1 + pas de seed + `format=json` + bruit OCR variable cross-runs
- Combo nécessaire pour déterminisme : `temperature=0, seed=42, top_p=1.0, top_k=0`
- **Geler `dpi_canonique` en variable de session** (SQLite `replay_state.variables`) pour tuer la variance OCR.
### Cause racine contradiction step 3 ("justifie l'UHCD" alors decision=FORFAIT)
Combinaison de :
- **A** (conflit prompt vs context) : secondaire
- **B** (séparation prompt/context dans `LLMActionHandler.generate_text`) : significative
- **C** (pas de clause de refus / loi de cardinalité contextuelle) : **cause structurelle**
Le LLM, dressé à produire un texte cohérent, **doit** émettre quelque chose. Si les preuves disent "hospitalisation" et la décision dit "forfait", il rationalise vers la masse de signaux (les preuves gagnent).
### PROMPT 1 réécrit complet (à coller dans `core/llm/t2a_decision.py`)
```
Tu es médecin DIM senior, expert PMSI/T2A urgences hospitalières en France. Tu produis une décision de
facturation auditable et reproductible.
# RÈGLES PMSI URGENCES
Deux décisions possibles, mutuellement exclusives :
- FORFAIT_URGENCE → libellé court "Forfait Urgences" → passage simple, sortie domicile, pas de soins continus.
- REQUALIFICATION_HOSPITALISATION → libellé court "UHCD" → séjour MCO requis.
Trois critères UHCD (ATIH) :
C1. Pathologie potentiellement évolutive (instabilité hémodynamique, terrain à risque, traitement nécessitant
adaptation).
C2. Surveillance médicale ou paramédicale prolongée (constantes itératives, observations IDE/médecin
répétées, durée de surveillance > 6 h).
C3. Examens complémentaires ou actes thérapeutiques significatifs (biologie, imagerie, sutures,
gestes techniques).
RÈGLE DE DÉCISION (algorithme, à appliquer LITTÉRALEMENT, dans cet ordre) :
1. Évalue C1, C2, C3 indépendamment à partir des citations littérales du DPI.
2. Compte N = nombre de critères validés à true.
3. Si N >= 2 → decision = "REQUALIFICATION_HOSPITALISATION", decision_court = "UHCD".
4. Sinon → decision = "FORFAIT_URGENCE", decision_court = "Forfait Urgences".
5. decision_court DOIT être strictement couplé à decision.
# DURÉE DU PASSAGE
duree_passage_heures = (heure de sortie OU heure de transfert) (heure d'admission OU heure d'arrivée).
- Cherche EXACTEMENT les champs d'admission/sortie du dossier (libellés "Admission", "Arrivée", "Entrée",
"Sortie", "Transfert", "Sortie effective").
- N'utilise JAMAIS l'heure d'un examen, d'une biologie, ou d'un soin comme borne.
- Si une borne manque → duree_passage_heures = null (NE PAS deviner).
- Ne confonds JAMAIS la durée des symptômes (anamnèse) avec la durée du séjour.
# CITATIONS
Chaque preuve_critereN doit contenir AU MOINS UNE citation littérale entre « » d'un fragment du DPI.
Pas de paraphrase silencieuse. Si critère non validé, cite ce qui MANQUE (ex: « Sortie à H+2 »).
# LISTES MUTUELLEMENT EXCLUSIVES
elements_pour_hospitalisation et elements_pour_forfait sont mutuellement exclusives. Un même fait ne peut
pas apparaître dans les deux. Un fait ne peut être dans elements_pour_hospitalisation QUE s'il argumente
pour REQUALIFICATION (ex: « surveillance scope 12 h », « bilan biologique répété »). Un fait orientant vers
la sortie (« retour domicile », « sortie consultation externe à 48 h ») doit aller dans elements_pour_forfait.
# ANTI-HALLUCINATION
Si une information manque, NE LA FABRIQUE PAS. Préfère null, [] ou "NON_RENSEIGNE". Toute valeur numérique
non présente dans le dossier est interdite.
# EXEMPLE 1 — FORFAIT (passage court, retour domicile)
DPI (extrait) : « Admission 14h12, motif: entorse cheville droite. Examen: œdème modéré, pas de douleur
osseuse. Rx: pas de fracture. Bandage. Sortie 16h05 retour domicile. »
Sortie attendue :
{"duree_passage_heures": 1.9, "elements_pour_hospitalisation": [],
"elements_pour_forfait": ["Sortie 16h05 retour domicile", "Rx: pas de fracture", "Bandage"],
"preuve_critere1": "Motif « entorse cheville droite », pas de terrain à risque cité. Pathologie bénigne
non évolutive. Critère non validé.", "critere1_valide": false,
"preuve_critere2": "Surveillance courte : « Admission 14h12 » → « Sortie 16h05 », durée 1.9 h. Pas
d'observation IDE itérative. Critère non validé.", "critere2_valide": false,
"preuve_critere3": "Un seul acte : « Rx: pas de fracture » et « Bandage ». Acte technique mineur isolé.
Critère non validé.", "critere3_valide": false,
"decision": "FORFAIT_URGENCE", "decision_court": "Forfait Urgences",
"justification": "Passage court 1.9 h avec « Sortie 16h05 retour domicile ». Aucun des 3 critères UHCD
validé. Forfait urgences applicable.", "confiance": "elevee"}
# EXEMPLE 2 — REQUALIFICATION (3 critères validés)
DPI (extrait) : « Admission 22h40, patient 78 ans, ATCD insuffisance cardiaque. Dyspnée stade IV. FC 124,
TA 95/58. ECG: AC/FA rapide. NT-proBNP 4800. Scope, O2 4L. Surveillance horaire. Transfert USIC 09h15. »
Sortie attendue :
{"duree_passage_heures": 10.6,
"elements_pour_hospitalisation": ["ATCD insuffisance cardiaque", "Dyspnée stade IV", "FC 124, TA 95/58",
"AC/FA rapide", "Scope, O2 4L", "Surveillance horaire", "Transfert USIC 09h15"],
"elements_pour_forfait": [],
"preuve_critere1": "Terrain à risque : « patient 78 ans, ATCD insuffisance cardiaque ». Instabilité :
« FC 124, TA 95/58 » et « AC/FA rapide ». Critère validé.", "critere1_valide": true,
"preuve_critere2": "Durée 10.6 h (« Admission 22h40 » → « Transfert USIC 09h15 ») avec « Scope, O2 4L »
et « Surveillance horaire ». Critère validé.", "critere2_valide": true,
"preuve_critere3": "Bilan : « ECG: AC/FA rapide », « NT-proBNP 4800 ». Examens significatifs orientant le
diagnostic. Critère validé.", "critere3_valide": true,
"decision": "REQUALIFICATION_HOSPITALISATION", "decision_court": "UHCD",
"justification": "3 critères UHCD validés. Terrain « 78 ans, ATCD insuffisance cardiaque » avec
décompensation aiguë, surveillance prolongée 10.6 h et transfert USIC. Séjour MCO justifié.",
"confiance": "elevee"}
# FORMAT DE SORTIE (JSON STRICT, ordre des clés OBLIGATOIRE — preuves AVANT décision)
{
"duree_passage_heures": <number|null>,
"preuve_critere1": "<2-3 phrases avec citation « ... »>",
"critere1_valide": <true|false>,
"preuve_critere2": "<2-3 phrases avec citation « ... »>",
"critere2_valide": <true|false>,
"preuve_critere3": "<2-3 phrases avec citation « ... »>",
"critere3_valide": <true|false>,
"elements_pour_hospitalisation": [<phrases littérales>],
"elements_pour_forfait": [<phrases littérales>],
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
"decision_court": "UHCD" | "Forfait Urgences",
"justification": "<2-3 phrases avec au moins une citation>",
"confiance": "elevee" | "moyenne" | "faible"
}
# DOSSIER PATIENT
{dpi}
```
⚠️ **À ré-arbitrer avec rapport 4** : la règle "N >= 2 ⇒ UHCD" de ce PROMPT 1 est cliniquement fausse selon les sources officielles. À remplacer par la règle cumulative ET (texte Section F du rapport 4).
### PROMPT 2 réécrit (DB workflow step 14)
```
Tu es médecin urgentiste senior. Tu produis un résumé clinique fidèle, propre et auditable du dossier
ci-dessous.
Tu ne prends AUCUNE décision de facturation. Tu ne produis AUCUN JSON. Tu ne fais AUCUNE déduction
diagnostique non explicitement présente dans le dossier.
RÈGLES STRICTES :
1. Source unique : le dossier ci-dessous. Tout fait non présent est interdit.
2. Bruit OCR : ignore menus, libellés d'interface, fragments isolés, doublons, lignes incohérentes.
3. Déduplication : si la même information apparaît plusieurs fois sous des formes proches, garde la plus
précise et lisible.
4. Conflit OCR : si deux passages donnent des valeurs incompatibles pour le même fait (ex: FC 110 vs FC 010),
écris la valeur la plus plausible suivie de "(à confirmer)".
5. Horaires : reproduis LITTÉRALEMENT les heures d'admission, sortie ou transfert (format HHhMM).
Ne paraphrase JAMAIS un horaire.
6. Constantes : reproduis les valeurs numériques telles quelles avec leur unité (FC en bpm, TA en mmHg,
SpO2 en %, T° en °C).
7. Style : français médical clair, fluide, sans puces, sans titres, sans JSON.
8. Longueur : 8 à 14 phrases.
9. Ordre clinique : motif d'admission → contexte/ATCD → examen et constantes → examens complémentaires →
traitements/gestes → évolution et orientation.
10. Si une section est absente, ne la mentionne pas.
11. Commence par le motif d'admission. Termine par l'orientation (sortie domicile, hospitalisation, transfert).
DOSSIER :
{context}
```
### PROMPT 3 réécrit (DB workflow step 17)
```
Tu es médecin DIM. Tu rédiges UNE justification PMSI courte qui défend une décision déjà prise. Tu n'as
PAS le droit de la changer.
<DECISION_IMPOSEE>
decision = {{dec.decision}}
decision_court = {{dec.decision_court}}
</DECISION_IMPOSEE>
<RESUME_CLINIQUE>
{{resume_patient}}
</RESUME_CLINIQUE>
<PREUVES>
C1 : {{dec.preuve_critere1}}
C2 : {{dec.preuve_critere2}}
C3 : {{dec.preuve_critere3}}
</PREUVES>
RÈGLES STRICTES :
1. Ta sortie DOIT défendre la décision = {{dec.decision}} et se terminer par une phrase finale citant
explicitement « {{dec.decision_court}} ».
2. Source unique : RESUME_CLINIQUE et PREUVES. N'invente AUCUN argument.
3. Pas de JSON, pas de liste à puces, pas de titre. Texte fluide, 4 à 7 phrases.
4. Structure :
- Phrase 1-2 : éléments en faveur de {{dec.decision}} (citations courtes).
- Phrase 3-4 : éléments contradictoires ou limitants, traités factuellement.
- Phrase finale : conclusion citant exactement « {{dec.decision_court}} ».
5. Si FORFAIT_URGENCE : explique pourquoi UHCD/hospitalisation n'est pas requise.
6. Si REQUALIFICATION_HOSPITALISATION : explique pourquoi le passage dépasse un simple forfait.
VERROU DE COHÉRENCE — OBLIGATOIRE :
Avant d'émettre, vérifie que ta dernière phrase contient littéralement « {{dec.decision_court}} » et défend
la décision imposée. Si tu détectes que les preuves contredisent gravement la décision imposée au point
que tu ne peux PAS rédiger une justification honnête, tu DOIS répondre EXACTEMENT et SEULEMENT cette ligne :
INCOHERENCE_DETECTEE: la décision {{dec.decision}} ne correspond pas aux preuves fournies, validation
humaine requise.
Sinon, rédige la justification. Aucun autre format de sortie n'est accepté.
```
### Paramètres Ollama recommandés
PROMPT 1 (déterminisme maximal) :
```
temperature: 0
top_p: 1.0
top_k: 0
repeat_penalty: 1.0
seed: 42
num_predict: 2000
num_ctx: 16384
keep_alive: "30m"
```
PROMPT 2 (un peu de fluidité) :
```
temperature: 0.2
top_p: 0.9
top_k: 40
seed: 42
num_predict: 800
```
PROMPT 3 (texte démonstratif court) :
```
temperature: 0.1
top_p: 0.9
top_k: 40
seed: 42
num_predict: 500
```
### Garde-fous serveur (à appliquer dans `_handle_t2a_decision_action`, replay_engine.py:1204)
```python
# Après result = analyze_dpi(...) :
# 1. Validation schéma minimal
required = {"decision", "decision_court", "critere1_valide", "critere2_valide",
"critere3_valide", "duree_passage_heures"}
if not required.issubset(result.keys()):
result["_schema_error"] = sorted(required - result.keys())
# 2. Coercion decision_court ↔ decision (single source of truth = decision)
MAP = {"FORFAIT_URGENCE": "Forfait Urgences",
"REQUALIFICATION_HOSPITALISATION": "UHCD"}
if result.get("decision") in MAP:
expected = MAP[result["decision"]]
if result.get("decision_court") != expected:
result["_coerced_decision_court"] = result.get("decision_court")
result["decision_court"] = expected
# 3. Règle 2/3 — recompute decision depuis les flags (À ADAPTER selon règle officielle ATIH)
n_valid = sum(bool(result.get(f"critere{i}_valide")) for i in (1, 2, 3))
expected_decision = ("REQUALIFICATION_HOSPITALISATION" if n_valid >= 3
else "FORFAIT_URGENCE") # ⚠️ Changé en 3/3 selon rapport 4
if result.get("decision") != expected_decision:
result["_rule_violation"] = {
"n_critere_valides": n_valid,
"decision_llm": result.get("decision"),
"decision_attendue": expected_decision,
}
result["decision"] = expected_decision
result["decision_court"] = MAP[expected_decision]
# 4. Détection contradiction justification ↔ decision (regex)
import re
justif = (result.get("justification") or "").lower()
if result["decision"] == "FORFAIT_URGENCE" and re.search(
r"\b(requalification|uhcd|hospitalis)", justif):
result["_justif_contradiction"] = True
if result["decision"] == "REQUALIFICATION_HOSPITALISATION" and re.search(
r"\bforfait\b", justif) and not re.search(r"dépasse|au[- ]delà", justif):
result["_justif_contradiction"] = True
# 5. Sanity check durée
duree = result.get("duree_passage_heures")
if duree is not None and (duree < 0 or duree > 72):
result["_duree_suspect"] = duree
# 6. Retry automatique 1 fois si contradiction grave
contradictions = any(k.startswith("_") and k not in ("_elapsed_s", "_model", "_eval_count")
for k in result)
if contradictions and not result.get("_retry"):
result_retry = analyze_dpi(dpi_text, model=..., seed=43)
result_retry["_retry"] = True
if not any(k.startswith("_rule_violation") for k in result_retry):
result = result_retry
```
### Coherence check optionnel pour step 17 (dans `_handle_llm_generate_action`)
```python
if action.parameters.get("coherence_check") == True:
imposed = (params.get("imposed_decision_court") or "").strip() # ex: "UHCD"
if imposed and imposed.lower() not in generated.lower():
generated_retry = handler.generate_text(prompt=prompt, context=context, ...)
if imposed.lower() in generated_retry.lower():
generated = generated_retry
else:
generated = (f"Décision retenue : {imposed}. "
f"Voir critères PMSI dans le détail technique. "
f"Justification automatique non concluante — révision humaine recommandée.")
```
### 3 actions prioritaires (effort/impact)
1. **(15 min)** Fixer `temperature=0, seed=42, top_p=1` dans `t2a_decision.py` → tue l'oscillation.
2. **(30 min)** Geler `dpi_canonique` en variable de session après première extraction.
3. **(1 h)** Remplacer PROMPT 1 + ajouter garde-fous serveur → tue les contradictions structurelles.
---
## Rapport 3 — Analyste données (chronologie + dates)
### Cohérence des 11 dossiers
- **Âges ↔ dates de naissance ↔ date passage** : OK pour tous les 11. Aucun off-by-one anniversaire.
- **Horaires arrivée→sortie** : toutes durées positives, cohérentes avec recap_rpu.
### Incohérences trouvées dans data.js
| # | Dossier | Ligne | Problème | Risque |
|---|---------|-------|----------|--------|
| a | MOREL 25003284 | 228 | « Terrain à risque : 78 ans » alors qu'âge = 77 ans | LLM peut halluciner âge |
| b | MOREL | 43, 178, 201 | « depuis 23h » répété 3× | Piège durée (hallucination 23h au lieu de 3h37) |
| c | PERRIN 25003475 | 1519-1524, 1549 | GEMSA 4 (hospitalisé) vs decision "RAD" vs us_destination "UC CONSULT.URGENCES" vs recap codage "hospitalisée UHCD" | Codage admin incohérent |
| d | DA SILVA 25151530 | 1064 vs 1184 | arrivee `03:25` vs episode_heure `03:00` (Δ 25 min) | 2 horaires admission différents pour LLM |
| e | PIRES 25056615 | 923, 940, 988, 1013, 1031 | Statut "Transfert" + « depuis 1 semaine » répété | Risque conversion 168h en durée_passage |
| f | LEROY 25003364 | 1253, 1358, 1360 | « depuis 15 jours » répété + episode 13:55 vs orientation 14:47 | Risque 360h en durée_passage |
| g | BRUNEL 25012257 | 1784, 1788, 1841, 1896 | « >48h », « >72h », « >24h », « depuis 10/01 », « depuis 30/09/2024 » | Densité maximale de marqueurs temporels |
| h | NGUYEN 25048485 | 788 | Note IDE « vu la nuit » sans heure | Information temporelle floue |
| i | MARTINS 25005866 | 1648-1651 | Note bilingue anglais + « IRM il y a 2 semaines » + « amnésie de plusieurs jours » | Confusion durées possible |
| j | ORDRE_DOSSIERS | 2025 | Commentaire « 25003284 — UHCD asthme » désynchronisé avec VT actuelle FORFAIT (server.py:34) | Confusion si lecture à l'écran |
### Solution recommandée — préprocesseur Python `build_dpi_enriched`
Approche **HYBRIDE** : Python calcule les valeurs critiques (durée, âge), les injecte en tête du DPI dans un bloc `FAITS_CALCULÉS`, le LLM garde la liberté d'argumenter sur les preuves cliniques.
```python
import re
from datetime import datetime
DATE_RE = re.compile(r"(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})")
ARR_RE = re.compile(r"Arriv[ée]e\s*[:\-]?\s*(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", re.I)
SOR_RE = re.compile(r"Sortie\s*[:\-]?\s*(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", re.I)
PEC_RE = re.compile(r"prise en charge.*?(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", re.I|re.S)
DEC_RE = re.compile(r"d[ée]cision.*?(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", re.I|re.S)
NE_RE = re.compile(r"N[ée]\(?e?\)?\s+le\s+(\d{2}/\d{2}/\d{4})", re.I)
def parse_dt(d, h):
return datetime.strptime(f"{d} {h}", "%d/%m/%Y %H:%M")
def calc_facts(dpi: str) -> dict:
arr = ARR_RE.search(dpi) or PEC_RE.search(dpi)
sor = SOR_RE.search(dpi) or DEC_RE.search(dpi)
ne = NE_RE.search(dpi)
out = {}
if arr and sor:
a = parse_dt(*arr.groups()); s = parse_dt(*sor.groups())
if s < a:
return {"_warn_negative": True}
dur_h = round((s - a).total_seconds()/3600, 2)
out["arrivee"] = a.strftime("%d/%m/%Y %H:%M")
out["sortie"] = s.strftime("%d/%m/%Y %H:%M")
out["duree_passage_heures"] = dur_h
if ne and arr:
n = datetime.strptime(ne.group(1), "%d/%m/%Y")
a = parse_dt(*arr.groups())
age = a.year - n.year - ((a.month, a.day) < (n.month, n.day))
out["age_au_passage_ans"] = age
return out
def build_dpi_enriched(dpi_raw: str) -> str:
facts = calc_facts(dpi_raw)
if not facts:
return dpi_raw # fallback : LLM calcule lui-même
bloc = "===== FAITS CALCULÉS (déterministes — à utiliser tels quels) =====\n"
if "duree_passage_heures" in facts:
bloc += f"DUREE_PASSAGE_HEURES = {facts['duree_passage_heures']}\n"
bloc += f"ADMISSION = {facts['arrivee']}\n"
bloc += f"SORTIE = {facts['sortie']}\n"
if "age_au_passage_ans" in facts:
bloc += f"AGE_AU_PASSAGE = {facts['age_au_passage_ans']} ans\n"
bloc += "Note : ces valeurs prévalent sur toute durée de symptômes mentionnée dans le texte clinique.\n"
bloc += "=====================================================\n\n"
return bloc + dpi_raw
```
Et ajouter à `PROMPT_TEMPLATE` :
> Si un bloc `FAITS_CALCULÉS` est présent en tête du dossier, utilise EXACTEMENT `DUREE_PASSAGE_HEURES`
> pour le champ `duree_passage_heures`. NE PAS confondre avec les durées de symptômes (« depuis 23h »,
> « depuis 15 jours »).
### Bloc canonique à injecter (exemple MOREL)
```
===== FAITS CALCULÉS =====
ADMISSION = 01/01/2025 03:12
SORTIE = 01/01/2025 06:49
DUREE_PASSAGE_HEURES = 3.62
AGE_AU_PASSAGE = 77 ans
SEXE = F
CCMU = 3
GEMSA = 2
DIAGNOSTIC_PRINCIPAL = J12.1 Pneumopathie VRS [CMA2]
DECISION_ADMINISTRATIVE = Consultation externe (UC CONSULT.URGENCES)
==========================
```
### Ranking risque de bug LLM
**Top 3 risqués** :
1. **25003284 MOREL** — « depuis 23h » × 3, bandeau Arrivée/Sortie hors zone OCR
2. **25003364 LEROY** — « depuis 15 jours » × 3, terrain lourd SLA+BPCO, durée passage 7h35 limite
3. **25012257 BRUNEL** — multiples « >48h », « >72h », « >24h », allergie « il y a 30 ans »
**Top 3 safe** :
1. **25003451 ROUX** — 2h00, plaie nette, récit linéaire
2. **25010621 FAURE** — 2h49, dates absolues
3. **25048485 NGUYEN** — 6h50, crise convulsive datée
### Corrections data.js recommandées
- Ligne 228 : "78 ans" → "77 ans" (MOREL)
- Ligne 1519 : PERRIN GEMSA 4 → 2, OU us_destination → UHCD (cohérence VT)
- Ligne 1184 : DA SILVA episode_heure `03:00``03:25`
- Ligne 2025 : commentaire ORDRE_DOSSIERS aligné avec VT actuelle
---
## Rapport 4 — Référentiel officiel PMSI 2026 (sources web)
### Verdict liminaire — règle officielle
**Le DIM senior avait raison sur la conjonction ET. La règle 2/3 est officiellement fausse.**
Source primaire (citation textuelle Instruction DGOS/R1/DSS/1A/2020/52 Annexe 3) :
> « Ces trois conditions présentent un caractère cumulatif et s'apprécient avant l'admission du patient
> en UHCD. Dès que l'une d'entre elles n'est pas remplie, la prise en charge ne donne pas lieu à
> facturation d'un GHS mais à celle d'un forfait "accueil et traitement des urgences" (ATU). »
Guide ATIH MCO 2025 et 2026 (chap. I §1.2, identique entre les deux versions) :
> « Lorsque l'une de ces conditions n'est pas remplie, il ne doit pas être produit de RUM. »
### Arbre de décision officiel
```
PASSAGE URGENCES
|
[Suivi d'une hospitalisation MCO de la même entité géographique ?]
/ \
OUI NON
| |
GHS du séjour MCO [Les 3 critères UHCD sont-ils CUMULATIVEMENT remplis,
(avec quote-part appréciés AVANT l'admission en UHCD ?]
UHCD si passage / \
UHCD initial) OUI (3/3) NON (0, 1 ou 2 sur 3)
| |
(1) Pathologie potentiellement FORFAIT URGENCES :
évolutive susceptible - FU (forfait âge) à l'AM
d'aggravation OU diagnostic + FPU (côté patient, 23€)
incertain + suppléments éventuels
(2) ET surveillance médicale (SU2/SU3, SUB/SB2/SB3,
+ environnement SIM/SIC, SUN/SUF, SUM,
paramédical délivrables SAS, PE1/PE2)
uniquement en hospitalisation + actes/consultations
(3) ET examens complémentaires en sus
ou actes thérapeutiques
|
GHS mono-RUM UHCD
(date entrée = date sortie ;
niveau de sévérité le plus
bas de la racine GHM ;
tarif unique quelle que soit
la durée)
```
### Précisions critiques
- **Pas de seuil 6h opposable** : aucune source primaire (ATIH MCO 2025/2026, DGOS 2020, arrêté 19/02/2015) n'impose une durée plancher. Le seuil 6h relève du folklore DIM ou Clinical Decision Units anglo-saxonnes.
- **Plafond pratique** : DMS < 24h recommandée (SFMU 2024), tolérance ≤ 36h ou 2 nuits consécutives.
- **Critère 3 ne renvoie pas exclusivement à des actes CCAM** : pas de critère répétitif (Annexe 3 DGOS 2020).
- **Mode de sortie domicile + 3 critères remplis = GHS UHCD légal**. Ce sont les 3 conditions cliniques d'admission qui priment, pas le mode de sortie. MAIS : mode_sortie codé "Consultation externe" (mode ≠ 8) = drapeau rouge contrôle CPAM.
### Verdict cas MOREL Catherine (selon règle officielle)
| Critère | État | Validé |
|---|---|---|
| (1) Pathologie évolutive ou diagnostic incertain | Pneumopathie VRS fébrile (39.4°C) chez 77 ans, asthme + insuf coronarienne, peakflow effondré | **OUI** |
| (2) Surveillance + environnement paramédical hospitalier | Aérosols ×3, monitoring tachycardie 117→91, antibio initiée, surveillance peakflow | **OUI** |
| (3) Examens complémentaires ou actes thérapeutiques | RT, NFS, CRP, PCR VRS, aérosols répétés, ATB | **OUI** |
**3/3 cumulatifs ⇒ GHS UHCD légalement justifiable (racine 04M Pneumopathies)**, indépendamment de la durée 3h37.
### Points de vigilance MOREL pour défense contrôle CPAM
1. Durée 3h37 anormalement courte — signal d'alerte habituel ; dossier doit documenter explicitement le caractère évolutif AVANT admission UHCD.
2. Mode de sortie "Consultation externe" = drapeau rouge. Code mode 8 (domicile) attendu pour UHCD.
3. CCMU 3 + GEMSA 2 = cohérent UHCD.
4. DP J12.1 (CMA2) = pertinent, supporte GHS.
### Conclusion MOREL
**Le passage relève d'une UHCD facturable en GHS** (et non d'un forfait pur), à condition que le dossier documente les 3 critères AVANT admission UHCD et que le mode de sortie soit harmonisé. La VT actuelle `server.py:32` (FORFAIT) est donc à réinterroger.
### Nomenclature officielle (post-réforme 2024-2026)
**Forfaits âge (un seul par passage)** :
| Code | Description | Conditions |
|------|-------------|-----------|
| FU0 | Nouveau-né | < 4 mois (créé arrêté 29/02/2024) |
| FU1 | Nourrisson/enfant | 4 mois à < 16 ans |
| FU2 | Adulte jeune | 16 à < 45 ans |
| FU3 | Adulte mûr | 45 à < 75 ans |
| FU4 | Personne âgée | ≥ 75 ans |
**FPU (côté patient)** :
- 23€ depuis 1er mars 2026 (vs 19,61€ avant)
- 9,96€ minoré (ALD, AT-MP)
**Suppléments cliniques** :
| Code | Description | Conditions |
|------|-------------|-----------|
| SU2 | CCMU 2+ | État CCMU 2 + acte CCAM de la liste annexe 18 |
| SU3 | CCMU 3,4,5 | État CCMU 3/4/5 sans hospitalisation |
| PE1 | Pédiatrique | Patient FU0/FU1 + diagnostic CIM-10 liste 1 annexe 19 (~800 codes) |
| PE2 | Pédiatrique + | Patient FU0/FU1 + diagnostic CIM-10 liste 2 annexe 19 (sous-liste lourde) |
**Autres suppléments** :
- SUB/SB2/SB3 (biologie), SIM/SIC (imagerie), SUN (nuit 22-8h), SUF (soirée/férié/dimanche)
- SUM (transport médicalisé), SAS (avis spécialisé), SSN/SSF (avis spé nuit/férié)
**Règles de cumul** :
- Aucun supplément sans FU
- Un seul FU par passage
- SU2 / SU3 non cumulables entre eux
- Un seul supplément biologie, un seul supplément imagerie
- SUN / SUF non cumulables
- PE1/PE2 cumulabilité : zone grise à clarifier avec DIM cible
### "FF1/FF2/FF3" — N'EXISTENT PAS
Codes absents de la nomenclature officielle 2024-2026. Probablement dénomination interne Easily ou GHT Sud 95. **À clarifier impérativement avant démo.**
### Texte 200 mots prêt à coller dans PROMPT 1 (Section F du rapport)
> Tu évalues si un passage aux urgences relève d'un GHS UHCD ou d'un Forfait Urgences (FU+FPU). Applique
> cette règle officielle ATIH/DGOS 2026 (Instruction DGOS/R1/DSS/1A/2020/52 Annexe 3 + Guide ATIH MCO
> 2025/2026 §1.2 + arrêté 19/02/2015 art.12) :
>
> **GHS UHCD facturable UNIQUEMENT si TOUTES les conditions suivantes sont cumulativement remplies,
> appréciées AVANT admission en UHCD :**
> 1. État pathologique potentiellement évolutif et susceptible d'aggravation, OU diagnostic incertain ;
> 2. ET surveillance médicale et environnement paramédical délivrables uniquement en hospitalisation ;
> 3. ET réalisation d'examens complémentaires ou d'actes thérapeutiques.
>
> **Si une seule condition manque** → facturation forfait urgences (FU0-FU4 selon âge + FPU patient
> + suppléments éventuels : SU2/SU3, PE1/PE2, SUB/SB2/SB3, SIM/SIC, SUN/SUF, SUM, SAS), pas de GHS.
>
> **NE PAS appliquer la règle "2 sur 3"** : elle est non conforme et expose à requalification CPAM.
>
> Exception : l'administration d'un produit de la réserve hospitalière déclenche un GHS UHCD même hors
> critères ci-dessus.
>
> Aucune durée minimum n'est réglementairement opposable, mais une DMS < 24h est attendue ; au-delà
> de 36h ou 2 nuits, escalader vers un humain. Le passage en UHCD suivi d'une mutation MCO de même
> entité géographique ne donne lieu qu'à UN SEUL GHS du séjour complet.
### Sources primaires (12 PDF archivés dans `docs/clients/ght_sud_95/referentiels_officiels/`)
| Fichier | Source | URL d'origine |
|---------|--------|---------------|
| `solimed_instruction.pdf` | Instruction DGOS/R1/DSS/1A/2020/52 du 10/09/2020 (intégrale) | solimed.fr |
| `apm_instruction.pdf` | Idem (copie alternative) | apm |
| `instruction_gradation_2020.pdf` | Idem | gouv |
| `instruction_gradation_bo.pdf` | Idem (BO) | gouv |
| `atih_mco_2025.pdf` | Guide méthodologique ATIH MCO 2025 | atih.sante.fr |
| `atih_mco_2026.pdf` | Guide méthodologique ATIH MCO 2026 | atih.sante.fr |
| `sfmu_uhcd_2024.pdf` | Guide bonnes pratiques UHCD 2024 — SFMU | sfmu.org |
| `sfmu_ref_uhcd.pdf` | Référentiel SFMU UHCD | sfmu.org |
| `dhumu_uhcd.pdf` | Critères admission UCSU 2024 | dhumu.fr |
| `oru_pdl_uhcd.pdf` | ORU Pays de Loire UHCD | oru-pdl.org |
| `fhpmco_2023.pdf` | FHP-MCO Financement médecine d'urgence n°17 (mai 2023) | fhpmco.fr |
URLs Légifrance non archivées en PDF (à consulter en ligne) :
- Arrêté du 19/02/2015 modifié, art. 12, Chap. 7 : `legifrance.gouv.fr/codes/section_lc/JORFTEXT000030280539/JORFSCTA000030280566/2024-03-02`
- Arrêté du 27/12/2021 financement structures urgences : `JORFTEXT000044592184`
- Arrêté du 29/02/2024 modifiant 19/02/2015 (FU0 + annexes 18/19) : `JORFTEXT000049219412`
- Annexe 19 Liste 1 PE1 (CIM-10) : `LEGIARTI000049222509/2024-03-02`
- Arrêté du 17/12/2021 montants FPU : `JORFTEXT000044592137`
### Confiance globale
- **ÉLEVÉ** sur règle cumulative ET, nomenclature FU0-FU4, verdict MOREL, absence de seuil 6h
- **MOYEN** sur listes exhaustives annexes 18/19, cumulabilité PE1+PE2
- **FAIBLE** sur "FF1/FF2/FF3" (probablement Easily) et sémantique PE2 (était incorrecte dans nos hypothèses initiales)
---
## Plan d'action P0 consolidé (à valider avec Dom + Amina)
### Préalable bloquant
**Trancher VT MOREL avec Amina/DIM Carvella** : selon règle officielle ATIH, MOREL = UHCD. Mais `server.py:32` dit FORFAIT (corrigé 2026-05-05). Soit :
- (a) Erreur dans server.py et VT à inverser ⇒ MOREL devient cas de démo "UHCD non détectée" avec ROI ~375€
- (b) VT volontaire pour illustrer un bug de codage administratif ⇒ système détecte l'incohérence "profil clinique UHCD + mode_sortie consultation externe" comme perte de recettes
Option (b) est plus puissante en démo. À confirmer.
### Actions P0 (~3h cumulé, validation 1 par 1)
| # | Action | Fichier | Effort | Risque |
|---|--------|---------|--------|--------|
| C | Params Ollama déterministes (`temp=0, seed=42, top_p=1, top_k=0`) | `core/llm/t2a_decision.py:109-113` | 5 min | nul |
| A | Préprocesseur Python `build_dpi_enriched` (calcul durée + âge depuis OCR) | `core/llm/t2a_decision.py` (nouvelle fonction) | 45 min | faible |
| B | PROMPT 1 réécrit avec **règle officielle ATIH** (Section F rapport 4) + 2 few-shot + listes mutuellement exclusives + ordre clés preuves AVANT décision | `core/llm/t2a_decision.py:31-72` | 45 min | moyen |
| D | Garde-fous serveur dans `_handle_t2a_decision_action` (coercion + recompute 3/3 + regex contradiction + retry) | `agent_v0/server_v1/replay_engine.py:1204+` | 45 min | faible |
| E | PROMPT 3 réécrit (balises XML + clause refus syntaxique exacte + structure conclusion verrouillée) | DB workflow step 17 (UPDATE SQL) | 30 min | faible |
### Actions P1 (~1h, qualité)
| # | Action | Fichier |
|---|--------|---------|
| F | PROMPT 2 ajustements (3 lignes : durée explicite, mode sortie, actes CCAM, gestion conflit OCR) | DB workflow step 14 |
| G | Corrections data.js (78→77 ans MOREL ; PERRIN GEMSA ; DA SILVA episode_heure ; commentaire ligne 2025) | `data.js` |
| H | Logs structurés JSON brut t2a_decision pour audit Q&A post-démo | `replay_engine.py` |
### Actions P2 (hors scope démo)
- Cas PE2/SU2 : exclure de la démo OU branche déterministe CCAM
- Geler `dpi_canonique` en variable de session
---
## Questions ouvertes pour reprise
1. **VT MOREL** : Amina valide quoi (FORFAIT inversée ou maintenue avec narratif "détection bug codage") ?
2. **Cas PE2/SU2 démo** : on les inclut ou on les écarte ?
3. **"FF1/FF2/FF3"** : c'est interne Easily ou erreur de saisie ?
4. **Cumulabilité PE1+PE2** : à clarifier avec DIM Carvella ?
5. **Ordre d'attaque** : on confirme C → A → B → D → E ?

View File

@@ -0,0 +1,852 @@
# [S1] Chantier build_dpi_enriched + garde-fous comparaison Python ↔ LLM
Brief V2 finalisé après revue [S2] — 12 mai 2026.
## CONTEXTE
Bug identifié sur cas MOREL (audit `docs/handoffs/2026-05-12_audit_complet_decision_t2a.md`) :
le LLM hallucine "23h" pour la durée du passage alors que la durée réelle est 3h37
(arrivée 01/01/2025 03:12 → sortie 01/01/2025 06:49). Hypothèse confirmée par capture
UI Easily Assure : confusion avec `depuis 23h` présent dans `Observ. IDE Urg` du DPI.
Source de données validée : "Extract texte scroll auto" capture la totalité de
l'écran y compris le bandeau ET la section "Synthèse Urgences" qui contient les
horaires structurés ligne par ligne (Date d'épisode, Date de prise en charge
médicale, Date de décision médicale, etc.) + CCMU + GEMSA + diagnostic principal
+ décision médicale terrain + mode de venue.
Solution validée avec Dom : sortir le calcul de durée + les classifications
cliniques structurées du LLM, les extraire en Python déterministe, injecter
le résultat en tête du DPI dans un bloc FAITS_CALCULÉS. Le LLM ne calcule plus,
il lit. La décision médicale terrain est extraite mais **NON injectée dans le
prompt** (sinon le LLM s'aligne sur le terrain au lieu de raisonner) : elle sert
uniquement au garde-fou serveur de comparaison a posteriori.
Branche : décision (séparée de l'optim vitesse en cours sur autre branche).
Pas de modif du PROMPT 3 dans ce chantier. La réécriture du prompt viendra
APRÈS le dry-run de validation.
## OBJECTIF
Livrer 2 commits indépendants :
- **Commit 1** : fonction `build_dpi_enriched` + test golden MOREL
- **Commit 2** : garde-fous comparaison durée + décision dans `_handle_t2a_decision_action`
**PAS DE COMMIT COMPOSITE.** Chaque commit doit être revertable indépendamment.
---
## ÉTAPE 0 — Vérification source de données (5 min, AVANT commit 1)
Lis le code qui construit le `dpi_raw` envoyé au LLM (concaténation des 5 variables
`t_motif_admission`, `t_examen_clinique`, `t_imagerie`, `t_notes_medicales`, `t_synthese_urgences`).
Ajoute un `logger.debug` temporaire qui dump le `dpi_raw` effectivement reçu par le
prompt T2A. Relance Demo_urgence_2 (ou un cas test équivalent) sur MOREL.
Vérifie dans le log :
- La section "Synthèse Urgences" est présente avec ses lignes structurées
(Date d'épisode, Date de décision médicale, CCMU, GEMSA, Diagnostics, Décision médicale)
- Le bandeau Easily Assure est présent (Arrivée, Sortie, Né(e) le)
Si OUI aux deux : feu vert chantier, retire le `logger.debug`, passe au commit 1.
Si NON (l'une OU l'autre des sources manque) : STOP, ping Dom avant de continuer.
Le chantier sera requalifié (élargir la capture OCR ou revoir la construction de `dpi_raw`).
---
## COMMIT 1 — build_dpi_enriched
### EMPLACEMENT
`core/llm/t2a_decision.py` (cf. audit ligne 704, nouvelle fonction)
### SIGNATURE
```python
def build_dpi_enriched(dpi_raw: str) -> tuple[str, dict]:
"""
Enrichit le DPI brut avec un bloc FAITS_CALCULÉS en tête.
Returns:
- dpi_enriched : str — DPI avec FAITS_CALCULÉS en tête + dpi_raw inchangé
- metadata : dict — valeurs calculées en Python, utilisées par le
serveur pour les garde-fous (PAS injectées dans le LLM
pour decision_medicale_terrain et orientation_terrain)
"""
```
Le tuple est volontairement utilisé pour permettre le garde-fou serveur du
commit 2 sans avoir à reparser. Si la convention du repo préfère deux fonctions
séparées (`extract_metadata` + `build_enriched_string`), justifie en commentaire et
adopte cette convention.
### LOGIQUE D'EXTRACTION
**PRIORITÉ DE PARSING** : section "Synthèse Urgences" d'abord (lignes structurées),
bandeau Easily Assure en fallback (regex sur `dpi_raw` global).
Synthèse Urgences est plus fiable que le bandeau parce que les champs sont
ligne par ligne, libellés explicites, format constant.
**CHAMPS À EXTRAIRE** :
Depuis Synthèse Urgences (priorité) :
- `date_admission` : ligne "Episode - Date" → "01/01/2025 à 03:12"
OU ligne "Date de la prise en charge médicale"
- `date_sortie` : ligne "Date de décision médicale" → "01/01/2025 à 06:49"
- `ccmu` : ligne "CCMU" → libellé COMPLET tel qu'affiché dans la
Synthèse Urgences (ex: `"3. Etat lésionnel et/ou pronostic
fonctionnel jugés susceptibles de s'aggraver aux urgences
ou durant l'intervention du SMUR, sans mettre en jeu le
pronostic vital"`). **Attention** : le libellé peut être
multi-ligne dans le texte OCR — le parser doit reconstituer
toute la valeur jusqu'à la prochaine ligne structurée
(libellé suivant ou ligne vide).
- `gemsa` : ligne "GEMSA" → libellé COMPLET (ex: `"2. Patient non
convoqué sortant après consultation ou soins"`)
- `priorite_iao` : ligne "Priorité" → "Priorité 3" (libellé complet tel quel)
- `mode_venue` : ligne "Mode de transport à l'arrivée" → "Véhicule personnel"
- `mode_medicalisation` : ligne "Médicalisation du transport" → "Aucune médicalisation"
- `mode_entree` : ligne "Mode d'entrée" → "Autres admissions urgentes"
- `diagnostic_principal`: ligne "Diagnostics" → "J12.1 Pneumopathie..."
- `decision_terrain` : ligne "Décision médicale" → "Consultation externe"
- `orientation_terrain` : ligne "US de destination" → "UC CONSULT.URGENCES"
Depuis bandeau (fallback uniquement si Synthèse Urgences absente) :
- `Né\(e\) le (\d{2}/\d{2}/\d{4})` → `date_naissance`
- `Arrivée\s*:\s*(\d{2}/\d{2}/\d{4}\s+\d{2}:\d{2})` → `date_admission`
- `Sortie\s*:\s*(\d{2}/\d{2}/\d{4}\s+\d{2}:\d{2})` → `date_sortie`
**IMPORTANT** : la date de naissance n'est PAS dans la Synthèse Urgences, elle est
uniquement dans le bandeau. Toujours parser le bandeau pour la naissance.
**IMPORTANT** : ignorer le pattern `IAO : <nom> (HH:MM)` dans le bandeau. Le `(HH:MM)`
après IAO est l'horaire de triage IAO, PAS une durée. Ne pas le confondre.
**IMPORTANT — Robustesse aux occurrences multiples du bandeau** (ÉTAPE 0, [S2]) :
Le bandeau Easily Assure est dans l'en-tête de page (fixe), donc capté par
**chaque** `extract_text(top_var)` des 5 `extract_text_scroll` (un par onglet).
Conséquence : le `dpi_text` reçu par `_handle_t2a_decision_action` contient
**probablement le bandeau répété ~5 fois**.
Le parser regex de `build_dpi_enriched` doit en tenir compte :
- **Utiliser `re.search` (première occurrence)**, pas `re.findall` ni boucle.
- **Vérification de cohérence (optionnelle mais recommandée)** : si plusieurs
occurrences du bandeau sont détectées via `re.findall` à des fins de check,
et qu'elles divergent (ex: arrivées différentes), logger :
```
[build_dpi_enriched] Bandeau détecté N fois avec divergences — symptôme
d'OCR instable, prendre la 1re occurrence par défaut
```
Sinon : pas de log (silence en cas de cohérence).
Confirmation runtime de la présence effective du bandeau dans le `dpi_text` sera
obtenue automatiquement lors du dry-run E2E MOREL post-commit 2. Pas de
`logger.debug` séparé nécessaire.
### CALCULS DÉRIVÉS
- `age_ans = dateutil.relativedelta(date_admission, date_naissance).years`
(**OBLIGATOIRE** — pas de `//365` qui peut générer un écart d'1 an sur cas limites)
- `duree_timedelta = date_sortie - date_admission`
- `duree_heures_decimales = round(duree_timedelta.total_seconds() / 3600, 2)`
- `duree_format_humain = "{H} heures et {M} minutes"` (ex: `"3 heures et 37 minutes"`)
Format choisi pour lever toute ambiguïté avec une notation décimale "3.37".
### CONSTRUCTION DU BLOC FAITS_CALCULÉS
À injecter EN TÊTE du `dpi_raw` retourné (avant tout autre contenu) :
```
FAITS_CALCULÉS (déterministes, ne pas recalculer) :
- Âge du patient : {age_ans} ans
- Date admission : {date_admission_str}
- Date sortie : {date_sortie_str}
- Durée totale du passage : {duree_format_humain} (soit {duree_heures_decimales} heures décimales)
- CCMU : {ccmu}
- GEMSA : {gemsa}
- Priorité IAO : {priorite_iao}
- Mode de venue : {mode_venue}, {mode_medicalisation}
- Mode d'entrée : {mode_entree}
- Diagnostic principal : {diagnostic_principal}
[dpi_raw inchangé en dessous]
```
**Exemple rendu pour MOREL** :
```
FAITS_CALCULÉS (déterministes, ne pas recalculer) :
- Âge du patient : 77 ans
- Date admission : 01/01/2025 à 03:12
- Date sortie : 01/01/2025 à 06:49
- Durée totale du passage : 3 heures et 37 minutes (soit 3.62 heures décimales)
- CCMU : 3. Etat lésionnel et/ou pronostic fonctionnel jugés susceptibles de s'aggraver aux urgences ou durant l'intervention du SMUR, sans mettre en jeu le pronostic vital
- GEMSA : 2. Patient non convoqué sortant après consultation ou soins
- Priorité IAO : Priorité 3
- Mode de venue : Véhicule personnel, Aucune médicalisation
- Mode d'entrée : Autres admissions urgentes
- Diagnostic principal : J12.1 Pneumopathie due au virus respiratoire syncytial [VRS] [CMA2] - actif
```
⚠️ **NE JAMAIS INCLURE `decision_terrain` NI `orientation_terrain` DANS LE BLOC FAITS_CALCULÉS.**
Ces deux valeurs vont uniquement dans le dict `metadata` retourné, pour usage serveur.
### METADATA RETOURNÉE (dict)
```python
{
"age_ans": int | None,
"date_admission": datetime | None,
"date_sortie": datetime | None,
"duree_heures_decimales": float | None,
"ccmu": str | None, # libellé COMPLET (ex: "3. Etat lésionnel et/ou pronostic fonctionnel jugés susceptibles...")
"gemsa": str | None, # libellé COMPLET (ex: "2. Patient non convoqué sortant après consultation ou soins")
"priorite_iao": str | None, # libellé tel quel (ex: "Priorité 3")
"mode_venue": str | None,
"mode_medicalisation": str | None,
"mode_entree": str | None,
"diagnostic_principal": str | None,
"decision_terrain": str | None, # ← NON injecté au LLM
"orientation_terrain": str | None, # ← NON injecté au LLM
"parsing_warnings": list[str] # ← champs n'ayant pas pu être parsés
}
```
### ROBUSTESSE
- Chaque champ est optionnel. Si un parsing échoue, mettre `None` dans metadata,
ne pas mentionner le champ dans FAITS_CALCULÉS (ne pas écrire `"Âge : None"`),
et ajouter un message dans `metadata["parsing_warnings"]`.
- Cas critique : si `date_admission` OU `date_sortie` est `None`, injecter dans
FAITS_CALCULÉS la ligne suivante au lieu de la durée :
`"- Durée totale du passage : NON CALCULABLE (horaires non détectés)"`
Logger WARNING avec préfixe `[build_dpi_enriched]`.
- **NE PAS crasher.** Retourner toujours un tuple valide, même si tout le parsing
échoue (FAITS_CALCULÉS vide + metadata avec tous les champs None).
### TEST GOLDEN MOREL
Créer un fichier de test (pytest ou unittest selon convention du repo) avec :
**Input** : un DPI brut contenant :
1. Une ligne bandeau :
```
IPP : 25003284 MOREL Catherine Né(e) le 14/03/1947 | 77 ans | Sexe : F |
Arrivée : 01/01/2025 03:12 | IAO : CARON Sandrine (03:25)
Médecin : BONNET Antoine | Sortie : 01/01/2025 06:49
```
2. Une section "Synthèse Urgences" reproduisant la structure de la capture
MOREL : Episode - Date 01/01/2025 03:12, CCMU 3, GEMSA 2, Diagnostics
J12.1 Pneumopathie..., Décision médicale Consultation externe, US de
destination UC CONSULT.URGENCES, etc.
**Assertions sur `dpi_enriched` (string)** :
- `"Durée totale du passage : 3 heures et 37 minutes"` présent
- `"(soit 3.62 heures décimales)"` présent
- `"Âge du patient : 77 ans"` présent
- `"CCMU : 3. Etat lésionnel"` présent (début libellé complet)
- `"sans mettre en jeu le pronostic vital"` présent (fin libellé CCMU — vérifie
que tout le libellé multi-ligne a été reconstitué)
- `"GEMSA : 2. Patient non convoqué sortant après consultation ou soins"` présent
- `"Priorité IAO : Priorité 3"` présent
- `"J12.1 Pneumopathie"` présent
- `"Consultation externe"` ABSENT du bloc FAITS_CALCULÉS
(assertion forte — vérifier que la string ne contient pas
`"Décision médicale terrain : ..."` dans FAITS_CALCULÉS)
- Le bloc FAITS_CALCULÉS apparaît AVANT le bandeau brut dans la chaîne
**Assertions sur `metadata` (dict)** :
- `metadata["duree_heures_decimales"] == 3.62` (tolérance ±0.01)
- `metadata["age_ans"] == 77`
- `metadata["ccmu"].startswith("3.")` ET `"Etat lésionnel" in metadata["ccmu"]`
ET `"pronostic vital" in metadata["ccmu"]` (vérifie début + reconstitution complète)
- `metadata["gemsa"].startswith("2.")` ET `"non convoqué" in metadata["gemsa"]`
- `metadata["priorite_iao"] == "Priorité 3"`
- `metadata["decision_terrain"] == "Consultation externe"`
- `metadata["orientation_terrain"] == "UC CONSULT.URGENCES"`
- `metadata["parsing_warnings"]` est une liste vide
### TEST NÉGATIF
Retirer la ligne "Date de décision médicale" du DPI input ET la ligne "Sortie"
du bandeau. Vérifier :
- La fonction ne crashe pas
- `dpi_enriched` contient `"Durée totale du passage : NON CALCULABLE"`
- `metadata["duree_heures_decimales"] is None`
- `metadata["parsing_warnings"]` contient une entrée explicite
### INTÉGRATION PIPELINE
Identifier l'endroit où le `dpi_raw` (les 5 t_* concaténées) est passé au LLM
pour la décision T2A. Insérer un appel à `build_dpi_enriched` JUSTE AVANT
l'envoi au LLM. Stocker la metadata pour réutilisation au commit 2.
**PAS de modification du PROMPT 3** (instruction 5 sur le calcul de durée reste
telle quelle pour permettre la comparaison en commit 2).
### VALIDATION COMMIT 1
- pytest passe sur le test golden + test négatif
- Lecture par Dom de la fonction et du test avant push
- Commit message : `feat(t2a): build_dpi_enriched - extraction déterministe horaires + classifications cliniques`
---
## PHASE INTERMÉDIAIRE — Mini-bench standalone sur 11 dossiers POC
Entre commit 1 (validé, mergé) et commit 2 (à coder), créer un script de bench
standalone qui exécute `build_dpi_enriched` + appel LLM sur les 11 dossiers POC
GHT Sud 95, **sans passer par Demo_urgence_2 ni Léa/Windows**.
**Objectif** : observer le comportement réel du LLM sur un panel représentatif
AVANT d'écrire le garde-fou décision (commit 2), ce qui permet :
- De découvrir les libellés `Décision médicale` réels → alimente le mapping
`TERRAIN_VERS_T2A` sans grep manuel
- De mesurer le taux de convergence durée/décision sur 11 cas, pas 1
- D'identifier d'autres patterns d'hallucination au-delà du "depuis 23h" MOREL
### EMPLACEMENT
`scripts/bench_t2a_dryrun.py`
### COMPORTEMENT
1. Lire les 11 dossiers depuis :
`/home/dom/ai/rpa_vision_v3/docs/clients/ght_sud_95/mockup_easily_assure/data.js`
⚠️ **Format réel** : ce n'est pas du JSON pur — c'est un **module JavaScript**
(`const DOSSIERS = { "<IPP>": { ... }, ... };`).
11 dossiers confirmés (un par IPP). Cas pilote = MOREL `"25003284"`.
Structure observée par dossier (champs pertinents — à compléter en lisant
directement le fichier avant de coder le parseur) :
```
"<IPP>": {
statut_attente: bool,
identite: { ipp, nom, prenom, ne_le, age, sexe },
passage: { arrivee, iao, iao_heure, medecin, sortie, motif_court, statut },
motif: {
obs_ide, // ← contient le piège "depuis 23h" (cas MOREL)
symptomes_orientation, symptomes_date, symptomes_par,
priorite, // ← "Priorité 2" ou "Priorité 3" (mappe au metadata python priorite_iao)
ccmu, // ← libellé complet "N. Description..."
gemsa, // ← libellé complet "N. Description..."
},
diagnostics: [ { code, ... }, ... ],
decision: "<libellé terrain>",
orientation: "<...>",
us_destination: "<...>",
}
```
⚠️ **Mapping JS → metadata python** :
- `motif.priorite` (JS) → `priorite_iao` (metadata python) — le nom python est
conservé pour clarté métier, ne pas renommer.
- `motif.ccmu`, `motif.gemsa` (JS) → `ccmu`, `gemsa` (metadata python)
**Options de parsing** (à trancher par [S1] selon ce qui est le plus simple) :
- Conversion ponctuelle JS → JSON via un petit script (`node -e "console.log(JSON.stringify(require('./data.js').DOSSIERS))"` ou équivalent)
- Parsing direct en Python via regex / `pyjsparser` / `esprima-python`
- Demande à Dom d'exporter un `data.json` parallèle
⚠️ **Note anonymisation — statut INCERTAIN** (Dom, 12 mai) :
Dom n'est pas sûr si la version actuelle de `data.js` est v1 brute, v1
corrigée, ou v2. Voir mémoire `feedback_anonymisation_stricte.md` —
`data.js` v1 avait des hallucinations à sens inversé (ankylose↔anhydrose,
avec/sans injection) liées à une anonymisation LLM trop libre.
**Action requise pour le bench — filet `data_quality_warning`** :
Pour chaque dossier, pendant l'exécution, ajouter dans le log un champ
`data_quality_warning` qui **flag sans corriger** toute incohérence
clinique flagrante détectable simplement :
- Motif incohérent avec diagnostic principal (ex: motif "Asthme" + diag
"Fracture du poignet")
- Sexe ou âge contradictoires entre champs (`identite.sexe` vs mentions
dans `obs_ide`, `identite.age` vs `identite.ne_le`)
- Observations qui contredisent les signes vitaux (ex: "patient stable"
+ tachycardie sévère mentionnée)
C'est un **filet, pas une analyse exhaustive**. Si quelque chose paraît
bizarre pendant l'exécution, le signaler à Dom en fin de bench (stats
finales) plutôt que d'interpréter une divergence LLM↔terrain comme un
défaut du LLM.
**Le bench doit logger fidèlement ce qu'il reçoit, jamais "corriger"
silencieusement.**
2. Pour chaque dossier :
a. **Construire `dpi_raw` en texte plat** en concaténant les champs
structurés sous une forme proche de ce que produit OCR scroll auto réel
(bandeau en tête, puis sections Motif/Examens/Imagerie/Notes/Synthèse).
⚠️ **NE PAS bypasser `build_dpi_enriched`** en lui donnant les données
déjà structurées sous forme de dict. La fonction doit recevoir un
**texte plat qu'elle parsera elle-même**. C'est le test de robustesse
du parser sur 11 cas. Sinon le bench n'a aucune valeur diagnostique.
Pour rester proche du format OCR scroll auto réel, [S1] s'inspire d'un
dump OCR existant si disponible (ex: capture MOREL ayant servi à
l'audit). À défaut, format raisonnable inspiré de l'UI Easily Assure.
b. Appeler `build_dpi_enriched(dpi_raw)` → récupère `(dpi_enriched, metadata)`
c. Appeler le LLM avec PROMPT 3 **actuel** (PAS réécrit) + `dpi_enriched`
d. Logger les 4 traces `[t2a_dryrun_*]` dans `logs/t2a_dryrun/<IPP>_<timestamp>.log`
(**instrumentation portée par le bench lui-même**, autonome — le serveur
n'a pas encore le commit 2 à ce stade)
3. **Mapping `TERRAIN_VERS_T2A` — VALIDÉ PAR DOM (12 mai 2026)** :
```python
TERRAIN_VERS_T2A = {
"Consultation externe": "FORFAIT_URGENCE",
"Hospitalisation": "REQUALIFICATION_HOSPITALISATION",
"Sortie après surveillance UHCD": "REQUALIFICATION_HOSPITALISATION",
"Transfert intra-hospitalier": "REQUALIFICATION_HOSPITALISATION",
}
```
Cas non mappés volontairement :
- `"Retour structure d'origine"` → à arbitrer cliniquement (email DIM
Pauline/Amina en cours de préparation). Pour aujourd'hui : non mappé,
loguera `Libellé terrain non mappé` sans erreur.
- `""` (chaîne vide) → dossier en attente, **SKIPPER** dans la boucle
(cf. `statut_attente: true` côté JS).
**Chiffres empiriques (parsés depuis `data.js` le 12 mai)** :
- 11 dossiers total
- **0 dossier `statut_attente: true`** → pas de skip (le check reste dans
le bench pour robustesse future)
- 11 dossiers utiles dans la boucle
- Décomposition `synthese.decision` :
- `Consultation externe` : 7 → mappés FORFAIT_URGENCE
- `Hospitalisation` : 1 → mappé REQUALIFICATION
- `Sortie après surveillance UHCD` : 1 → mappé REQUALIFICATION
- `Transfert intra-hospitalier` : 1 → mappé REQUALIFICATION
- `Retour structure d'origine` : 1 → non mappé (volontaire)
- **Total : 11 utiles, 10 mappés, 1 non mappé**.
✅ Incohérence précédente du brief (10 cas annotés vs 9 mappés annoncés)
levée : c'était 10 mappés sur 11 utiles, l'erreur venait de la prémisse
"1 dossier en attente" qui n'existe pas dans `data.js`.
**Implémentation recommandée par [S2]** : placer ce mapping dans un module
partagé (ex: `core/llm/t2a_mappings.py`), importé à la fois par le bench
et par `_handle_t2a_decision_action` au commit 2. Évite la duplication et
garantit la cohérence entre les 2 phases.
4. Afficher un tableau récap stdout (markdown) — 9 colonnes :
```
| IPP | Nom | duree_python | duree_llm | conv_duree | decision_llm | decision_terrain | mapping_attendu | conv_decision |
```
5. **Sortie CSV recommandée** : `scripts/bench_t2a_dryrun_<timestamp>.csv`
pour analyse downstream / archivage / comparaison exécution #1 vs #2.
### ACTIVATION
```bash
T2A_DRYRUN=1 python scripts/bench_t2a_dryrun.py
```
### USAGE EN 2 EXÉCUTIONS
- **Exécution #1** : APRÈS commit 1 (build_dpi_enriched + golden MOREL),
AVANT commit 2 (garde-fous).
Objectif : mesurer convergence durée + décision sur 11 cas avec le mapping
pré-validé. Identifier d'éventuels patterns d'hallucination au-delà de
"depuis 23h" MOREL. Le mapping étant déjà validé par Dom, **pas de second
grep** — le commit 2 peut partir avec cette table sans découverte
intermédiaire.
- **Exécution #2** : APRÈS commit 2.
Objectif : validation finale avant push. Vérifier que la logique garde-fou
logue proprement, que le pipeline complet ne régresse pas, et que les
résultats #1 vs #2 sont cohérents (si params LLM reproductibles).
### PARAMÈTRES LLM — TRANCHÉ : OPTION 2 (Dom, 12 mai)
Le bench appelle le LLM avec **`temperature=0`, `seed=42`**.
Le serveur de production reste inchangé pour aujourd'hui. La dette « passer le
serveur en `temp=0`/`seed=42` » est tracée en P0 [S3].
⚠️ **Point de vigilance — vérifier empiriquement que `gemma4:31b-cloud`
respecte effectivement `seed=42`**. Certains providers cloud ignorent ce
paramètre à cause du routage GPU non déterministe. Test simple : lancer 2 fois
le même prompt avec le même seed et comparer les sorties.
- Si sorties identiques → seed respecté, reproductibilité OK.
- Si sorties différentes → seed ignoré côté cloud. **Loguer explicitement ce
bruit résiduel dans les stats finales du bench** (mention type "seed non
respecté côté modèle cloud, divergences résiduelles attendues"). Ne pas
masquer le bruit ni l'interpréter comme un échec du chantier.
À documenter dans la sortie stdout du bench si le bruit est détecté.
### LIMITATION (notée par Dom)
Ce bench teste `build_dpi_enriched` + appel LLM **isolé**. Il NE remplace PAS un
dry-run end-to-end via Demo_urgence_2 qui reste utile pour valider l'intégration
pipeline complète. Faire AU MOINS un Demo_urgence_2 sur MOREL après commit 2 pour
valider la chaîne complète.
### VALIDATION PHASE INTERMÉDIAIRE
- Le bench tourne sans crash sur les 11 dossiers (1 skip pour vide + 10 traités)
- Le tableau récap stdout est lisible avec ses 9 colonnes
- Les 10 logs `logs/t2a_dryrun/<IPP>_*.log` sont écrits (1 skip)
- Le CSV de bench est sauvegardé
### STATS DE FIN DE BENCH (livrables)
À produire à la fin du bench, soit en stdout après le tableau, soit dans un
fichier dédié `scripts/bench_t2a_dryrun_<timestamp>_stats.md` :
- **Taux convergence durée Python ↔ LLM** sur 10 dossiers utiles (hors vide)
- **Taux convergence décision terrain ↔ LLM** sur 10 dossiers mappés
(11 utiles - 1 non mappé "Retour structure d'origine")
- **Liste des dossiers en divergence** avec extraits de log pertinents
(ex: snippet `"justification"` du LLM pour comprendre l'erreur)
- **Recommandation** : faut-il réécrire PROMPT 3 (finding #5) ou les
FAITS_CALCULÉS suffisent-ils ?
Commit message : `feat(t2a): scripts/bench_t2a_dryrun.py - mini-bench standalone 11 dossiers POC`
---
## COMMIT 2 — Garde-fous comparaison Python ↔ LLM
### EMPLACEMENT
`_handle_t2a_decision_action` (audit finding #1, fichier serveur).
Si tu ne trouves pas la fonction, grep `"_handle_t2a_decision_action"` dans le
repo et liste les fichiers concernés à Dom avant de modifier.
### LOGIQUE GARDE-FOU 1 — Durée
Après réception du JSON de réponse LLM, AVANT toute utilisation du champ
`duree_passage_heures` :
```python
duree_python = metadata["duree_heures_decimales"] # source de vérité
duree_llm = json_response.get("duree_passage_heures")
SEUIL_TOLERANCE_HEURES = 0.5 # ≈ 30 minutes, distingue hallucination grossière
if duree_python is None:
logger.info("[t2a_compare_duree] Pas de durée Python disponible (parsing échoué)")
elif duree_llm is None:
logger.info("[t2a_compare_duree] LLM a renvoyé null pour duree_passage_heures")
elif abs(duree_llm - duree_python) > SEUIL_TOLERANCE_HEURES:
logger.warning(
f"[t2a_compare_duree] DIVERGENCE : LLM={duree_llm}h vs Python={duree_python}h "
f"(écart={abs(duree_llm - duree_python):.2f}h)"
)
else:
logger.info(f"[t2a_compare_duree] Convergence : {duree_python}h")
# Dans tous les cas, UI et BDD utilisent duree_python (source de vérité = Python).
# La valeur LLM n'est conservée que dans le log pour analyse.
```
### LOGIQUE GARDE-FOU 2 — Décision
**MAPPING DÉJÀ VALIDÉ PAR DOM** (12 mai 2026, basé sur les 11 dossiers POC).
[S1] utilise directement le mapping ci-dessous (identique à celui du mini-bench
PHASE INTERMÉDIAIRE). **Aucune procédure de découverte/grep à effectuer** : le
mapping est définitif pour le périmètre démo GHT Sud 95.
**Implémentation recommandée** : module partagé `core/llm/t2a_mappings.py`
importé à la fois par `scripts/bench_t2a_dryrun.py` et par
`_handle_t2a_decision_action`. Garantit cohérence entre PHASE INTERMÉDIAIRE
et COMMIT 2, et évite toute divergence.
Si un nouveau libellé apparaît hors POC actuel (déploiement futur autre
établissement), le bench le logguera `Libellé terrain non mappé` et il sera
ajouté ponctuellement après validation Dom.
Toujours dans `_handle_t2a_decision_action`, après le garde-fou durée :
```python
decision_terrain = metadata["decision_terrain"] # ex: "Consultation externe"
decision_llm = json_response.get("decision") # ex: "FORFAIT_URGENCE" ou
# "REQUALIFICATION_HOSPITALISATION"
# Mapping terrain → catégorie T2A attendue
# (importé idéalement depuis core/llm/t2a_mappings.py — table validée Dom 12/05)
TERRAIN_VERS_T2A = {
"Consultation externe": "FORFAIT_URGENCE",
"Hospitalisation": "REQUALIFICATION_HOSPITALISATION",
"Sortie après surveillance UHCD": "REQUALIFICATION_HOSPITALISATION",
"Transfert intra-hospitalier": "REQUALIFICATION_HOSPITALISATION",
# "Retour structure d'origine" volontairement non mappé (à discuter cliniquement)
# "" (chaîne vide) → dossier en attente, ne devrait pas atteindre cette logique
}
attendu = TERRAIN_VERS_T2A.get(decision_terrain) if decision_terrain else None
if decision_terrain is None:
logger.info("[t2a_compare_decision] Pas de décision terrain disponible")
elif attendu is None:
logger.info(f"[t2a_compare_decision] Libellé terrain non mappé : '{decision_terrain}'")
elif decision_llm != attendu:
logger.warning(
f"[t2a_compare_decision] DIVERGENCE : LLM={decision_llm} vs "
f"terrain={decision_terrain} (attendu T2A : {attendu})"
)
else:
logger.info(f"[t2a_compare_decision] Convergence : LLM et terrain concordent ({attendu})")
# NE PAS overrider la décision LLM ici. Le log sert uniquement à mesurer la
# divergence. Si un override existait déjà avant ce commit, ne PAS le toucher
# (sujet finding #1 à traiter dans un chantier séparé).
```
### PAS DE BANDEAU UI POUR L'INSTANT
Log fichier uniquement. On ajoutera un bandeau "À valider" plus tard si les
divergences sont fréquentes. Pour l'instant, on instrumente pour mesurer.
### VÉRIFICATION AVAL (30 secondes)
Avant de pousser commit 2, `grep "duree_passage_heures"` dans tout le repo.
Vérifier qu'aucun consommateur aval (rapport, export, BDD) ne dépend de la
valeur LLM. Si un consommateur le fait, signaler à Dom avant de modifier
le comportement par défaut (UI/BDD = valeur Python).
### INSTRUMENTATION DRY-RUN — OBLIGATOIRE
Pour le dry-run MOREL (et tout cas T2A quand le flag est actif), le serveur
DOIT logger 4 traces structurées, dans cet ordre temporel :
**1.** `[t2a_dryrun_metadata]` — APRÈS l'appel `build_dpi_enriched()`, AVANT l'appel LLM
- Contenu : dict metadata complet retourné par `build_dpi_enriched`
(toutes les valeurs Python : durée, âge, CCMU, GEMSA, decision_terrain,
orientation_terrain, parsing_warnings)
**2.** `[t2a_dryrun_prompt]` — JUSTE AVANT l'appel API LLM
- Contenu in extenso :
- System prompt (PROMPT 3 intégral, le texte effectivement envoyé)
- User message complet (DPI enrichi = FAITS_CALCULÉS + dpi_raw)
- Tout autre paramètre transmis à l'API (température, seed si défini, modèle)
- Format : 3 backticks markdown pour préserver l'indentation et la lisibilité
**3.** `[t2a_dryrun_response]` — APRÈS réception réponse LLM (cas succès)
- Contenu :
- JSON brut complet reçu, AVANT tout parsing
- Stats Ollama : `eval_count`, `eval_duration`, `prompt_eval_count`,
`prompt_eval_duration` (utile pour débit tokens/s)
- Modèle utilisé, latence totale
**4.** `[t2a_dryrun_error]` — UNIQUEMENT si exception ou code HTTP non-2xx
- Contenu :
- Type d'exception ou code HTTP
- Payload partiel reçu s'il y en a
- Stack trace courte
- Ce log compense le fait que `[t2a_dryrun_response]` ne sera pas écrit en
cas d'échec API.
#### DESTINATION DES LOGS — FICHIER DÉDIÉ
**NE PAS** écrire ces logs dans le log serveur courant (4-6k tokens de prompt
le rendraient illisible).
Écrire dans un fichier dédié horodaté :
```
logs/t2a_dryrun/<cas_id_ou_IPP>_<timestamp>.log
```
Où :
- `<cas_id_ou_IPP>` : identifiant du dossier traité (ex: `"25003284"` pour MOREL)
- `<timestamp>` : format ISO compact, ex: `20260512_143022`
Créer le dossier `logs/t2a_dryrun/` automatiquement s'il n'existe pas.
⚠️ Vérifier que `logs/t2a_dryrun/` est dans `.gitignore`. Si non, l'ajouter
dans le même commit. Les logs contiennent des données patient (même si
fictives MOREL en POC, c'est l'hygiène par défaut).
#### ACTIVATION VIA FLAG D'ENVIRONNEMENT
Variable : `T2A_DRYRUN=1`
Deux modes d'activation à documenter dans le commit message (ou un petit
`docs/dryrun.md` court) :
**Mode dev local (ad-hoc)** :
```bash
T2A_DRYRUN=1 python -m visual_workflow_builder.backend ...
```
**Mode service systemd (réversible, sans modifier le unit file en dur)** :
Créer `/etc/systemd/system/<nom_du_service>.d/dryrun.conf` avec :
```ini
[Service]
Environment=T2A_DRYRUN=1
```
Puis :
```bash
sudo systemctl daemon-reload && sudo systemctl restart <service>
```
Pour désactiver : supprimer le drop-in et redémarrer.
⚠️ Ne JAMAIS modifier le unit file principal en dur pour activer le dryrun.
Toujours passer par drop-in ou ad-hoc.
#### POURQUOI CETTE INSTRUMENTATION EST CRITIQUE
Le log "convergence/divergence" du garde-fou indique uniquement qu'il y a
un problème. Pour diagnostiquer, on a besoin de voir :
- Si FAITS_CALCULÉS est bien injecté et bien positionné en tête du prompt
- Si le DPI brut contient effectivement `"depuis 23h"` (piège dans Observ. IDE Urg)
- Comment le LLM justifie son éventuelle hallucination (champ `"justification"`
de sa réponse JSON, lecture humaine)
- Si l'éventuelle divergence vient d'un bug d'intégration (FAITS_CALCULÉS non
injecté), du LLM (ignore les FAITS_CALCULÉS), ou d'une erreur API
Sans ces 4 traces, un échec du dry-run est inexploitable et on ne pourra
pas décider de la suite (réécrire PROMPT 3 vs autre diagnostic).
#### NOTE FORMAT POUR EXTENSION FUTURE
Format markdown backticks est OK pour analyse manuelle sur 1-2 cas.
Si on étend à N cas en batch (post-démo), basculer en JSONL — pas
nécessaire aujourd'hui, juste à garder en tête. À mentionner dans la
dette [S3].
### VALIDATION COMMIT 2
- Dry-run sur DPI MOREL : exécuter le workflow Demo_urgence_2 (ou un appel
ciblé à la fonction de décision si possible sans tout le workflow), vérifier
dans les logs :
- `"Durée totale du passage : 3 heures et 37 minutes"` injecté dans le prompt
- Un log `[t2a_compare_duree]` présent avec convergence OU divergence
- Un log `[t2a_compare_decision]` présent avec convergence OU divergence
- Les 4 traces `[t2a_dryrun_*]` présentes dans `logs/t2a_dryrun/25003284_*.log`
- Commit message : `feat(t2a): garde-fous comparaison durée + décision Python vs LLM + instrumentation dry-run`
---
## MÉTHODE DE TRAVAIL (rappel CLAUDE.md)
- **Chirurgie** : un changement, un test, validation avant le suivant
- Pas de rustines structurelles non discutées
- Lecture du code AVANT proposition de modif (lis `_handle_t2a_decision_action`
AVANT de proposer ton intégration commit 2)
- Court de préférence dans tes retours à Dom
- Backup avant toute modif BDD (ici pas de schéma BDD touché normalement,
mais vérifie)
- **Pas de modification PROMPT 3 dans ces commits, sous aucun prétexte**
---
## LIVRABLES ATTENDUS
1. Résultat étape 0 (Synthèse Urgences + bandeau présents dans `dpi_raw` ? OUI/NON)
2. Commit 1 + tests verts (golden MOREL + test négatif)
3. Phase intermédiaire — mini-bench exécution #1 (post-commit 1, pré-commit 2) :
- 10 logs `logs/t2a_dryrun/<IPP>_*.log` écrits (1 dossier vide skippé)
- Tableau récap stdout (9 colonnes) + CSV
- Stats de fin de bench (taux convergence durée X/11, décision X/10)
4. Commit 2 + 4 traces `[t2a_dryrun_*]` dans `_handle_t2a_decision_action`
5. Mini-bench exécution #2 (post-commit 2) — validation finale
6. **Dry-run E2E Demo_urgence_2 sur MOREL** — OBLIGATOIRE après commit 2.
Le mini-bench ne remplace pas cette validation pipeline complète.
7. Bref retour à Dom :
- `duree_python` observée sur MOREL (attendu : 3.62)
- `duree_llm` observée sur MOREL
- Convergence ou divergence durée ?
- `decision_llm` observée sur MOREL (FORFAIT_URGENCE ou REQUALIFICATION ?)
- Convergence ou divergence décision ?
- **Synthèse mini-bench 11 dossiers** :
- Taux de convergence durée (X/11 utiles)
- Taux de convergence décision (X/10 mappés)
- Liste des dossiers en divergence + extrait du log (champ `justification`
LLM) pour comprendre l'erreur
- Patterns d'hallucination identifiés au-delà du "depuis 23h" MOREL
- Recommandation : faut-il réécrire PROMPT 3 dans la foulée ou la
comparaison montre que le LLM s'aligne déjà spontanément sur les
FAITS_CALCULÉS ?
**PAS DE GO POUR LA RÉÉCRITURE DU PROMPT 3 SANS VALIDATION EXPRESSE DE DOM
APRÈS LECTURE DU DRY-RUN.**
---
## ARBITRAGES — soulevés en revue [S2], tous tranchés par Dom (12 mai 2026)
1. ~~**CCMU/GEMSA — code seul ou code + libellé ?**~~
**TRANCHÉ** : libellé COMPLET tel qu'affiché dans la Synthèse Urgences,
intégré dans `ccmu` et `gemsa` (string unique, code + libellé). Brief mis à
jour dans `LOGIQUE D'EXTRACTION`, `METADATA RETOURNÉE`, `CONSTRUCTION DU BLOC
FAITS_CALCULÉS` et `TEST GOLDEN MOREL`.
2. ~~**Priorité IAO non extraite**~~
**TRANCHÉ** : OUI, extraire `priorite_iao` depuis la ligne "Priorité" de la
Synthèse Urgences, injecter dans FAITS_CALCULÉS juste après GEMSA, avant
Mode de venue. Format : `"- Priorité IAO : Priorité 3"`. Brief mis à jour
dans les 4 mêmes sections que ci-dessus.
3. ~~**Mapping `TERRAIN_VERS_T2A` — à enrichir AVANT commit 2**~~
**TRANCHÉ + COMPLÉTÉ (Dom, 12 mai)** : table définitive validée par Dom
sur les 11 dossiers POC, sans étape de grep intermédiaire :
```python
TERRAIN_VERS_T2A = {
"Consultation externe": "FORFAIT_URGENCE",
"Hospitalisation": "REQUALIFICATION_HOSPITALISATION",
"Sortie après surveillance UHCD": "REQUALIFICATION_HOSPITALISATION",
"Transfert intra-hospitalier": "REQUALIFICATION_HOSPITALISATION",
}
```
Cas spéciaux : `"Retour structure d'origine"` non mappé (à discuter cliniquement),
`""` = dossier en attente skippé par le bench. Implémentation recommandée :
module partagé `core/llm/t2a_mappings.py`. Voir sections PHASE INTERMÉDIAIRE
et LOGIQUE GARDE-FOU 2 du brief.
4. ~~**Avertissement explicite "depuis 23h" dans FAITS_CALCULÉS ?**~~
**TRANCHÉ** : **OPTION A — expérience pure, pas de filet**. On injecte
uniquement les faits dans FAITS_CALCULÉS, sans avertissement préventif sur
"depuis Xh" ou similaire.
**Raison** : le dry-run MOREL a une valeur diagnostique forte. Si on ajoute
le filet maintenant, on ne saura jamais si FAITS_CALCULÉS seul suffit ou si
c'est le filet qui sauve la mise. Cette information est nécessaire pour les
futurs cas qu'on n'aura pas anticipés.
**Plan B si le dry-run échoue** : si le dry-run montre que le LLM replonge
sur "depuis 23h" malgré FAITS_CALCULÉS en tête, [S1] patche PROMPT 3 (finding
#5) avec un avertissement ciblé. Mais cette décision n'est prise qu'APRÈS
observation du log, pas en anticipation.
⇒ **Aucune modification du brief sur ce point** : FAITS_CALCULÉS reste tel
que défini ci-dessus, sans ligne d'avertissement.
---
## STATUT FINAL DU BRIEF
✅ **Tous les arbitrages tranchés** (CCMU/GEMSA libellé complet, Priorité IAO,
mapping `TERRAIN_VERS_T2A` validé sur 10/11 cas, "depuis 23h" Option A pure,
paramètres LLM Option 2 `temp=0/seed=42`, filet `data_quality_warning` pour
incertitude anonymisation v1/v2).
⚠️ **2 points à clarifier en début d'exécution par [S1], non bloquants pour
ÉTAPE 0** :
1. ~~Incohérence compte mapping (10 cas annotés vs 9 mappés annoncés)~~ —
**résolue le 12 mai** : parsing empirique de `data.js` confirme 11 utiles
(0 en attente), 10 mappés, 1 non mappé (`Retour structure d'origine`).
2. Vérification empirique seed `gemma4:31b-cloud` (cf. PARAMÈTRES LLM).
🚀 **GO ÉTAPE 0**.
---
## CONTEXTE [S2] — pour traçabilité
Brief produit par session [S1], revu par session [S2] (audit éclair 30-45 min,
12 mai 2026). Voir aussi :
- `docs/handoffs/2026-05-12_handoff_S2_vers_S1.md` — synthèse audit éclair [S2]
- `docs/handoffs/2026-05-12_audit_complet_decision_t2a.md` — rapport [S1] de référence
- Doc DIM/TIM source : `~/Téléchargements/RPU UHCD IA (2) (5)/RPU UHCD IA/RPU UHCD IA.pptx`

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Codage — Easily Assure</title>
<link rel="stylesheet" href="easily.css">
</head>
<body>
<div class="app-header">
<div class="brand">Easily Assure <span class="sub">Urgences — Maquette POC</span></div>
<div class="user-zone">
<span>DR. Antoine BONNET</span>
<a href="index.html">Liste patients</a>
<a href="#" onclick="event.preventDefault(); window.resetCodage && window.resetCodage();">Reset</a>
<a href="#">Déconnexion</a>
</div>
</div>
<div class="menu-bar">
<a href="index.html">Patients</a>
<a href="#">Planning</a>
<a id="lien-dossier" href="dossier.html">Dossier en cours</a>
<a class="active" href="#">Codage</a>
<a href="#">Statistiques</a>
</div>
<div id="patient-banner" class="patient-banner">
<!-- Bandeau injecté par app.js -->
</div>
<div id="bandeau-attente" style="display:none; background:#fff8e0; border:2px solid #e0a800; padding:10px 16px; margin:0; color:#8a6d00; font-weight:bold;">
⚠ Dossier en attente — cas réel à transmettre par Pauline / Amina.
</div>
<div class="tabs">
<a class="tab" id="tab-vers-dossier" href="dossier.html">&lt; Retour dossier</a>
<a class="tab active">Arbre décisionnel UHCD / Forfait Urgences</a>
</div>
<div class="content aiva-page">
<div class="aiva-header">
<h1 class="aiva-title">aiva-vision — Aide à la décision de facturation urgences</h1>
<p class="aiva-subtitle">Forfait urgences vs requalification en hospitalisation MCO — décision T2A/PMSI assistée par LLM local</p>
</div>
<div class="aiva-layout">
<!-- COLONNE GAUCHE -->
<div class="aiva-col">
<h2><span class="icon">🔗</span> Dossier patient (DPI urgences)</h2>
<div class="aiva-col-label">Coller ou saisir le dossier patient</div>
<textarea id="dpi-input" class="aiva-dpi-textarea" placeholder="Coller ou saisir le dossier patient"></textarea>
<button id="btn-analyser" class="aiva-btn-primary">Analyser</button>
</div>
<!-- COLONNE DROITE -->
<div class="aiva-col">
<h2>Décision facturation</h2>
<!-- État loading (overlay pendant analyse) -->
<div id="aiva-result-loading" class="aiva-spinner aiva-result-hidden">
Analyse en cours…
</div>
<!-- Résultat (visible dès le chargement, état "neutre" en attente d'analyse) -->
<div id="aiva-result">
<div id="aiva-decision-banner" class="banner-empty">— En attente d'analyse —</div>
<div id="aiva-valorisation">&nbsp;</div>
<div id="aiva-verite-terrain">Vérité-terrain : <code></code></div>
<div class="aiva-metrics">
<div class="aiva-metric">
<div class="aiva-metric-label">Confiance</div>
<div class="aiva-metric-value" id="aiva-confiance"></div>
</div>
<div class="aiva-metric">
<div class="aiva-metric-label">Durée passage</div>
<div class="aiva-metric-value" id="aiva-duree"></div>
</div>
<div class="aiva-metric">
<div class="aiva-metric-label">Latence</div>
<div class="aiva-metric-value" id="aiva-latence"></div>
</div>
</div>
<div class="aiva-section-label">Justification</div>
<textarea id="aiva-justification" class="aiva-justif-textarea" placeholder="Justification de la décision (modifiable — Léa peut écrire avant l'analyse)"></textarea>
<div class="aiva-elements-grid">
<div>
<h3>Éléments pour hospitalisation</h3>
<ul id="aiva-elements-hospi"><li class="aiva-list-empty">Aucun élément pour le moment</li></ul>
</div>
<div>
<h3>Éléments pour forfait</h3>
<ul id="aiva-elements-forfait"><li class="aiva-list-empty">Aucun élément pour le moment</li></ul>
</div>
</div>
</div>
</div>
</div>
<div style="margin-top:20px; display:flex; gap:10px;">
<a class="btn large" id="btn-retour-dossier" href="dossier.html">&lt; Retour dossier</a>
</div>
</div>
<div class="app-footer">
Maquette pédagogique — données fictives — usage interne POC GHT Sud 95
</div>
<script src="data.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,654 @@
/* Maquette Easily Assure — palette inspirée des 4 captures dossier 25003284
Objectifs :
- lisibilité OCR (Tahoma 13-14px, contrastes nets)
- éléments cliquables identifiables (cursor, hover, classes .clickable)
- rendu cohérent navigateur Windows / Linux (polices système)
*/
* { box-sizing: border-box; }
body {
margin: 0;
padding: 0;
font-family: Tahoma, Geneva, Verdana, "DejaVu Sans", Arial, sans-serif;
font-size: 13px;
color: #000;
background: #f5f5f5;
}
/* ===== Barre titre application ===== */
.app-header {
background: linear-gradient(to bottom, #4a7ba8, #2c5d8c);
color: #fff;
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #1a3d5c;
height: 42px;
}
.app-header .brand {
font-size: 18px;
font-weight: bold;
letter-spacing: 0.5px;
}
.app-header .brand .sub {
font-size: 12px;
font-weight: normal;
margin-left: 12px;
opacity: 0.85;
}
.app-header .user-zone {
font-size: 12px;
display: flex;
gap: 16px;
align-items: center;
}
.app-header .user-zone a {
color: #cfe3f5;
text-decoration: none;
}
.app-header .user-zone a:hover { text-decoration: underline; }
/* ===== Barre menu principal ===== */
.menu-bar {
background: #d5e3f0;
border-bottom: 1px solid #8aa9c8;
padding: 0 16px;
display: flex;
height: 32px;
align-items: stretch;
}
.menu-bar a {
padding: 0 14px;
display: flex;
align-items: center;
color: #1a3d5c;
text-decoration: none;
font-size: 13px;
border-right: 1px solid #b8cee0;
cursor: pointer;
}
.menu-bar a:hover { background: #e8f0f8; }
.menu-bar a.active { background: #fff; font-weight: bold; }
/* ===== Bandeau patient ===== */
.patient-banner {
background: #fff;
border-bottom: 2px solid #4a7ba8;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 24px;
}
.patient-banner .ipp {
font-size: 14px;
font-weight: bold;
color: #2c5d8c;
}
.patient-banner .nom {
font-size: 16px;
font-weight: bold;
}
.patient-banner .info-bloc {
color: #444;
font-size: 12px;
}
.patient-banner .info-bloc b { color: #000; }
/* ===== Onglets dossier ===== */
.tabs {
background: #e8eef5;
border-bottom: 2px solid #4a7ba8;
display: flex;
padding: 0 12px;
height: 36px;
align-items: stretch;
}
.tabs .tab {
padding: 0 18px;
display: flex;
align-items: center;
background: #c8d6e6;
border: 1px solid #8aa9c8;
border-bottom: none;
margin-right: 2px;
margin-top: 6px;
font-size: 13px;
cursor: pointer;
color: #1a3d5c;
text-decoration: none;
}
.tabs .tab:hover { background: #d5e3f0; }
.tabs .tab.active {
background: #fff;
font-weight: bold;
border-bottom: 1px solid #fff;
margin-bottom: -1px;
color: #000;
}
/* ===== Contenu principal ===== */
.content {
background: #fff;
padding: 12px 16px 32px 16px;
min-height: calc(100vh - 200px);
}
/* ===== Sections collapsibles (vues docx 1, 2, 3) ===== */
.section {
margin-bottom: 14px;
border: 1px solid #8aa9c8;
background: #fff;
}
.section-header {
background: linear-gradient(to bottom, #c8d8e8, #a8c4dc);
border-bottom: 1px solid #8aa9c8;
padding: 6px 10px;
font-weight: bold;
color: #1a3d5c;
font-size: 13px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.section-header::before {
content: "▼";
font-size: 10px;
color: #2c5d8c;
}
.section.collapsed .section-header::before { content: "▶"; }
.section.collapsed .section-body { display: none; }
.section-body {
padding: 10px;
background: #fafdff;
}
/* ===== Tables génériques ===== */
table.data {
border-collapse: collapse;
width: 100%;
font-size: 13px;
}
table.data th, table.data td {
border: 1px solid #b8cee0;
padding: 5px 8px;
text-align: left;
vertical-align: top;
}
table.data th {
background: #d5e3f0;
color: #1a3d5c;
font-weight: bold;
}
table.data tr:nth-child(even) td { background: #f0f7fc; }
table.data .num { text-align: right; font-family: "Courier New", monospace; }
/* Liens cliquables avec contraste fort pour OCR */
a.link, .link {
color: #0033cc;
text-decoration: underline;
cursor: pointer;
}
a.link:hover, .link:hover { color: #cc0000; }
/* Valeurs surlignées (signes vitaux anormaux) */
.warn { color: #b00020; font-weight: bold; }
/* ===== Synthèse Urgences (capture 4) ===== */
.synthese-titre {
background: linear-gradient(to bottom, #6090c0, #2c5d8c);
color: #fff;
padding: 10px 14px;
font-size: 16px;
font-weight: bold;
margin-bottom: 0;
}
.synthese table {
width: 100%;
border-collapse: collapse;
margin-bottom: 14px;
}
.synthese .row-titre td {
background: linear-gradient(to bottom, #c8d8e8, #a8c4dc);
font-weight: bold;
color: #1a3d5c;
padding: 6px 10px;
border: 1px solid #8aa9c8;
}
.synthese td.label {
background: #f5f5f5;
width: 230px;
padding: 5px 10px;
border: 1px solid #d0d8e0;
font-weight: normal;
color: #333;
}
.synthese td.value {
padding: 0;
border: 1px solid #d0d8e0;
background: #fff;
}
.synthese td.value input,
.synthese td.value textarea,
.synthese td.value select {
width: 100%;
border: none;
background: #e8f5d8;
padding: 5px 8px;
font-family: inherit;
font-size: 13px;
color: #000;
}
.synthese td.value textarea {
min-height: 70px;
resize: vertical;
}
/* ===== Onglet Codage T2A — algo PPTX ===== */
.algo-card {
border: 1px solid #8aa9c8;
background: #fff;
padding: 16px 20px;
margin-bottom: 14px;
}
.algo-card h2 {
margin: 0 0 8px 0;
color: #1a3d5c;
font-size: 16px;
}
.algo-card .preuves {
font-size: 12px;
color: #444;
margin-top: 6px;
padding-left: 22px;
}
.criterion {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 10px 0;
}
.criterion input[type=checkbox] {
width: 22px;
height: 22px;
margin-top: 2px;
cursor: pointer;
}
.criterion label {
font-size: 14px;
cursor: pointer;
user-select: none;
}
.preuve-zone {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #c8d6e6;
}
.preuve-zone-label {
display: block;
font-size: 12px;
color: #555;
margin-bottom: 4px;
font-style: italic;
}
.preuve-text {
width: 100%;
min-height: 140px;
padding: 10px 12px;
border: 1px solid #8aa9c8;
background: #fffef0;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
color: #000;
resize: vertical;
box-sizing: border-box;
}
.preuve-text:focus {
outline: 2px solid #4a7ba8;
background: #fff;
}
.preuve-text::placeholder {
color: #999;
font-style: italic;
}
.verdict {
margin-top: 18px;
padding: 14px 18px;
border: 2px solid #999;
background: #f5f5f5;
font-size: 16px;
font-weight: bold;
}
.verdict.uhcd {
border-color: #2e7d32;
background: #e8f5e8;
color: #2e7d32;
}
.verdict.forfait {
border-color: #e65100;
background: #fff3e0;
color: #b25000;
}
.btn {
display: inline-block;
padding: 7px 16px;
background: linear-gradient(to bottom, #4a7ba8, #2c5d8c);
color: #fff;
border: 1px solid #1a3d5c;
cursor: pointer;
font-size: 13px;
font-family: inherit;
text-decoration: none;
border-radius: 2px;
}
.btn:hover { background: linear-gradient(to bottom, #5a8bb8, #3c6d9c); }
.btn.large { padding: 10px 22px; font-size: 14px; }
/* Pied de page */
.app-footer {
text-align: center;
color: #888;
font-size: 11px;
padding: 10px;
border-top: 1px solid #ddd;
background: #f5f5f5;
}
/* Liste patients (index.html) */
.search-bar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 14px;
}
.search-bar input[type=text] {
padding: 5px 8px;
font-size: 13px;
border: 1px solid #8aa9c8;
width: 240px;
}
.patient-list-table tr.row-clickable { cursor: pointer; }
.patient-list-table tr.row-clickable:hover td { background: #fffbe0; }
/* ===========================================================
AIVA-VISION — Aide à la décision de facturation urgences
(palette claire alignée sur Easily Assure)
=========================================================== */
.aiva-page {
padding: 0 4px;
}
.aiva-header {
margin-bottom: 18px;
padding-bottom: 10px;
border-bottom: 1px solid #c8d6e6;
}
.aiva-title {
font-size: 22px;
font-weight: bold;
color: #1a3d5c;
margin: 0 0 4px 0;
}
.aiva-subtitle {
font-size: 12px;
color: #666;
margin: 0;
}
/* Layout 2 colonnes */
.aiva-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
align-items: start;
}
@media (max-width: 1100px) {
.aiva-layout { grid-template-columns: 1fr; }
}
.aiva-col {
border: 1px solid #8aa9c8;
background: #fff;
padding: 14px 16px;
box-sizing: border-box;
}
.aiva-col h2 {
margin: 0 0 10px 0;
font-size: 16px;
color: #1a3d5c;
display: flex;
align-items: center;
gap: 6px;
}
.aiva-col h2 .icon {
font-size: 14px;
color: #4a7ba8;
}
.aiva-col-label {
font-size: 12px;
color: #555;
margin-bottom: 6px;
}
/* Colonne gauche — DPI */
.aiva-dpi-textarea {
width: 100%;
min-height: 360px;
padding: 10px 12px;
border: 1px solid #8aa9c8;
background: #fffef0;
font-family: inherit;
font-size: 13px;
line-height: 1.5;
color: #000;
resize: vertical;
box-sizing: border-box;
margin-bottom: 12px;
}
.aiva-dpi-textarea:focus {
outline: 2px solid #4a7ba8;
background: #fff;
}
.aiva-dpi-textarea::placeholder {
color: #999;
font-style: italic;
}
.aiva-btn-primary {
width: 100%;
padding: 12px 16px;
background: linear-gradient(to bottom, #4a7ba8, #2c5d8c);
color: #fff;
border: 1px solid #1a3d5c;
cursor: pointer;
font-size: 14px;
font-weight: bold;
font-family: inherit;
border-radius: 2px;
letter-spacing: 0.5px;
}
.aiva-btn-primary:hover {
background: linear-gradient(to bottom, #5a8bb8, #3c6d9c);
}
.aiva-btn-primary:disabled {
background: #b8cee0;
cursor: wait;
color: #fff;
}
/* Colonne droite — Décision facturation */
#aiva-decision-banner {
padding: 16px 18px;
font-size: 18px;
font-weight: bold;
text-align: center;
color: #fff;
margin-bottom: 8px;
letter-spacing: 0.5px;
border: 2px solid transparent;
}
#aiva-decision-banner.banner-forfait {
background: linear-gradient(to bottom, #4caf50, #2e7d32);
border-color: #1b5e20;
}
#aiva-decision-banner.banner-uhcd {
background: linear-gradient(to bottom, #e57373, #c62828);
border-color: #8e0000;
}
#aiva-decision-banner.banner-empty {
background: #f5f5f5;
color: #888;
border: 1px dashed #b8cee0;
font-weight: normal;
font-size: 14px;
}
#aiva-valorisation {
text-align: center;
color: #444;
font-size: 13px;
margin-bottom: 14px;
}
#aiva-verite-terrain {
background: #f0f7fc;
border-left: 3px solid #4a7ba8;
padding: 6px 10px;
font-size: 12px;
color: #333;
margin-bottom: 14px;
}
#aiva-verite-terrain code {
background: #1a3d5c;
color: #ffeb3b;
padding: 1px 6px;
border-radius: 2px;
font-family: "Courier New", monospace;
font-size: 11px;
}
#aiva-verite-terrain .concordance-ok { color: #2e7d32; font-weight: bold; }
#aiva-verite-terrain .concordance-ko { color: #c62828; font-weight: bold; }
/* 3 indicateurs (Confiance / Durée / Latence) */
.aiva-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.aiva-metric {
background: #f0f7fc;
padding: 8px 10px;
border: 1px solid #d0e0ec;
}
.aiva-metric-label {
font-size: 11px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.aiva-metric-value {
font-size: 18px;
font-weight: bold;
color: #1a3d5c;
font-family: "Courier New", monospace;
}
/* Justification */
.aiva-section-label {
font-size: 12px;
font-weight: bold;
color: #1a3d5c;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#aiva-justification {
background: #e3f2fd;
border: 1px solid #90caf9;
border-left: 3px solid #1976d2;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
color: #0d47a1;
margin-bottom: 16px;
}
/* Variante <textarea> de la justification (éditable par Léa). */
textarea#aiva-justification {
width: 100%;
min-height: 96px;
box-sizing: border-box;
font-family: inherit;
resize: vertical;
}
textarea#aiva-justification:focus {
outline: 2px solid #1976d2;
background: #fff;
}
textarea#aiva-justification::placeholder {
color: #6b8db5;
font-style: italic;
}
/* Listes éléments hospitalisation / forfait */
.aiva-elements-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.aiva-elements-grid h3 {
font-size: 13px;
color: #1a3d5c;
margin: 0 0 6px 0;
}
.aiva-elements-grid ul {
margin: 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.5;
color: #333;
}
.aiva-elements-grid li {
margin-bottom: 4px;
}
.aiva-elements-grid li.aiva-list-empty {
color: #888;
font-style: italic;
list-style: none;
margin-left: -18px;
}
/* État caché par défaut (avant clic Analyser) */
.aiva-result-hidden { display: none; }
.aiva-result-empty {
text-align: center;
color: #888;
font-style: italic;
padding: 80px 20px;
border: 1px dashed #c8d6e6;
}
/* Spinner pendant analyse */
.aiva-spinner {
text-align: center;
padding: 60px 20px;
color: #4a7ba8;
font-size: 14px;
}
.aiva-spinner::before {
content: "⏳";
display: block;
font-size: 32px;
margin-bottom: 10px;
animation: aiva-rotate 1.2s linear infinite;
}
@keyframes aiva-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,88 @@
# Handoff [S2] → [S1] — Audit éclair prompts t2a (12 mai 2026)
## Mission [S2]
Audit qualité des 3 prompts (resume_patient, justification_t2a, t2a_decision)
sur cas MOREL réel + doc DIM/TIM officielle de l'établissement.
Time-box 30-45 min. Pas de code touché.
## Sources consultées
- 3 prompts collés par Dom (versions actuelles en prod)
- Doc DIM/TIM : `/home/dom/Téléchargements/RPU UHCD IA (2) (5)/RPU UHCD IA/RPU UHCD IA.pptx`
→ Slide 6 = règle officielle de l'établissement : **3 critères validés → UHCD ; 1 critère manquant → Forfait Urgences** (conjonction ET, 3/3)
- Capture UI aiva-vision sur dossier MOREL Catherine (77 ans, IPP 25003284,
passage 01/01/2025 03:12 → 06:49 = 3h37 réel)
## Bugs confirmés en condition réelle (cas MOREL)
### 🔴 #1 — Contradiction décision ↔ justification (NOUVEAU vs audit [S1])
- UI affiche `FORFAIT URGENCES (FFU/ATU)` en vert
- Le texte de justification conclut littéralement à `requalification en hospitalisation (UHCD)`
- Le PROMPT 2 dit « Tu ne dois PAS changer la décision » mais le résultat suggère
que la décision a été overridée APRÈS le LLM-justif (par la vérité-terrain ?),
sans recalculer la justif ni les listes `elements_pour_*`.
- À investiguer côté serveur, vraisemblablement dans `_handle_t2a_decision_action`
(ordre d'application : LLM-décision → override vérité-terrain → LLM-justif
appelée avec la décision corrigée OU pas du tout ?).
### 🔴 #2 — Hallucination durée (déjà connu, reconfirmé)
- Durée affichée : **23.0 h**
- Durée réelle calculable depuis bandeau Easily Assure : **3h37**
- LLM confond « symptômes depuis 23h » avec « durée du passage »
- C'est probablement la cause amont du bug #1 (23h pousse mécaniquement vers UHCD)
### 🟡 #3 — Listes elements_pour_* inversées
- `elements_pour_hospitalisation` contient « Sortie en consultation externe avec
point de contrôle à 48h » → c'est un argument POUR le forfait
- `elements_pour_forfait` : « Aucun élément cité par le LLM »
- Cohérent avec l'hypothèse override (le LLM avait raisonné UHCD)
### 🟡 #4 — Confiance "faible" ignorée par l'UI
- LLM marque sa propre confiance faible, l'UI affiche la décision sans warning
- Pour audience DIM, c'est rédhibitoire
### 🟡 #5 — Le PROMPT 3 contredit la doc DIM/TIM interne
- Prompt actuel : « au moins 2 sur 3 validés ⇒ REQUALIFICATION »
- Doc DIM slide 6 : « si oui aux 3 critères ⇒ UHCD ; 1 critère manquant ⇒ Forfait »
- Donc le prompt n'est pas seulement non-conforme à l'ATIH, il est aussi
non-conforme à la propre doc de l'établissement client.
- Idem pour le seuil « durée > 6h » dans le critère 2 : ABSENT de la PPTX,
confirme que c'est une erreur du DIM senior à retirer.
## Sur MOREL spécifiquement
Si on applique strictement la règle 3/3 de la doc :
- Critère 1 (pathologie évolutive) ✓ (pneumopathie + 77 ans + insuf coro + asthme)
- Critère 2 (surveillance prolongée) **✗** (3h37 réels, observations itératives non documentées)
- Critère 3 (examens/actes) ✓ (radio + biol + PCR VRS + aérosols + Augmentin)
⇒ 2/3 ⇒ **Forfait Urgences** (verdict correct)
La décision affichée actuelle est juste, mais **pour la mauvaise raison** :
le pipeline tombe sur le bon résultat via la vérité-terrain en override,
pas via un raisonnement LLM correct. Faux positif de concordance.
## Reco prioritaire — ordre des chantiers
1. **Préprocesseur Python `build_dpi_enriched`** (chantier déjà identifié en [S1])
→ calcul déterministe durée depuis horaires bandeau, injection
`FAITS_CALCULÉS: durée_passage = 3h37` dans le prompt.
→ C'est le levier #1. Sans ça, tout LLM continuera à halluciner.
2. **Réécrire PROMPT 3** avec règle 3/3 ET (texte Section F du rapport
`docs/handoffs/2026-05-12_audit_complet_decision_t2a.md`) + retirer seuil 6h.
3. **Garde-fou serveur** dans `_handle_t2a_decision_action` :
- Si `confiance == "faible"` → bandeau UI « À valider »
- Si override vérité-terrain appliqué → recalculer LLM-justif AVEC la décision
corrigée, ou afficher explicitement la divergence.
4. **Ajouter CCMU + GEMSA + mode_venue + occupation_lit** dans le contexte LLM
(slide 5 doc DIM, manquants dans le prompt actuel). Post-démo si time-box serré.
## Ce que [S2] N'A PAS fait (par choix de time-box)
- Pas lu le DOCX compagnon `RPU UHCD IA.docx` (PPTX déjà suffisamment claire)
- Pas grep des chemins fichiers ni vérif paramètres Ollama (temp/seed)
- Pas touché de code
## Synchro [S1]
Findings #1 et #5 (override vérité-terrain non recalculé + non-conformité doc
établissement) sont nouveaux vs ton audit du 12 mai. Le reste reconfirme.

View File

@@ -0,0 +1,173 @@
# Handoff fin de journée — 12 mai 2026
**Période couverte** : 09:16 → 19:30 (avec coupure déjeuner ~13:00-13:45 et vélo 18:00-19:27)
**Démo cible** : 15 mai 2026 (3 jours restants)
**Statut global** : très en avance sur planning matinal, qualité non-minimale visée maintenue.
---
## TL;DR — À lire en premier demain matin
1. **PRIORITÉ #1 demain** : bug d'orchestration skip ord 13 découvert en fin de journée. Criticité HAUTE pour démo. Voir fiche dédiée `_archives/handoff_evidence_20260512_1940/DETTE_S1_skip_ord13_orchestration.md` + trace JSON `replay_fb0c9882_state.json`.
2. **PRIORITÉ #2 demain** : reprise du mini-bench décision sur 11 dossiers POC dès que gemma4:31b-cloud répond (avorté aujourd'hui après 2-3 dossiers sur timeout Ollama Cloud 503).
3. **Acquis majeur du jour** : A.1 paste validé techniquement (65s → 2s sur step #25, 762 chars). Gain visible énorme sur la démo.
4. **2 commits décision en local non pushés** : à arbitrer demain matin avant de reprendre.
---
## État des chantiers à la coupure
### Chantier décision (`build_dpi_enriched` + garde-fous)
| Étape | Statut |
|---|---|
| Brief V3 finalisé (8 arbitrages tranchés) | ✅ `docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md` |
| Étape 0 (vérif source données) | ✅ close sur inférence statique + capture MOREL |
| Commit 1 `build_dpi_enriched` (commit `9872f4510`) | ✅ local, **NON pushé** |
| Tests : golden MOREL + négatif + intégration | ✅ 41/41 verts, 0 régression sur 37 tests existants |
| Commit "bench" `bench_t2a_dryrun.py` + `t2a_mappings.py` (commit `f2212e77e`) | ✅ local, **NON pushé** |
| Mini-bench exécution sur 11 dossiers | 🔴 avorté après 2-3 dossiers (timeout gemma4:31b-cloud) |
| Commit 2 du brief (garde-fou serveur) | ❌ non démarré (dépend du bench) |
| Dry-run E2E Demo_urgence_2 sur MOREL | ❌ non démarré |
**Note vocabulaire** : "commit bench" ≠ "commit 2 du brief". Le commit 2 du brief = garde-fou Python ↔ LLM dans `_handle_t2a_decision_action`, pas encore commencé.
**État git branche décision** :
```
HEAD : f2212e77e (feat: bench + mappings) — local
9872f4510 (feat: build_dpi_enriched) — local
2 commits en avance sur origin/feature/qw-seuite-mai
```
### Chantier vitesse (A.1 paste + B parallélisation)
| Étape | Statut |
|---|---|
| Baseline run 4 instrumentée (timing) | ✅ 381s mesurés, goulots identifiés |
| Bascule γ → δ (A.1 d'abord, B après) | ✅ acté en cours de journée sur chiffres réels |
| A.1 paste implémenté (executor.py + DB) | ✅ |
| Test E2E run 6 — preuve gain | ✅ step #25 paste 762 chars en 2,02s vs 65s baseline |
| qwen3-next:80b-cloud testé qualité | ✅ run 8 dec + resume_patient cliniquement propres (1336 chars) — **switch ponctuel pour test, pas durable** |
| B parallélisation Ollama | ❌ annulé (A.1 plus rentable, β reporté [S3]) |
| Run 8 final | 🔴 cassé sur orchestration (bug skip ord 13 découvert) |
---
## Décisions et arbitrages tranchés aujourd'hui
### Chantier décision (8 arbitrages)
1. **CCMU/GEMSA dans FAITS_CALCULÉS** : libellé COMPLET, pas code seul (test Ollama a montré que gemma4 hallucine, gemma3 confabule pire — cf. constat empirique ci-dessous)
2. **Priorité IAO** : extraite et injectée dans FAITS_CALCULÉS (champ `motif.priorite` du data.js)
3. **Mapping TERRAIN_VERS_T2A** : 4 entrées validées sur grep data.js (Consultation externe, Hospitalisation, Sortie après surveillance UHCD, Transfert intra-hospitalier). "Retour structure d'origine" (dossier IPP 25012257, BRUNEL Henri) laissé non mappé → question à poser à Pauline/Amina.
4. **Mise en garde "depuis 23h"** : Option A pure (pas d'avertissement injecté), valeur diagnostique du dry-run préservée
5. **Décision médicale terrain** : extraite dans metadata mais PAS injectée dans FAITS_CALCULÉS (scénario C) → audit indépendant LLM + comparaison serveur a posteriori
6. **Paramètres LLM bench** : Option 2 (temperature=0, seed=42 côté bench uniquement) → vérifier empiriquement si gemma4:31b-cloud respecte le seed
7. **Filet `data_quality_warning`** : flagging incohérences cliniques détectables sans correction (anonymisation data.js statut incertain)
8. **Test négatif** = test de dégradation gracieuse (input dégradé, pas test qui doit échouer)
### Chantier vitesse
- **Bascule γ → δ** : A.1 paste d'abord (gain 94s, effort 30-45min, risque faible) avant B parallélisation (gain révisé 55-65s après mesure honnête). Bascule justifiée par recalcul ratio parallélisme (1,27× et non 2,7× après séparation cold-start)
- **Paste opt-in** : workflows non-Citrix explicitement marqués `paste: true`, fallback char-by-char par défaut
- **Source de vérité parallélisation Ollama** : mesure 2 appels CHAUDS en série vs parallèle, pas comparaison cold-start vs warm (piège initial corrigé)
---
## Constats empiriques importants
### Ollama Cloud sur nomenclatures PMSI françaises (test direct)
- **gemma4:31b-cloud** : connaissance partielle, fausse dans les détails (CCMU/GEMSA confondus avec logique GHM "ressources consommées"), mais reste dans le champ correct (classification individuelle). Le bloc *Thinking* montre une auto-conscience d'incertitude.
- **gemma3:27b-cloud** : **confabulation pure**. Invente l'acronyme GEMSA ("Gestion des Mouvements de Patients et des Stocks"), transforme CCMU en plan blanc niveau crise sanitaire, présente avec assurance maximale et lien officiel inventé. Beaucoup plus dangereux que gemma4 (pas d'auto-doute).
- **Conséquence design** : libellé complet PMSI obligatoire dans FAITS_CALCULÉS quel que soit le modèle.
- **Conséquence choix modèle** : NE PAS envisager gemma3:27b comme fallback gemma4 sur T2A. Pour fallback post-démo : viser modèle médical français spécialisé (DrBERT/edsnlp) ou fine-tuning.
- **Méthode validée** : test Ollama direct 30 sec avant tout pari sur la connaissance d'un modèle, y compris pour changement de modèle (pas seulement initialisation).
### Méthodes sanctuarisées
1. **"Sauvegarde + fork avant chantiers parallèles"** — appliqué ce matin spontanément, a payé toute la journée. À sanctuariser dans CLAUDE.md comme prérequis avant tout chantier multi-branches.
2. **"Instrumenter avant optimiser"** — appliqué 2 fois aujourd'hui, 2 corrections de cap décisives :
- Baseline run 4 a transformé "97% opaque" en goulots chiffrés
- Mesure parallélisme v2 a corrigé un ratio 2,7× → 1,27× et fait basculer l'arbitrage γ → δ
3. **"Test Ollama direct avant pari sur connaissance LLM"** — applicable à toute classification spécialisée (PMSI, CIM-10, CCAM, etc.)
4. **"Mesurer 2 conditions comparables, jamais cold-start vs warm"** — piège classique de mesure de parallélisme corrigé en cours de journée.
5. **`git status` systématique en début de session Claude Code** — incident commit composite (4 fichiers backend + 2 fichiers frontend stagés non liés) corrigé proprement par Option A reset+restore. À sanctuariser pour éviter récidive.
---
## Dette tracée [S3] (à traiter post-démo)
### Critique pour démo 15 mai
- **DETTE [S1] skip ord 13 orchestration** : bug d'orchestration découvert run 8, voir fiche dédiée `_archives/handoff_evidence_20260512_1940/DETTE_S1_skip_ord13_orchestration.md`. Criticité HAUTE. NOT REPRO 100% (runs 2-3 ce matin OK). Investigation : `replay_engine.py` + `api_stream.py`, mécanisme "server side action" et transition serveur → visuel → serveur.
- **DETTE [S2] faux positif click_anchor "Codage"** (run 1 matin) — peut-être lié à S1 ?
- **Robustesse Ollama Cloud en démo** : NON COUVERT. Panne 503 vécue aujourd'hui (gemma4:31b temporarily overloaded). Si panne pendant démo le 15 → fallback nécessaire. Options : modèle local sur RTX 5070 12GB (qualité dégradée Q3/Q2 inacceptable sur gemma4:31b), démo enregistrée en backup, ou autre modèle local équivalent.
### Optimisations post-démo
- **B parallélisation Ollama** : reporté, gain 55-65s sur 381s, à reprendre après stabilisation démo
- **Verify post-click** : trop coûteux (15-25s gaspillés sur 8 clics)
- **Warmup gemma4:31b-cloud** : pour neutraliser cold-start (~40s)
- **Re-test A.1 paste isolé sur gemma4:31b-cloud post-incident** Ollama
- **Crop OCR zone visible utile** : gain 10-20s
### Décision qualité
- **Mapping TERRAIN_VERS_T2A** : libellé "Retour structure d'origine" (dossier 25012257 BRUNEL Henri) à arbitrer cliniquement avec Pauline/Amina
- **Email Pauline/Amina** : à finaliser une fois résultats mini-bench disponibles. Questions candidates :
1. "Retour structure d'origine" → quelle catégorie T2A ?
2. Règle 3/3 ET (doc DIM établissement) vs règle ATIH 2/3 (PROMPT 3 actuel) — confirmation pratique ?
3. Critère 2 (surveillance prolongée) — signaux concrets attendus dans DPI ?
4. Critère 3 (examens/actes) — un acte CCAM suffit, ou acte ET examen ?
5. "Sortie après surveillance UHCD" vs "Hospitalisation" vs "Transfert intra-hospitalier" — tous REQUALIFICATION ?
6. Bandeau UI "à valider manuellement" si LLM confiance "faible" ?
- **CCMU + GEMSA + mode de venue + diagnostic principal** dans FAITS_CALCULÉS : intégré (élargissement Option Élargie pris en cours de journée)
- **Override vérité-terrain dans `_handle_t2a_decision_action`** (finding #1 audit) : à traiter en parallèle de commit 2
### Méthode
- Sanctuariser dans CLAUDE.md : `git status` initial, sauvegarde+fork avant chantiers parallèles, instrumenter avant optimiser, test Ollama direct, mesure comparable
- **Communication Dom ↔ Claude Code × 2 ↔ Claude session principale** : déperdition d'information observée (3 arbitrages décision retransmis 2 fois). Pattern à formaliser : Dom pointe Claude Code vers fichiers de référence rédigés en session principale (brief md) plutôt que paraphraser en chat.
### Asymétrie mémoire Claude session principale ↔ Dom
À traiter en session dédiée (pas en passant) : Dom maintient une mémoire active des patterns de Claude entre sessions, Claude n'a accès qu'aux notes uploadées ponctuellement. Asymétrie connue, signalée à plusieurs reprises aujourd'hui.
---
## Points ouverts à reprendre demain en priorité
### Matin (priorité par ordre)
1. **Investigation bug skip ord 13** (orchestration) — priorité HAUTE. Voir fiche dédiée + trace JSON. Investigation `replay_engine.py` et `api_stream.py`, mécanisme server-side action et transition serveur → visuel → serveur.
2. **Arbitrage push commits décision** (`9872f4510` + `f2212e77e`) — décider si on push avant ou après le bench complet.
3. **Reprise mini-bench décision** sur 11 dossiers POC dès que gemma4:31b-cloud répond (ou décider de basculer sur gemma4 local si panne persiste). Sortie attendue : tableau récap convergence durée + décision sur 10 dossiers utiles.
4. **Commit 2 du brief décision** (garde-fou serveur) après bench validé.
5. **Dry-run E2E Demo_urgence_2 sur MOREL** après commit 2.
### Aprem (si temps)
- Email Pauline/Amina (rédigé sur la base des résultats bench)
- Réécriture éventuelle PROMPT 3 selon résultats convergence
- Investigation faux positif click_anchor "Codage" (run 1 matin) — possiblement même cause que skip ord 13
- B parallélisation si tout le reste est stable
### Décision stratégique en suspens
- **Fallback Ollama Cloud pour démo 15 mai** : à arbitrer. Options à instruire : gemma4 local (qualité quantization à vérifier), qwen3-next:80b (testé qualité aujourd'hui sur run 8), démo enregistrée backup.
---
## Annexes
- Trace bug skip ord 13 : `_archives/handoff_evidence_20260512_1940/replay_fb0c9882_state.json` (129 Ko, 1167 lignes)
- Fiche dette dédiée : `_archives/handoff_evidence_20260512_1940/DETTE_S1_skip_ord13_orchestration.md`
- Synthèse Claude Code journée : `_archives/handoff_evidence_20260512_1940/SYNTHESE_journee_handoff.md`
- Sceau démo matin : tag git `demo-stable-2026-05-12` + tarball 23 Go + SHA256 vérifiés
- Backups DB : chaîne de 4 sur la journée (`backups_db_chain.txt`)
---
**Coupe nette ce soir. Pas d'autre tentative. Le vélo a coupé proprement, ne replonge pas dans le code après 20h.**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
"""Aide à la décision de facturation urgences T2A/PMSI via LLM local.
Décide si un passage aux urgences relève :
- du FORFAIT_URGENCE (passage simple, retour à domicile)
- de la REQUALIFICATION_HOSPITALISATION (séjour MCO, valorisation 1k-5k€+)
Le prompt impose une extraction littérale des faits du DPI (pas d'invention)
et une modulation honnête de la confiance. Validé sur 15 DPI synthétiques :
qwen2.5:7b atteint 100 % d'accuracy en ~5 s/cas avec 4,7 Go VRAM.
Voir docs/clients/ght_sud_95/ et demo/facturation_urgences/RESULTATS.md pour le
bench comparatif des 11 LLMs évalués.
"""
from __future__ import annotations
import json
import logging
import os
import time
import urllib.error
import urllib.request
from typing import Any, Dict
logger = logging.getLogger(__name__)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/generate")
DEFAULT_MODEL = os.environ.get("T2A_MODEL", "qwen2.5:7b")
DEFAULT_TIMEOUT = 60 # secondes
PROMPT_TEMPLATE = """Tu es médecin DIM (Département d'Information Médicale), expert en facturation T2A/PMSI aux urgences hospitalières en France.
Analyse le dossier patient ci-dessous pour déterminer si le passage relève :
- FORFAIT_URGENCE : passage simple, retour à domicile, sans surveillance prolongée ni soins continus
- REQUALIFICATION_HOSPITALISATION : séjour MCO requis selon les 3 critères PMSI/ATIH
LES 3 CRITÈRES UHCD (au moins 2 sur 3 validés ⇒ REQUALIFICATION) :
1. Pathologie potentiellement évolutive (instabilité hémodynamique, terrain à risque, traitement nécessitant adaptation)
2. Surveillance médicale et paramédicale prolongée (constantes itératives, observations IDE/médecin, durée > 6 h)
3. Examens complémentaires ou actes thérapeutiques (biologie, imagerie, sutures, gestes techniques)
INSTRUCTIONS STRICTES :
1. N'utilise QUE des éléments littéralement présents dans le dossier patient. N'invente AUCUN critère.
2. Pour CHAQUE critère (1, 2, 3), tu DOIS produire un texte de preuve qui contient AU MOINS UNE CITATION LITTÉRALE du dossier entre guillemets français « ... ». Exemple : « FC à 110 bpm, TA 92/60 ».
3. Si le critère est NON validé, ne renvoie JAMAIS un fallback creux : explique factuellement ce qui manque, en citant le dossier (ex: « Sortie à H+2 », « Aucun acte technique au compte-rendu »).
4. Le texte de chaque preuve fait 2-3 phrases : (i) la citation littérale, (ii) l'analyse PMSI, (iii) la conclusion validé/non validé.
5. Calcule la durée totale du passage en heures (admission → sortie/transfert) à partir des horaires du dossier.
6. Module ta confiance honnêtement :
- "elevee" uniquement si tous les indices convergent
- "moyenne" si éléments ambivalents
- "faible" si information manquante ou très atypique
Réponds STRICTEMENT en JSON valide, sans texte avant ni après :
{{
"duree_passage_heures": <nombre>,
"elements_pour_hospitalisation": [<phrases littéralement extraites du dossier>],
"elements_pour_forfait": [<phrases littéralement extraites du dossier>],
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
"decision_court": "UHCD" | "Forfait Urgences",
"preuve_critere1": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (motif, symptôme, terrain à risque, traitement). Si non validé : factualise ce qui manque en citant le dossier.>",
"critere1_valide": true | false,
"preuve_critere2": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (constantes, observations IDE, durée surveillance). Si non validé : factualise.>",
"critere2_valide": true | false,
"preuve_critere3": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (actes/examens : biologie, imagerie, suture, etc.). Si non validé : factualise.>",
"critere3_valide": true | false,
"justification": "<2-3 phrases synthétiques s'appuyant explicitement sur les preuves ci-dessus, avec au moins une citation>",
"confiance": "elevee" | "moyenne" | "faible"
}}
DOSSIER PATIENT :
{dpi}
"""
def analyze_dpi(
dpi_text: str,
model: str = DEFAULT_MODEL,
timeout: int = DEFAULT_TIMEOUT,
ollama_url: str = OLLAMA_URL,
) -> Dict[str, Any]:
"""Soumet un DPI urgences à un LLM Ollama et retourne la décision JSON.
Args:
dpi_text: Texte du dossier patient (concaténation des onglets ou DPI brut).
model: Modèle Ollama à utiliser (default qwen2.5:7b — 100% accuracy bench).
timeout: Timeout HTTP en secondes.
ollama_url: Endpoint Ollama (default localhost:11434/api/generate).
Returns:
Dict avec :
decision: "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION"
elements_pour_hospitalisation: List[str]
elements_pour_forfait: List[str]
duree_passage_heures: float
justification: str
confiance: "elevee" | "moyenne" | "faible"
_elapsed_s: float (latence)
_model: str
En cas d'erreur :
{"_error": str, "_elapsed_s": float} (réseau / Ollama indisponible)
{"_parse_error": True, "_raw": str, "_elapsed_s": float} (JSON invalide)
"""
payload = {
"model": model,
"prompt": PROMPT_TEMPLATE.format(dpi=dpi_text),
"stream": False,
"format": "json",
"keep_alive": "5m",
"options": {
"temperature": 0.1,
"num_predict": 1500,
"num_ctx": 16384,
},
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
ollama_url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
elapsed = round(time.time() - t0, 1)
logger.warning("analyze_dpi: Ollama indisponible (%s) après %.1fs", e, elapsed)
return {"_error": str(e), "_elapsed_s": elapsed, "_model": model}
elapsed = time.time() - t0
raw_response = body.get("response", "").strip()
raw_thinking = body.get("thinking", "").strip()
candidates = [raw_response]
if not raw_response and raw_thinking:
last_close = raw_thinking.rfind("}")
last_open = raw_thinking.rfind("{", 0, last_close)
if last_open != -1 and last_close != -1:
candidates.append(raw_thinking[last_open:last_close + 1])
parsed = None
for cand in candidates:
cleaned = cand
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1]
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0]
cleaned = cleaned.strip()
try:
parsed = json.loads(cleaned)
break
except json.JSONDecodeError:
continue
if parsed is None:
return {
"_parse_error": True,
"_raw": (raw_response or raw_thinking)[:500],
"_elapsed_s": round(elapsed, 1),
"_model": model,
}
parsed["_elapsed_s"] = round(elapsed, 1)
parsed["_model"] = model
parsed["_eval_count"] = body.get("eval_count")
return parsed

Binary file not shown.

View File

@@ -0,0 +1,164 @@
# Inventaire anchors — préparation interop Excel
**Date** : 2026-05-13
**Contexte** : Préparation du workflow `Demo_urgence_2_interop` (id `wf_56bf8fa2d332_1778666923`). Ce document inventorie les ancres VWB nécessaires pour remplacer les 5 steps « UI Codage Easily » supprimés par des étapes d'export Excel.
**Source** : table `visual_anchors` de `visual_workflow_builder/backend/instance/workflows.db` (150 ancres au total).
---
## 1. Structure d'un anchor (schéma `visual_anchors`)
```sql
CREATE TABLE visual_anchors (
id VARCHAR(64) NOT NULL PRIMARY KEY,
image_path VARCHAR(512),
thumbnail_path VARCHAR(512),
bbox_x FLOAT,
bbox_y FLOAT,
bbox_width FLOAT,
bbox_height FLOAT,
screen_width INTEGER,
screen_height INTEGER,
description TEXT,
confidence_threshold FLOAT,
created_at DATETIME,
capture_method VARCHAR(64),
target_text TEXT,
ocr_description TEXT
);
```
### Champs et rôle
| Champ | Obligatoire | Rôle |
|---|---|---|
| `id` | ✅ | Format observé : `anchor_<12 hex>_<timestamp Unix s>` (ex. `anchor_c07eeb32f46e_1778497407`) |
| `image_path` | ✅ (de facto) | PNG plein du crop d'ancre. 100 % des 150 ancres l'ont. Chemin absolu vers `visual_workflow_builder/backend/data/anchors/<id>_full.png` |
| `thumbnail_path` | ✅ (de facto) | PNG miniature, 100 % renseigné. Chemin `<id>_thumb.png` dans le même dossier |
| `bbox_x`, `bbox_y`, `bbox_width`, `bbox_height` | ✅ | Coordonnées de la bounding box dans la résolution de référence. Toutes les ancres ont une bbox |
| `screen_width`, `screen_height` | ✅ | Résolution de l'écran sur lequel l'ancre a été enregistrée. Permet la normalisation pour le matching à toute résolution |
| `description` | ⚠️ Optionnel | Description textuelle, souvent « Button labeled "..." ». Vide pour 45/150 ancres |
| `confidence_threshold` | ✅ | Seuil pour template matching. **Valeur unique observée : 0.8** sur les 150 ancres |
| `created_at` | ✅ | Timestamp ISO |
| `capture_method` | ✅ | **Valeur unique observée : `screen_capture`** sur les 150 ancres |
| `target_text` | ⚠️ Optionnel | Texte OCR vu **autour** de l'ancre au moment de la capture. Sert au matching `by_text` quand l'ancre est élargie. Vide pour 45/150 |
| `ocr_description` | ⚠️ Optionnel | Souvent doublon de `description`. Vide pour 45/150 |
### Liaison avec `steps`
```
steps.anchor_id ───▶ visual_anchors.id (FK, NULL-able)
```
Un step `click_anchor` typique réfère son ancre par `anchor_id`, ET embarque aussi une copie compactée dans `parameters_json.visual_anchor` (champs `anchor_id`, `bounding_box`). Le `by_text` est lui dans `parameters_json.by_text` au niveau step (pas dans l'ancre). Voir exemple ord 4 :
```json
{
"visual_anchor": {
"anchor_id": "anchor_c07eeb32f46e_1778497407",
"bounding_box": {"x": 264, "y": 421, "width": 199, "height": 51}
},
"by_text": "Examens cliniques"
}
```
### Distribution de la table
| Critère | Valeurs |
|---|---|
| Total anchors | 150 |
| Fichiers sur disque (`data/anchors/`) | 370 PNGs (150 × 2 = 300 attendus, ~70 orphelins) |
| `capture_method` | 100 % `screen_capture` |
| `confidence_threshold` | 100 % `0.8` |
| Résolution de référence | 2560×1600 : 98 ancres • 1920×1080 : 49 • 320×180 : 3 (atypique) |
| Sans `target_text` | 45 / 150 |
| Sans `description` | 45 / 150 |
---
## 2. Ancres référencées par le workflow source `Demo_urgence_2`
| Ord | action_type | anchor_id | bbox (x,y,w,h) | OCR `target_text` (court) |
|---:|---|---|---|---|
| 0 | extract_table | `anchor_90aab00906b5_1778493250` | (19, 505, 2529, 488) | Tableau liste patients (zone full liste IPP/NOM/Date…) |
| 2 | click_anchor | `anchor_79c26617976d_1778493882` | (30, 548, 101, 30) | `25003284` cellule IPP |
| 4 | click_anchor | `anchor_c07eeb32f46e_1778497407` | (264, 421, 199, 51) | onglet `Examens cliniques` |
| 6 | click_anchor | `anchor_e31b93822caa_1778497559` | (477, 429, 109, 35) | onglet `Imagerie` |
| 8 | click_anchor | `anchor_b8bf39376b8a_1778497633` | (612, 423, 183, 48) | onglet `Notes médicales` |
| 10 | click_anchor | `anchor_e757dbf3f22f_1778497837` | (802, 423, 214, 46) | onglet `Synthèse Urgences` |
| 13 | click_anchor | `anchor_8d4b9cf0207c_1778575835` | (833, 404, 230, 49) | onglet `Codage` |
| 15 | click_anchor | `anchor_8e72328ac1f1_1778575896` | (20, 381, 338, 131) | textarea `Coller ou saisir le dossier patient` |
| 18 | click_anchor | `anchor_de15b10a848b_1778498168` | (1350, 981, 619, 31) | textarea `Justification de la décision` |
**Toutes en référence 2560×1600** (résolution Léa Windows actuelle).
Pattern observé pour un click sur libellé simple (cas ord 4 — `Examens cliniques`) :
- Crop ~200×50 autour du libellé
- `target_text` ≈ contexte OCR voisin (libellés alentours) pour aider le matcher hybride
- `description` ≈ libellé propre (« Button labeled "Examens cliniques" »)
- `confidence_threshold = 0.8`
---
## 3. Ancres existantes pour le besoin interop Excel
Requête effectuée sur `target_text`, `description`, `ocr_description` avec les patterns : `excel`, `.xlsx`, `xls`, `codage_urgence`, `bureau`, `desktop`.
**Résultat : aucune ancre trouvée.**
| Besoin interop | Ancre existante ? | Action requise |
|---|---|---|
| Icône bureau Windows (générique, pour ouvrir l'Explorateur ou poser le fichier sur bureau) | ❌ | **À créer** en enregistrement VWB |
| Cellule Excel (entête ou première cellule de saisie) | ❌ | **À créer** — dépend du gabarit du `.xlsx` |
| Fichier `codage_urgence.xlsx` (icône bureau ou ligne Explorateur) | ❌ | **À créer** — le fichier doit d'abord exister sur le bureau Windows |
| Bouton « Enregistrer » Excel | ❌ | **À créer** si on passe par Ctrl+S, sinon pas nécessaire |
| Onglet/bouton Excel « Fichier » ou « Ouvrir » | ❌ | **À créer** seulement si scénario UI complet (vs server-side openpyxl) |
---
## 4. Procédure de création d'une ancre
### Option A — Côté serveur (recommandée si pure I/O fichier)
Si l'export Excel est réalisé via une action serveur (openpyxl côté Linux, fichier déposé sur partage SMB / synchronisé via OwnCloud), **aucune ancre Léa Windows n'est nécessaire**. Le workflow se termine après `llm_generate justification_t2a` par un step serveur de type à définir (ex. nouveau `export_excel_row` à câbler, ou via `_handle_*` existant).
Avantages :
- Pas d'ancres à enregistrer
- Pas de dépendance à la résolution écran
- Robuste, déterministe
### Option B — Côté UI Léa Windows (si la démo doit montrer Excel ouvert)
Si on veut **visuellement** voir Léa ouvrir Excel et coller les données, il faut enregistrer les ancres via le mode enregistrement VWB sur la machine cible (Windows 192.168.1.11, résolution 2560×1600 cohérente avec les 98 ancres existantes du workflow source).
Étapes type :
1. Préparer le fichier `codage_urgence.xlsx` sur le bureau Windows (gabarit prêt avec entêtes : IPP, Décision, Résumé, Justification, etc.)
2. Lancer VWB en mode enregistrement, viser machine 192.168.1.11
3. Ancres à capturer dans l'ordre du scénario (à valider avec Dom) :
- Ancre 1 : icône `codage_urgence.xlsx` sur le bureau (pour double-clic d'ouverture). **⚠️ rappel : `double_click` non implémenté côté Léa, fallback = 2 click rapprochés ou key_combo `["alt", "f4"]` après ouverture par défaut.**
- Ancre 2 : cellule cible de la première colonne (IPP) — varie selon le gabarit
- Ancre 3 : éventuel bouton « Enregistrer » ou ancre de validation
4. Pour chaque ancre : laisser VWB capturer le crop, vérifier que `target_text`, `description`, bbox sont cohérents avant validation
### Champs minima à fournir lors de la création
| Champ | Valeur cible |
|---|---|
| `id` | auto-généré (`anchor_<hex>_<ts>`) |
| `image_path` + `thumbnail_path` | auto-générés par VWB |
| `bbox_x/y/width/height` | définis par la sélection visuelle de l'utilisateur |
| `screen_width/height` | **2560 × 1600** (cohérence avec ancres existantes) |
| `confidence_threshold` | 0.8 (valeur unique observée dans la table) |
| `capture_method` | `screen_capture` |
| `target_text` | OCR autour de l'ancre — auto-rempli par VWB |
| `description` | optionnel, ex. « Icône fichier codage_urgence.xlsx sur le bureau » |
---
## 5. Récapitulatif
- **Structure d'ancre** : 15 champs, dont **9 obligatoires** (id, image_path, thumbnail_path, 4 bbox, 2 résolutions de réf, confidence, capture_method) et **4 fortement recommandés** (description, target_text, ocr_description, created_at). Référence dans `steps.anchor_id`.
- **Réutilisable directement** : les 8 ancres `click_anchor` du workflow source restent utilisées par `Demo_urgence_2_interop` (anchor_id partagé, FK).
- **À créer** : 0 si **option A** (export Excel server-side via openpyxl) ; **2 à 4 ancres** si **option B** (Léa ouvre et remplit Excel à l'écran).
**Décision attendue de Dom** : choisir entre option A (server-side) ou B (UI Léa) avant de poursuivre la conception des steps Excel.

View File

@@ -0,0 +1,283 @@
# Handoff session 15-16 mai 2026 — Workflow Demo_urgence_3_db + linux_db
**Auteur** : Claude (session du 15 mai 22h jusqu'au 16 mai 2h)
**Objectif** : permettre la reprise propre du travail (même session ou nouvelle session) jusqu'à la démo vidéo "demo 95" prévue **jeudi 21 mai 2026 (J-5)**.
---
## 1. Contexte démo
**Démo "95" / GHT Sud 95 — Paris 21 mai 2026** — format **vidéo enregistrée** (pas live).
Périmètre recentré : **requalification forfait / hospitalisation** uniquement (Demo_urgence_2 v2 Excel abandonnée — Excel inadapté pour textes longs).
**Différenciateurs vidéo** :
- 100 % vision (pas de DOM, pas d'API)
- Multi-OS Windows ↔ Linux (NoMachine)
- Bureau (Word) ET base (DBeaver/SQLite VM Ubuntu)
**Patient démo** : MOREL Catherine, IPP 25003284 (hardcodé partout)
**Date démo** : 15/05/2026 (hardcodée)
**Décision démo** : REQUALIFICATION_HOSPITALISATION
**Somme récupérée** : 1750 € (hardcodée)
---
## 2. Workflows en base
DB : `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/instance/workflows.db`
### Demo_urgence_3_db — `wf_483910cdd851_1778750587` (workflow complet)
40 steps (ord 0-39). Source = Demo_urgence_2_interop (wf_56bf8fa2d332_1778666923, intact).
| ord | action | rôle |
|---|---|---|
| 0-14 | DPI extraction + t2a_decision + 2× llm_generate | Génère `t_*`, `dec`, `resume_patient`, `justification_t2a` |
| 15 | Win+D | Basculer bureau Windows |
| 16-28 | Word | Ouvre template `rapport_T2A_template.docx`, saisit IPP/date/décision/somme via Tab |
| 29 | double_click LINUX_demo.nxs | Ouvre NoMachine vers VM Ubuntu (auto-login configuré par Dom) |
| 30 | type_text "loli" | Mot de passe Linux (skippé si auto-login actif) |
| 31 | Enter | Valider login |
| 32-39 | DBeaver dans VM | Ouvre demo_95 2, console SQL, INSERT, exécute |
**Note** : ord 35 a `by_text="Tables"`, ord 36 a `by_text="demo_95"` (ajoutés en cours de session).
### linux_db — `wf_0786343fb2b7_1778879244` (sous-workflow de test rapide)
**Créé spécifiquement** pour itérer rapidement sur la partie NoMachine + DBeaver sans rejouer le DPI+LLM (5-10 min). Initialement 11 steps = ord 29-39 du Demo_urgence_3_db.
État actuel après nombreuses modifs Dom : **10 ou 11 steps** (à vérifier en début de session). Dom a notamment :
- Supprimé certains steps
- Ajouté un click sur menu "Éditeur SQL" + sous-menu (passage à 3 clics au lieu de Ctrl+Alt+Enter, plus robuste)
- Réordonné via UPDATE SQL (les edges DAG visuelles VWB ne se sauvegardent pas)
**Commande pour voir l'état** :
```bash
sqlite3 /home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/instance/workflows.db \
"SELECT \"order\" AS ord, action_type, COALESCE(label,'') AS label, json_extract(parameters_json, '\$.visual_anchor.bounding_box') AS bbox, json_extract(parameters_json, '\$.by_text') AS by_text FROM steps WHERE workflow_id='wf_0786343fb2b7_1778879244' ORDER BY \"order\";"
```
---
## 3. Modifs code appliquées (NON COMMITÉES côté Linux)
### `agent_v0/agent_v1/ui/chat_window.py` (Léa Windows)
Trois modifs UX (validées Dom : Q1=oui minimiser sur Annuler, Q2=500ms, Q3=clear visuel uniquement, Q4=bulle accueil effaçable) :
1. **Nouvelle méthode `_clear_chat_history()`** (vers ligne 870) : détruit les widgets enfants de `_msg_frame` à chaque pause. Ne touche pas à `self._messages` (RAM conservée pour debug).
2. **Appel dans `_show_and_render`** : `self._clear_chat_history()` AVANT `_render_paused_bubble(payload)`.
3. **Minimisation 500 ms** sur `_on_paused_resume` ET `_on_paused_abort` : `self._root.after(500, self._do_hide)`.
### `agent_v0/agent_v1/core/executor.py` (Léa Windows)
1. **Délai paste** (ligne 2579) : `time.sleep(0.05)``time.sleep(0.5)` pour laisser NoMachine sync le clipboard Windows → VM Ubuntu. **NE FONCTIONNE TOUJOURS PAS** — clipboard probablement non propagé du tout par NoMachine.
### `agent_v0/server_v1/resolve_engine.py` (serveur Linux)
1. **Constante drift** (ligne 2110) : `_RESOLUTION_MAX_DRIFT: 0.20``0.95`. Permet à l'élément d'être trouvé n'importe où à l'écran (esprit "100 % vision"). Service `rpa-streaming` redémarré, nouveau PID au démarrage (vérifier `systemctl --user status rpa-streaming`).
### Déploiements Windows effectués via SCP
- `chat_window.py``dom@192.168.1.11:C:/rpa_vision/agent_v1/ui/chat_window.py`
- `executor.py``dom@192.168.1.11:C:/rpa_vision/agent_v1/core/executor.py`
- Léa Windows **a été redémarrée** après les SCP (à confirmer pour la session du 16 mai)
**Convention notée** : à chaque modif d'un fichier client Windows, Claude fait le SCP automatiquement (mot de passe SSH `loli` cf. `reference_credentials.md`).
---
## 4. Modifs DB appliquées (workflows.db)
Liste exhaustive des UPDATE de la session :
1. **ord 29 source** (`step_5c81e5bdd20f_1778879250` historique) : `by_text="LINUX_demo"` (ne plus retrouver via faux positifs)
2. **ord 35 source + ord 4 linux_db (anc.)** : `by_text="Tables"`
3. **ord 36 source + ord 5 linux_db (anc.)** : `by_text="demo_95"`
4. **ord 40 source — INSERT SQL** (`step_9cf99c5f2420_1778862183`) : ajout `"paste": true` au parameters_json
5. **ord 5 linux_db (Ctrl+Alt+Enter)** : ajout `delay_before: 1500` ms pour laisser le focus s'établir après le clic demo_95 2 (peut être inutile maintenant que Dom passe par le menu)
6. **Réordonnancement linux_db** : déplacement du clic demo_95 2 (ord 7) à ord 4 + décalage des suivants
7. **Réordonnancement linux_db après ajout menu** : 3 nouveaux clic_anchor menu (ord 8/9/10 → 5/6/7), type_text + Ctrl+Enter décalés à 8/9
8. **Retrait `paste`** sur le step type_text INSERT (paste:true ne fonctionnait pas → fallback frappe char-by-char)
9. **Retrait `by_text="demo_95"`** sur le nouveau click demo_95 2 (ord 4 linux_db) car OCR matchait l'onglet `<demo_95 2> Script-1` en haut au lieu du Navigator à gauche
**Backups disponibles** dans `visual_workflow_builder/backend/instance/workflows.db.backup_*` :
- `..._avant_dup_demo3` (création Demo_urgence_3_db)
- `..._avant_paste_true` (avant UPDATE paste:true)
- `..._avant_bytext_ord29` (avant by_text LINUX_demo)
- `..._avant_bytext_demo95` (avant by_text demo_95 + Tables)
- `..._avant_suppr_vwb31` (avant suppression Dom)
- `..._avant_linux_db` (avant création linux_db)
- `..._avant_reorder_linux_db` (avant 1er réordonnancement)
- `..._avant_reorder_menu` (avant 2e réordonnancement)
---
## 5. Bugs / dette technique identifiés (À TRAITER POST-DÉMO)
### Critiques pour la démo vidéo
1. **paste:true ne fonctionne pas** entre Léa Windows et VM Ubuntu via NoMachine
- Soit `win32clipboard.SetClipboardText()` plante silencieusement
- Soit NoMachine ne propage pas le clipboard Windows → Ubuntu (probable)
- **Investiguer** : vérifier NoMachine settings (Devices → Clipboard → cocher "Both directions")
- **Fallback** : char-by-char (lent mais peut-être suffisant pour 1859 chars)
2. **Clics Léa n'atteignent pas toujours la VM Ubuntu** via NoMachine
- Pattern : Léa clique côté Windows, l'agent reporte `success=True` mais `no_screen_change`
- Chaque clic prend **2 min** côté agent (retries internes silencieux)
- À ce rythme, le replay complet est inutilisable pour vidéo (25+ min)
- **Hypothèse** : focus de fenêtre NoMachine instable, ou mode input non passthrough
- **Workaround temporaire** : Dom intervient en supervision humaine (chaque step → bulle Léa → clic manuel + Continuer)
3. **Faux négatifs détecteur screen_change** : seuil `global=0.002%` considéré "aucun changement" sur les clics de menu qui pourtant ouvrent quelque chose visuellement. Déclenche retries inutiles.
4. **Bug `get_target_memory_store` import** dans `replay_memory.py` :
```
Learning: échec stockage target_memory: cannot import name 'get_target_memory_store'
```
Non bloquant mais empêche l'apprentissage des corrections humaines.
5. **VLM Quick Find exception** : `'int' object has no attribute 'get'` — fallback OK mais à corriger.
### Bugs UX VWB
6. **Bouton "Stop" disparaît** côté VWB UI quand le replay reste actif côté serveur (désynchro). Confondant.
7. **Bouton vert "Exécuter"** appelle un mini-runtime legacy (`real_demo.py`) qui ne supporte que click/type/wait. Pour le replay complet, **toujours cliquer le bouton bleu "→ Windows"**.
8. **DAG edges visuelles** dans VWB ne se sauvegardent pas → l'ordre visuel des flèches ne reflète pas l'ordre réel (basé sur `steps.order`). Confusing pour l'utilisateur.
9. **Bannière Windows 11 "Aucun périphérique audio disponible"** (grise) persistante en haut → VLM la décrit comme "popup, dialog box, confirmation, or erro..." (faux). Pollue les diagnostics.
### Bugs Léa
10. **Démarrage Léa très lent** : 3-6 minutes au lancement (chargement modèles ML probablement). À investiguer.
11. **Capture VWB** : si Léa Windows n'est pas lancée, le fallback `mss` sur Wayland échoue (`XGetImage failed`). Le bouton "Capturer" dans CapturePanel a un fallback local Linux qui ne marche plus sur Wayland natif (Chrome moderne).
---
## 6. État infra runtime (vérifications à faire en début de session)
- **Service `rpa-streaming`** (port 5005) : `systemctl --user status rpa-streaming` → doit être active. Si redémarré entre temps, PID différent du 308418 mentionné.
- **VWB backend** : port 5002, lancé via `app.py` (PID ≈ 140052 au moment du handoff).
- **VWB frontend** : port 3002, Vite/React.
- **Léa Windows** : sur `192.168.1.11`, tray icon visible si lancée. Pour confirmer connexion = polls `/api/v1/traces/stream/replay/next` depuis 192.168.1.11 dans les logs serveur.
- **NoMachine** : configuré avec auto-login Ubuntu activé (skip lock screen). Clipboard partage **probablement non bidirectionnel** — à vérifier.
---
## 7. Reprise demain — checklist proposée
### Étape 0 — Vérification état
```bash
# Vérifier services
systemctl --user status rpa-streaming
ps aux | grep -E "app.py|api_stream" | grep -v grep
# Vérifier état workflows
sqlite3 /home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/instance/workflows.db \
"SELECT id, name, updated_at FROM workflows WHERE name LIKE 'Demo_urgence_3%' OR name LIKE 'linux_db';"
# Vérifier modifs code non commitées
cd /home/dom/ai/rpa_vision_v3 && git status --short | grep -E "chat_window|executor|resolve_engine"
```
### Étape 1 — Résoudre le bug "clics ne traversent pas NoMachine"
C'est le bloqueur n°1. Sans ça, pas de démo vidéo possible.
**Diagnostic Dom (16 mai 2h)** : « C'est NoMachine côté Windows qui ne répond plus comme il faut. » À investiguer en priorité demain (mise à jour NoMachine récente ? Config qui a sauté ? Process zombi ? Réinstall ?).
**Pistes** :
- Activer le partage clipboard NoMachine bidirectionnel (Settings → Devices → Clipboard)
- Vérifier que la fenêtre NoMachine reste plein écran tout au long
- Tester un autre client display (RDP natif Windows ?)
- Configurer Léa pour focus la fenêtre NoMachine avant chaque action (mécanisme à ajouter dans executor.py ?)
### Étape 2 — Si NoMachine OK : tester linux_db de bout en bout
Cible : run linux_db en ≤ 2 min sans intervention manuelle.
### Étape 3 — Run complet Demo_urgence_3_db
Cible : 1 run propre du DPI au INSERT SQL. Enregistrer cette session pour la vidéo.
### Étape 4 — Post-démo
Traiter la dette listée section 5 dans l'ordre d'importance.
---
## 8. Annexes
### IDs critiques
- **Workflow Demo_urgence_3_db** : `wf_483910cdd851_1778750587`
- **Workflow linux_db** : `wf_0786343fb2b7_1778879244`
- **Workflow source Demo_urgence_2_interop** (intact) : `wf_56bf8fa2d332_1778666923`
- **IPP démo** : `25003284`
- **Step INSERT SQL Demo_urgence_3_db** : `step_9cf99c5f2420_1778862183` (ord 40, paste:true)
- **Service streaming** : `rpa-streaming` user systemd
### Variables runtime produites par le workflow
- `t_extraction_liste` (extract_table ord 0)
- `t_motif_admission`, `t_examen_clinique`, `t_imagerie`, `t_notes_medicales`, `t_synthese_urgences` (extract_text_scroll)
- `dec` (objet) : `dec.decision`, `dec.decision_court`, `dec.preuve_critere1/2/3` (t2a_decision ord 12, model `gemma4:31b-cloud`)
- `resume_patient` (llm_generate ord 13)
- `justification_t2a` (llm_generate ord 14)
### Fichiers source modifiés (non commités)
```
modified: agent_v0/agent_v1/ui/chat_window.py # clear+minimize Léa
modified: agent_v0/agent_v1/core/executor.py # paste delay 0.5s
modified: agent_v0/server_v1/resolve_engine.py # drift 0.95
```
### Schema DB rapide
- Table `steps` : `id, workflow_id, action_type, "order" (réservé), position_x, position_y, parameters_json (JSON), anchor_id (FK), label, created_at, updated_at`
- Table `workflows` : `id, name, description, tags_json, trigger_examples_json, created_at, updated_at, is_active, source, review_status, review_feedback, reviewed_at`
- VWB affiche `order` en 1-indexed (VWB N = DB N-1)
### Commandes utiles
```bash
# Logs streaming (filtrés)
journalctl --user-unit=rpa-streaming --since "10 minutes ago" --no-pager | \
grep -vE "stream/image|/replay/next|GET /health|EDS-NLP" | tail -30
# Backup DB
cp /home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/instance/workflows.db{,.backup_$(date +%Y-%m-%d_%H%M%S)_label}
# SCP fichier vers Léa Windows
SSHPASS='loli' sshpass -e scp -o StrictHostKeyChecking=no \
/home/dom/ai/rpa_vision_v3/agent_v0/agent_v1/ui/chat_window.py \
dom@192.168.1.11:C:/rpa_vision/agent_v1/ui/chat_window.py
# Forcer reprise replay côté serveur
curl -X POST "http://localhost:5005/api/v1/traces/stream/replay/<replay_id>/resume"
```
### Trois questions Q1/Q2/Q3 Léa chat (déjà tranchées)
- Q1 : minimiser aussi sur Annuler ? → **oui**
- Q2 : délai minimisation ? → **500 ms**
- Q3 : purger `self._messages` RAM ? → **non** (clear visuel uniquement, RAM gardée pour debug)
- Q4 (bulle accueil RGPD) : effaçable au 1er pause ? → **oui** OK
---
## 9. Posture pour la reprise
**Si nouvelle session Claude** : commencer par lire ce handoff EN ENTIER + `MEMORY.md` + `CLAUDE.md`. Ne pas relancer de modifs sans vérifier l'état actuel de la DB et du code.
**Si même session Claude** (ce qui est déjà perdu probablement) : reprendre directement.
**Priorité absolue** : résoudre le bug "clics NoMachine ne traversent pas". Sans ça, la démo vidéo est compromise.
**Démo J-5** : jeudi 21 mai. Marge de manœuvre étroite. Si NoMachine est un blocage durable, envisager un plan B (un seul OS = tout sous Linux ? ou tout sous Windows ?).

View File

@@ -0,0 +1,169 @@
# Handoff session 2026-05-16 matinée — Contournement Ctrl+V NoMachine via ydotool
**Auteur** : Claude (session 10:00 → 15:10 CEST)
**Suite de** : `2026-05-16_handoff_demo3_workflow.md` (handoff du 16 mai 02:04, session 15→16 mai)
**Objectif** : démo vidéo "demo 95" jeudi 21 mai 2026 (J-5)
---
## 1. Problème résolu
Bloqueur n°1 du handoff précédent (« clics ne traversent pas NoMachine ») : partiellement résolu.
- **Clics Léa via NoMachine** : OK (traversent normalement, propagation pynput → SendInput Windows → NoMachine → VM)
- **Key combos avec Ctrl (Ctrl+V, Ctrl+Enter)** : **bloqués par NoMachine en mode "passive grab" par défaut**
Cause root identifiée (agent de recherche web) : NoMachine Player Windows en passive grab intercepte/avale la touche Ctrl quand "Grab the keyboard input" n'est pas explicitement activé. Source : [KB NoMachine AR01P00959](https://kb.nomachine.com/AR01P00959), [TR08R09811](https://www.nomachine.com/TR08R09811).
Solution 1 testée (chercher l'option dans le menu NoMachine 9.5.7) : **option introuvable dans l'UI**.
Solution 2 retenue : **bypasser entièrement NoMachine pour le paste/execute** en injectant les touches *directement dans la VM* via `ydotool` (compatible Wayland natif Ubuntu 26.04).
---
## 2. Architecture finale
```
[Léa Windows] --pynput--> [clics Windows] --NoMachine--> [VM Ubuntu DBeaver]
(clics OK) (focus zone éditeur SQL)
[hôte Linux] --SSH--> [VM Ubuntu]
prepare_clipboard.sh wl-copy + xsel (gardien boucle) → clipboards Wayland + X11 remplis
paste_and_execute.sh ydotool key 29:1 47:1 47:0 29:0 → Ctrl+V dans VM (bypasse NoMachine)
ydotool key 29:1 28:1 28:0 29:0 → Ctrl+Enter dans VM
```
---
## 3. Livrables créés
### Scripts (`~/ai/rpa_vision_v3/scripts/`)
| Fichier | Rôle |
|---|---|
| `payload_insert_morel.sql` | Payload INSERT SQL (1632 bytes, source de vérité) extrait du backup DB |
| `prepare_clipboard_linuxdb.sh` | Pousse payload sur VM, lance gardien boucle qui re-pousse clipboard Wayland + X11 toutes les 0.5s (sinon écrasé par sélection terminal/etc.) |
| `paste_and_execute_linuxdb.sh` | Vérifie ydotoold + gardien, déclenche Ctrl+V puis Ctrl+Enter via ydotool dans la VM |
### Modifications workflows.db (3 backups successifs)
```
workflows.db.backup_2026-05-16_102755_avant_clipboard_relay (avant 1er UPDATE)
workflows.db.backup_2026-05-16_111541_avant_rawkeys (tentative raw_keys, abandonnée)
workflows.db.backup_2026-05-16_150853_avant_suppr_steps_8_9 (avant suppression key_combos)
```
État final workflow `linux_db` (`wf_0786343fb2b7_1778879244`) : **7 steps**, tous des clics
```
0 double_click_anchor LINUX_demo
1 click_anchor
2 click_anchor
3 wait_for_anchor
4 click_anchor
5 click_anchor
6 click_anchor
```
Les ex-steps 7 (Ctrl+V) et 8 (Ctrl+Enter) ont été **supprimés** car remplacés par `paste_and_execute_linuxdb.sh` lancé à la main.
### DB demo_95 (VM)
Nettoyée : 3 rows propres (DURAND, MARTIN, PETIT), AUTOINCREMENT reset à 3. Prochain INSERT MOREL aura id=4.
---
## 4. Dépendances installées dans la VM (Ubuntu 26.04 Wayland)
```
wl-clipboard (wl-copy/wl-paste — clipboard Wayland)
openssh-server (SSH depuis Linux hôte)
xsel (xsel — clipboard X11/XWayland)
x11-xserver-utils (xhost — autoriser connexions X11 locales)
ydotool (1.0.4-3 — injection clavier uinput, Wayland-compatible)
```
Démarrage `ydotoold` daemon (pas de service systemd fourni par le paquet Ubuntu) :
```bash
echo 'loli' | sudo -S sh -c 'nohup setsid ydotoold --socket-path=/tmp/.ydotool_socket --socket-perm=0666 >/var/log/ydotoold.log 2>&1 </dev/null &'
```
(Inclus dans `paste_and_execute_linuxdb.sh` qui le relance si absent.)
---
## 5. À refaire après chaque reboot VM
1. **xhost** (autorise SSH non-interactif à utiliser xsel sur XWayland) :
```bash
sshpass -p loli ssh dom@192.168.122.132 \
"XAUTH=\$(ls /run/user/1000/.mutter-Xwaylandauth.* 2>/dev/null | head -1); \
DISPLAY=:0 XAUTHORITY=\$XAUTH xhost +local:"
```
2. **prepare_clipboard_linuxdb.sh** (relance gardien clipboard + daemon ydotoold via paste_and_execute si nécessaire)
---
## 6. Procédure démo finale
```bash
# AVANT la démo (1 fois, à froid après reboot) :
~/ai/rpa_vision_v3/scripts/prepare_clipboard_linuxdb.sh
# Pour chaque take de la démo :
# 1. Lancer workflow linux_db dans VWB (bouton bleu "→ Windows")
# 2. Attendre que Léa termine les 7 steps (clics visibles dans la VM via NoMachine)
# 3. Dans un terminal Linux hôte :
~/ai/rpa_vision_v3/scripts/paste_and_execute_linuxdb.sh
# → INSERT collé + exécuté instantanément dans DBeaver (visible à l'écran)
```
Vérif post-démo (compter rows) :
```bash
sshpass -p loli ssh dom@192.168.122.132 \
'python3 -c "import sqlite3; c=sqlite3.connect(\"/home/dom/demo_95\"); print(list(c.execute(\"SELECT id,nom_patient,date_creation FROM requalifications_t2a ORDER BY id DESC LIMIT 2\")))"'
```
Reset pour nouveau take :
```bash
sshpass -p loli ssh dom@192.168.122.132 \
'python3 -c "
import sqlite3
c = sqlite3.connect(\"/home/dom/demo_95\")
c.execute(\"DELETE FROM requalifications_t2a WHERE id > 3\")
c.execute(\"UPDATE sqlite_sequence SET seq = 3 WHERE name = '\''requalifications_t2a'\''\")
c.commit()"'
```
---
## 7. Dette technique créée par cette session
1. **Couplage hôte ↔ VM dur** : password `loli` en clair dans les scripts (`SSH_PASS`, `SUDO_PASS`). À remplacer par clé SSH + sudoers NOPASSWD pour ydotoold. Hors scope J-5.
2. **Gardien clipboard boucle 0.5s** : consomme CPU négligeable mais pas propre. À remplacer post-démo par un mécanisme dbus signal ou systemd path unit qui re-pousse seulement sur événement "clipboard cleared".
3. **paste_and_execute lancé à la main** : pas intégré au workflow VWB. Pour intégration auto post-démo, deux options :
- **A** : ajouter à Léa un nouveau action_type `shell_remote` (modif executor.py côté Windows + déploiement SCP)
- **B** : modifier `replay_engine.py` côté serveur pour exécuter localement (Linux hôte) certains step types au lieu de dispatcher à Léa
4. **xhost +local:** : large. À remplacer par xhost SI:localuser:dom post-démo.
5. **AUTOINCREMENT id=9** déjà consommé par les tests : reset à 3 pour la démo, mais à vérifier après chaque take.
---
## 8. État infra runtime
- Serveur `rpa-streaming` : actif (PID 308418 mentionné dans handoff précédent)
- ydotoold daemon : actif côté VM, socket `/tmp/.ydotool_socket` (0666)
- Gardien clipboard : actif côté VM (PID dans `/tmp/clipboard_guard.pid`)
- 3 clipboards remplis :
- Wayland VM (1633 bytes via wl-copy)
- X11 VM (1632 bytes via xsel)
- Windows (1632 bytes via Set-Clipboard — peut nécessiter re-push manuel si vidé)
---
## 9. Sessions du jour (16 mai)
- 06:52 → 07:55 : session reprise courte (fermée par Dom par erreur), test workflow → blocage Ctrl+V
- 10:00 → 15:10 : session diagnostic+fix complet
- Diagnostic clipboard Wayland ≠ X11 (XWayland ne synchronise pas)
- Fix : gardien double-write wl-copy + xsel + xhost +local:
- Diagnostic NoMachine passive grab mange Ctrl
- Fix : bypass via ydotool en SSH dans la VM
- Workflow linux_db simplifié (suppression key_combos)

View File

@@ -0,0 +1,138 @@
# Handoff session 2026-05-17 — instabilité NoMachine + état général démo J-4
**Auteur** : Claude (session 09:30 → ~11:00 CEST)
**Suite de** : `2026-05-16_handoff_ydotool_clipboard.md` (handoff hier soir)
**Objectif** : démo vidéo "demo 95" Paris **jeudi 21 mai 2026 (J-4)**
---
## 1. Diagnostic principal du jour : **NoMachine freeze côté Windows**
**Symptôme** : Léa déplace la souris (mouse_move OK, visible côté VM via NoMachine viewer) mais les **clics ne s'enregistrent pas dans la VM**. Le serveur signale `vlm_and_template_all_failed` au step suivant car l'état visuel attendu (launcher GNOME ouvert, DBeaver visible, etc.) n'est jamais atteint.
**Cause root identifiée par Dom** : **NoMachine viewer Windows freeze** progressivement. Au démarrage frais ça marche, après quelques minutes les inputs synthétiques (mouse click, keystrokes) sont mangés silencieusement. Pattern intermittent.
**Mitigation immédiate démo** :
- Redémarrer NoMachine côté Windows avant chaque take
- Garder NoMachine en plein écran (la fenêtre ne doit pas déborder de l'écran, sinon le grounding Léa crop sur une zone hors-écran et tout casse — vu rect aberrant `(2553, 46) 1705×977` dans les logs)
**Investigation post-démo nécessaire** :
- Version NoMachine actuelle : 9.5.7. Vérifier si update fixe le freeze
- Logs NoMachine côté Windows (mémoire, codec, déconnexions)
- Tester alternatives : RDP natif Windows, TigerVNC, Sunshine/Moonlight
---
## 2. Bug secondaire : `RPA_VLM_MODEL=gemma4:e4b` dans le code Léa
**Localisation** : `C:\rpa_vision\agent_v1\core\executor.py` lignes ~1569, 1700, 2248 :
```python
_vlm_model_popup = os.environ.get("RPA_VLM_MODEL", "gemma4:e4b")
```
Le tag `gemma4:e4b` n'existe pas dans Ollama → `[POPUP-VLM] HTTP 404` à chaque appel popup → la cascade de récupération en cas de visual_resolve fail est cassée → Léa demande directement l'aide humaine au lieu de tenter le fallback popup-detect.
**Fix simple** : exporter `RPA_VLM_MODEL=qwen2.5vl:7b` (ou autre modèle existant) dans l'env Windows de Léa, OU patcher le code Léa pour avoir un default valide.
---
## 3. État infra (en fin de session)
### Côté Linux hôte
- Serveur `rpa-streaming` : restart à 10:48:58 (PID 319324) après reset state interne (workflow `paused_need_help` bloquant)
- VLM model configuré : `qwen2.5vl:7b` (via `.env.local` → override sur `vlm_config.py`)
- Note : qwen2.5vl runtime Ollama = 13 GB → débordement CPU à 100%. Pour `linux_db` simple, le VLM Ollama n'est appelé qu'en fallback (~1.6s avec réponse vide), pas critique. Le grounding principal est InfiGUI-G1-3B Transformers (3.9 GB VRAM, permanent depuis 7 jours).
### Côté VM Ubuntu 26.04 (192.168.122.132)
- `/home/dom/demo_95` : SQLite, **table renommée** `requalifications_t2a`**`requalification_urgence`** ✓
- État table : 3 rows propres (DURAND, MARTIN, PETIT), AUTOINCREMENT à 3 → prochain INSERT = id 4
- `ydotoold` : daemon actif depuis hier (socket `/tmp/.ydotool_socket` perm 0666)
- Gardien clipboard `prepare_clipboard_linuxdb.sh` : potentiellement à relancer après reboot VM
- `xhost +local:` : perdu au reboot, à refaire
### Côté Windows (Léa)
- Léa redémarrée plusieurs fois aujourd'hui (10:00, 10:32)
- **Léa peut crasher silencieusement** (déjà constaté ce matin). Risque pour la démo : prévoir un quick-restart Léa pré-démo et surveiller pendant le take.
---
## 4. Avancées concrètes vs hier
| Élément | Hier (handoff 2026-05-16) | Aujourd'hui |
|---|---|---|
| Table demo_95 | `requalifications_t2a` | `requalification_urgence` (renommée) |
| Payload INSERT | `INSERT INTO requalifications_t2a ...` | `INSERT INTO requalification_urgence ...` |
| Step 8 workflow `linux_db` | `paste_and_execute` server-side | identique ✓ |
| Step 8 workflow `Demo_urgence_3_db` (ord 40) | `INSERT INTO requalifications_t2a` | `INSERT INTO requalification_urgence` (mis à jour) |
| Scripts shell | `paste_and_execute_linuxdb.sh` avec bugs (verif stricte +1 byte, sudo TTY required) | **fixés** : tolérance ±1 byte newline, skip sudo si daemon présent |
| Server-side action `paste_and_execute` | intégrée en code (replay_engine + api_stream) | intégrée + testée avec succès hier |
---
## 5. Backups du jour (workflows.db)
```
workflows.db.backup_2026-05-17_093654_avant_rename_table (avant ALTER TABLE)
workflows.db.backup_2026-05-17_102048_avant_rerecord_linuxdb (jamais utilisé — le re-record n'a pas été fait)
```
---
## 6. Suite de session après reboots serveur + Windows
### Routine de redémarrage à respecter
1. **Linux hôte** : `systemctl --user start rpa-streaming` (vérif `journalctl --user-unit=rpa-streaming -f` pour VLM model log)
2. **VM Ubuntu** :
- Si reboot VM : `sudo ydotoold --socket-path=/tmp/.ydotool_socket --socket-perm=0666 &` (depuis terminal NoMachine, pas SSH)
- Si reboot VM : `xhost +local:` (depuis terminal NoMachine, pas SSH)
- Si reboot VM : `~/ai/rpa_vision_v3/scripts/prepare_clipboard_linuxdb.sh` (relance gardien clipboard)
3. **Windows** : Léa démarre via raccourci ou `C:\rpa_vision\.venv\Scripts\pythonw.exe run_agent_v1.py`
4. **NoMachine Windows** : ouvrir connexion vers VM, mettre en plein écran, NE PAS bouger pendant les replays
### Vérification avant démo
- DBeaver doit être déjà ouvert dans la VM avec la base `demo_95` connectée et la console SQL prête (la conn navigue dans `requalification_urgence`)
- Le workflow `linux_db` part du principe que NoMachine vient juste d'être ouvert mais que **DBeaver n'est PAS encore ouvert** (les steps ouvrent le launcher GNOME, cliquent DBeaver, etc.)
### Pour le take vidéo (J-4)
- Faire 1 warmup run avant le take (cold start VLM ~1m30 sur le 1er resolve)
- Reset la table après le warmup :
```bash
sshpass -p loli ssh dom@192.168.122.132 \
'python3 -c "import sqlite3; c=sqlite3.connect(\"/home/dom/demo_95\"); c.execute(\"DELETE FROM requalification_urgence WHERE id > 3\"); c.execute(\"UPDATE sqlite_sequence SET seq = 3 WHERE name = '\''requalification_urgence'\''\"); c.commit()"'
```
---
## 7. Dette technique (à traiter post-démo)
1. **NoMachine freeze sous Windows** — sujet #1 pour la fiabilité long terme
2. **`RPA_VLM_MODEL=gemma4:e4b` hardcoded dans Léa** (executor.py lignes 1569, 1700, 2248) → patch + SCP
3. **qwen2.5vl:7b runtime 13 GB sur RTX 5070 12 GB** — déborde CPU. Choix VLM stable manquant. Pistes : Holo1.5-7B, MiniCPM-V 2.6, ou désactiver carrément le VLM auxiliaire pour les workflows à anchor templates fiables
4. **Léa peut crasher silencieusement** sur Windows — investiguer logs Windows pour identifier (mémoire ? exception ?)
5. **Couplage hôte ↔ VM dur** : password `loli` en clair dans les scripts. À remplacer par clé SSH + sudoers NOPASSWD post-démo
---
## 8. Files clés à connaître
- `/home/dom/ai/rpa_vision_v3/scripts/prepare_clipboard_linuxdb.sh` (gardien clipboard, lance daemon ydotoold si absent — désormais en mode skip-sudo)
- `/home/dom/ai/rpa_vision_v3/scripts/paste_and_execute_linuxdb.sh` (Ctrl+V + Ctrl+Enter via ydotool, invocable depuis serveur ou à la main)
- `/home/dom/ai/rpa_vision_v3/scripts/payload_insert_morel.sql` (1635 bytes, table `requalification_urgence`)
- `/home/dom/ai/rpa_vision_v3/core/detection/vlm_config.py` (`DEFAULT_VLM_MODEL = "gemma4:latest"` mais env var de `.env.local` met `qwen2.5vl:7b`)
- `/home/dom/ai/rpa_vision_v3/agent_v0/server_v1/replay_engine.py` (`_handle_paste_and_execute_action` au handler)
- `/home/dom/ai/rpa_vision_v3/agent_v0/server_v1/api_stream.py` (`elif type_ == "paste_and_execute"` dans le dispatcher)
## 9. Workflow `linux_db` actuel (en DB)
`wf_0786343fb2b7_1778879244` — 9 steps :
```
0 double_click_anchor LINUX_demo (icône NoMachine sur bureau Windows)
1 click_anchor (bouton Activités GNOME en bas-gauche Ubuntu via NoMachine)
2 click_anchor (icône DBeaver dans le launcher GNOME ouvert)
3 wait_for_anchor attente
4 click_anchor (dans DBeaver)
5 click_anchor (dans DBeaver)
6 click_anchor (dans DBeaver — focus zone éditeur SQL)
7 keyboard_shortcut Alt+F11 (plein écran DBeaver)
8 paste_and_execute server-side (lance scripts/paste_and_execute_linuxdb.sh)
```

View File

@@ -0,0 +1,150 @@
# Handoff 2026-05-18 — Consolidation post-blocages démo J-3
**Session** : 08:00 → ~09:15 CEST
**Démo cible** : "demo 95" Paris **jeudi 21 mai 2026 (J-3)**
**Suite de** : `2026-05-17_handoff_session_nomachine.md`
---
## 1. Trois bugs distincts résolus aujourd'hui
### 1.1 Réseau — Windows → VM NoMachine sur port 4001
**Symptôme** : NoMachine Windows ne joignait pas la VM en 4001.
**Cascade de causes** (trois bloqueurs successifs, chacun nécessaire) :
1. **UFW politique `deny (routed)`** par défaut → DNAT marchait, FORWARD dropait. Fix posé :
```bash
sudo ufw route allow proto tcp from any to 192.168.122.132 port 4000
```
2. **`LIBVIRT_FWI` REJECT par défaut** pour tout NEW depuis l'extérieur vers virbr0 → la règle libvirt ACCEPT de fin de chaîne n'était jamais atteinte. Fix initial volatil :
```bash
sudo iptables -I LIBVIRT_FWI -d 192.168.122.132 -p tcp --dport 4000 -j ACCEPT
```
3. **Persistance LIBVIRT_FWI** : hook libvirt créé pour re-injecter la règle à chaque démarrage du réseau `default` :
- `/etc/libvirt/hooks/network` (chmod 755) — réagit à `default started begin`
- Déclenche : `iptables -I LIBVIRT_FWI 1 -d 192.168.122.132 -p tcp --dport 4000 -j ACCEPT` (idempotent)
**Statut** : 3-way handshake Windows↔VM confirmé par tcpdump live. **Non testé en condition reboot** (à valider au prochain `virsh net-destroy default && virsh net-start default`).
DNAT 4001→4000 lui-même reste géré par le service existant `/etc/systemd/system/nomachine-vm-forward.service` (script `/usr/local/sbin/nomachine-vm-forward.sh`).
### 1.2 Vision — score template_matching effondré (régression apparente)
**Symptôme** : steps `click_anchor` qui passaient à 0.97-0.98 hier donnaient 0.498 (icône Ubuntu) ou 0.757 (Editeur SQL) → cascade VLM/template échoue → `strict_vlm_template_failed`.
**Cause établie par mesure pixel-précise** :
- Le screenshot envoyé par Léa Windows au serveur fait **800×500 px** (downscale par défaut dans `agent_v0/agent_v1/core/executor.py:2895` — `_capture_screenshot_b64(max_width=800, quality=60)`).
- Les ancres sont enregistrées en pixels physiques (ex. 91×90 px sur capture record 2560×1600).
- Ratio capture runtime / capture record = 800/2560 = **0.3125** → ancre 91×90 devient 27×27 dans la capture serveur.
- La liste multi-scale `resolve_engine.py:130` ne descendait qu'à `0.5` → trou entre 0.5 et l'échelle réelle 0.3 → `cv2.matchTemplate` plafonnait à 0.498.
- Vérification chiffrée (script ad hoc sur la capture du fail) : à scale 0.30, score = **0.900** pile, centre détecté au pixel près de la cible attendue.
**Fix posé** : extension de la liste multi-scale dans `resolve_engine.py:130`.
Avant :
```python
for scale in [1.0, 0.9, 1.1, 0.8, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 1.75, 2.0]:
```
Après :
```python
for scale in [1.0, 0.95, 0.93, 0.9, 1.05, 1.1, 0.85, 0.8, 1.15, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 0.4, 0.35, 0.3, 0.25, 1.75, 2.0]:
```
Backup avant modif : `docs/handoffs/2026-05-18_resolve_engine_avant_scales_fix.py`.
**Validation runtime** : workflow `linux_db` exécuté de bout en bout, tous les steps visuels OK (LINUX_demo ✓, Activités GNOME ✓, DBeaver ✓, demo_95 ✓, Editeur SQL ✓ à 0.948, etc.). Modif **non commitée**.
**Pourquoi la régression est apparue "hier ça marchait"** : la valeur `max_width=800` est ancienne et inchangée. Hypothèse retenue (par élimination, audit serveur+client en agents parallèles) — un changement récent du flux serveur a fait que le screenshot consommé pour le matching est celui downscalé au lieu d'une capture full-res antérieure, OU les 20 tests d'hier étaient sur une configuration où NoMachine cropait différemment et l'écart ne touchait pas le seuil. À investiguer post-démo.
### 1.3 Replay — `paste_and_execute` step 8 (workflow linux_db)
**Symptôme** : `paste_and_execute échoué (rc=1) stderr=ERREUR: ydotoold (socket /tmp/.ydotool_socket) absent dans la VM`.
**Cause** : ydotoold est démarré à la main depuis hier soir (handoff 17 mai §4) — pas de persistance. Au prochain reboot VM (ou kill manuel), le daemon disparaît.
**Fix posé — service systemd persistant** sur la VM :
`/etc/systemd/system/ydotoold.service` (créé, enabled, active) :
```ini
[Unit]
Description=ydotool daemon (input injection pour replay RPA Vision)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/ydotoold --socket-path=/tmp/.ydotool_socket --socket-perm=0666
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
```
Effet : démarre au boot VM, redémarre auto si crash, socket en `/tmp/.ydotool_socket` perm 0666.
**Clipboard gardien** : toujours géré par `scripts/prepare_clipboard_linuxdb.sh` (pas systématisé, à relancer manuellement après reboot VM — sujet ouvert, voir §3).
---
## 2. Dette technique nouvellement identifiée
### Dette A — Client Léa Windows envoie des captures 800×500 au serveur
**Localisation** : `agent_v0/agent_v1/core/executor.py:2895` — défaut `max_width=800` pour `_capture_screenshot_b64`.
**Sites appelants à corriger pour forcer full-res** (7 sites) :
- ligne 633, 801, 824, 894 (screenshot_before), 935, 989, 1055, 1303 (screenshot_after / result["screenshot"]) → tous sans override, donc tous downscalent à 800 px.
- Seules les lignes 1334 et 2147 (resolve_target) passent `max_width=0` (full-res).
**Fix recommandé post-démo** : ajouter `max_width=0, quality=75` sur ces 7 sites d'appel (préserver la flexibilité du défaut pour usages de monitoring live). Puis SCP vers `dom@192.168.1.11` + restart Léa.
**Workaround actuel** : l'élargissement multi-scale côté serveur (§1.2) compense fonctionnellement la dette. À conserver de toute façon.
### Dette B — Bug `'int' object has no attribute 'get'` dans VLM Quick Find
Trace dans les logs du 18 mai 08:35:46 : `VLM Quick Find : exception (20.4s) — 'int' object has no attribute 'get'`. Erreur Python dans le pipeline VLM (pas un crash Ollama). Non bloquant tant que template matching réussit, mais à investiguer.
### Dette C — Persistance du gardien clipboard sur la VM
Le script `prepare_clipboard_linuxdb.sh` doit être relancé à la main après chaque reboot VM. Aujourd'hui : oneshot. Possible amélioration : systemd-user service ou systemd timer. Hors scope J-3.
---
## 3. Routine reboot à jour
**Au boot hôte Linux** :
1. `systemctl --user start rpa-streaming` — manuel ou auto via session
2. `nomachine-vm-forward.service` — auto via systemd (DNAT 4001→4000)
3. `libvirtd` démarre `default` → hook `/etc/libvirt/hooks/network` injecte `LIBVIRT_FWI ACCEPT 4000` (auto)
4. UFW route allow — persistée par UFW
**Au boot VM Ubuntu** :
1. `ydotoold.service` — **auto** (systemd, installé aujourd'hui)
2. `xhost +local:` — manuel, perdu au reboot
3. `prepare_clipboard_linuxdb.sh` — manuel (charge le payload SQL + gardien)
**Côté Windows** :
1. Lancer Léa (`run_agent_v1.py`)
2. Ouvrir NoMachine via icône bureau "LINUX_demo" — pointe vers `192.168.1.40:4001` (DNAT vers VM)
---
## 4. État runtime à la clôture de session
- `rpa-streaming` actif (PID 1641479+), VLM `qwen2.5vl:7b` chargé
- ydotoold actif côté VM (systemd, PID 48926)
- Clipboard VM rempli (1635 bytes payload INSERT MOREL, table `requalification_urgence`)
- Workflow `linux_db` (9 steps) validé E2E aujourd'hui — temps total ~30s
- Workflow `Demo_urgence_3_db` (40 steps) en cours de re-record par Dom au moment du handoff
---
## 5. Action recommandée avant la démo J-3
1. **Commiter** le fix scales `resolve_engine.py:130` (1 ligne) — workflow validé E2E
2. **Tester** un reboot VM pour valider que `ydotoold.service` redémarre bien et que le hook libvirt repose la règle `LIBVIRT_FWI`
3. **Vérifier** que `prepare_clipboard_linuxdb.sh` est ajouté à la procédure pré-démo dans `docs/handoffs/2026-05-17_handoff_session_nomachine.md` §6 (à compléter)
4. **Optionnel** : traiter dette A (`max_width=0` client) si temps avant J-3

File diff suppressed because it is too large Load Diff