82 Commits

Author SHA1 Message Date
Dom
c371c9775f chore(bench): résultats bruts bench OCR (docTR 139 + EasyOCR 54 items, référencé par doc bench)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m56s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:49:41 +02:00
Dom
931cf13217 feat(navigate): jalon partiel D1 — compile navigate + coercion coords sûre
Ferme Gap C : _edge_to_normalized_actions produit désormais une action navigate
(handler serveur atteignable). Ajoute _coerce_action_coords : cast x_pct/y_pct en
float APRÈS résolution des templates, JAMAIS de fallback (0,0) — template non
résolu / valeur invalide → pause_for_human (safety_level=high). Non-régression
prouvée sur mouse_click classiques (idempotent sur floats).

⚠️ NE FERME PAS le write-only : Gap A (P1-B) non livré — aucun step click/type ne
déclare encore consommer navigate_login_coords. TestCompilerGapLiteralFloats
assert l'état ouvert. Boucle complète = chantier suivant (P1-B + test e2e edge→action).

21 tests verts, boot OK. Revue croisée Claude (GO jalon partiel).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:49:32 +02:00
Dom
fd9efdbbf5 docs(bench): PP-OCRv5 vs docTR vs EasyOCR CPU — PP-OCRv5 BLOCKED, docTR reste roi
Bench candidat PP-OCRv5 (veille OCR 02/07) : CPU BLOCKED (bug upstream
paddlepaddle 3.3.1 PIR/OneDNN, non contournable). docTR CPU = meilleur
rapport qualité/latence (0.7s, 10/11, word-level bboxes). PaddleOCR venv =
confirmé ORPHAN. Bench GPU = action séparée si on veut ré-évaluer PP-OCRv5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:45:36 +02:00
Dom
19187e633e docs(carto): carte de référence wiring code branché/non branché
Carto « existing-first » prouvée (chaîne imports depuis points d'entrée actifs,
fichier:ligne). Découvertes : self-healing = façade morte (enable_healing
fantôme), navigation write-only (avant D1), autonomous_planner inerte, YOLO/
CLIP/V4/api_core morts. Corrige 4 erreurs de nos cartos antérieures. Cascade
résolution = VLM-first prouvé (≠ README OCR→template→YOLO→VLM).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:44:47 +02:00
Dom
9a34ecded6 docs(R1): acte create-or-skip + corrige docstring wiring périmé
Sémantique R1 tranchée par Dom (02/07) : create-or-skip (la version validée
par revue humaine fait foi, un ré-apprentissage ne l'écrase pas). Aligne
docstring learned_workflow_bridge (disait à tort « pas branché au worker »
depuis c82829f2b) + spec F1-1 (barré create-or-update).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:44:47 +02:00
Dom
bd1c9d2c8a feat(deploy+bench+ops): DGX vm scripts, Windows RDP launcher, bench cases, agent_chat enable script
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m53s
tests / Tests sécurité (critique) (push) Has been skipped
2026-07-02 13:32:36 +02:00
Dom
6907ecc82f docs: track design docs, plans, audits, coordination infrastructure, handoffs
- 21 docs/*.md: audits, design notes, deployment plans, checklists, memos
- Coordination: ROLES, runbooks (DGX reboot, Lea live), patches, registre, syntheses, systemd, QG template
- Handoffs: 6 Codex handoff documents + README + template
2026-07-02 13:29:58 +02:00
Dom
7dd5c872df chore(gitignore): untrack ephemeral state + ignore large local artifacts
- git rm --cached: .inbox_baseline.txt, .loop_log.txt (coordination ephemeral)
- Add: .agents/, .codex/, agent_chat/state/, graphify/, graphify-out/ (local state/tool)
- Add: webbrowser (11M PostScript artifact), deploy/installer/lea_python_embed_working.tgz (37M)
- Add: benchmarks/computer_use/predictions/ (generated), **/instance/*.db.bak* (runtime backup)
2026-07-02 13:29:21 +02:00
Dom
bb1ea42318 feat(tools): add 7 wired+bench utility scripts (A+B classification)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m49s
tests / Tests unitaires (sans GPU) (push) Failing after 1m53s
tests / Tests sécurité (critique) (push) Has been skipped
- A (wired, imports project modules): e2e_map_roles, anonymize_demo, grounding_e2e_resolve_engine
- B (orphan projection, standalone benches): enrichment_eval_multi, extract_easily_bench_cases, extract_record_bench_cases, grounding_eval_multi
2026-07-02 13:27:04 +02:00
Dom
b062e2cca7 chore(gitignore): add C-MORT one-shot tools + sanitizer test gitignore list
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m48s
tests / Tests sécurité (critique) (push) Has been cancelled
tests / Tests unitaires (sans GPU) (push) Has been cancelled
2026-07-02 13:24:59 +02:00
Dom
4cb173a8ec chore(coordination+docs): watcher mandat AGENTS.md, recadrage POC CLAUDE.md, dette enrichie, loop script robustifié
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m49s
tests / Tests unitaires (sans GPU) (push) Failing after 1m53s
tests / Tests sécurité (critique) (push) Has been skipped
2026-07-02 13:07:34 +02:00
Dom
882e4e1f3a docs(design+audit): navigate coords consumption gaps + dead code C-MORT audit
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m51s
tests / Tests unitaires (sans GPU) (push) Has been cancelled
tests / Tests sécurité (critique) (push) Has been cancelled
DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md: 3 structural gaps
with code evidence (Gap A/B/C), 2 resolution options with comparative
table, test rouge proposal.

AUDIT_CODE_MORT_2026-07-02.md: 8 C-MORT, 5 B-ORPHELIN, 4 duplicats,
3 QG-gated suppression lots (~1900 lines).
2026-07-02 13:02:04 +02:00
Dom
cac965cef9 test(coords+capture): coords write-only gap (10 tests) + capture I/O + image_chat_cli
test_coords_consumption_gap.py documents 3 structural gaps where
NavigateCoords are written but never consumed. test_capture_io.py and
test_image_chat_cli.py cover capture and chat CLI paths.
2026-07-02 13:01:49 +02:00
Dom
ebed4d7546 feat(vwb): pont R1 import idempotent core→DB par signature trajectoire
Add import_core_workflow_to_db() — create-or-update par signature de
trajectoire (décision produit Dom 23/06). Les workflows source='manual'
sont exclus du filtre de fusion. Inclut test TDD idempotent (ré-import
2× → toujours 1 seul workflow).
2026-07-02 13:01:33 +02:00
Dom
9a8242add5 chore(gitignore): ignore coordination ephemeral dirs + untrack workflows.db
- Add inbox_qwen/, inbox_codex/, inbox_claude/, active/ to gitignore
- Add .inbox_baseline.txt, .loop_log.txt to gitignore
- git rm --cached workflows.db (runtime data, already covered by **/instance/*.db rule)
2026-07-02 13:01:18 +02:00
Dom
f9a0531325 feat(navigation): brique login visuel OCR-ancre + action navigate au replay
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m52s
tests / Tests unitaires (sans GPU) (push) Failing after 1m58s
tests / Tests sécurité (critique) (push) Has been skipped
- core/navigation/ : visual_verifier (presence=OCR, role=VLM ancre sur tokens),
  grounding (OCR-anchor first, VLM fallback, cache coords valide par la vue),
  visual_login (verify_before/after, DETTE-023), action_resolver (pont runtime)
- api_stream/replay_engine : dispatch action navigate server-side,
  never-fail -> needs_review, import depuis core.navigation (boot 5005 garanti)
- 131 tests verts (wiring boot, e2e handler, unit modules)

Chantier Qwen 01-02/07/2026, revue croisee Claude (plan deploy v2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:31:44 +02:00
Dom
ab78ae390a chore(version): bump 1.0.1 -> 1.0.2 (fixes client + installeur upgrade-safe)
Nouvelle politique : versionner chaque livrable. 1.0.2 = httpx embed +
capture JPEG + watchdog RDP + MAJ silencieuse (OFF) + installeur voie 1
(preserve identite, tue Lea, backup, purge). Source de verite = config.py
(AGENT_VERSION) + Lea.iss (MyAppVersion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:06:39 +02:00
Dom
e59489e2cd feat(installer): upgrade — backup rollback (hors embed) + purge captures
Complete la voie 1 sur les 2 items de confort/securite du checklist upgrade :
- BACKUP : robocopy code+config vers <app>_backup HORS python-embed/sessions/
  logs (leger, rapide) => filet de rollback manuel si la nouvelle version
  deconne (l'install manuel n'a pas d'A/B auto).
- PURGE : suppression des captures accumulees (agent_v1/sessions) = donnees
  d'apprentissage internes non exploitables cote clinique. Libere le disque ;
  le fix capture JPEG evite la reprise de saturation. Logs conserves (180j).

Valide .11 (upgrade sur etat Emilie) : Lea tuee, identite+serveur preserves,
backup code/config sans embed, 40 PNG purges -> 0, exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:58:54 +02:00
Dom
86e31ada34 fix(installer): upgrade-safe voie 1 — preserve identite+config, tue Lea avant copie
Poste clinique (Emilie) = install existante + Lea vive + config reelle
(machine_id lea-4zbgwxty, vrai serveur). L'installeur regenerait config.txt +
machine_id a chaque install => l'upgrade ecrasait l'identite fleet et forcait
la resaisie du serveur/token (Gap 1), et ne fermait pas la Lea en cours =>
DLL python-embed verrouillees (Gap 2).

Voie 1 :
- FindExistingInstallDir + LoadExistingConfig : detecte l'install, pre-remplit
  le wizard avec la VRAIE conf et memorise le machine_id.
- CurStepChanged(ssInstall) : preserve le machine_id existant (pas de regen).
- PrepareToInstall : tue Lea via le PID du lock avant la copie (libere les DLL).

Valide sur .11 (upgrade silencieux sur etat Emilie simule) : machine_id +
serveur preserves, fausse Lea tuee, lock retire, 4 fixes presents, exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:48:40 +02:00
Dom
94fd93ad19 chore(build): verifie anyio+typing_extensions dans l'embed (deps transitives httpx)
Le check de completude embed ne verifiait que httpx/httpcore/h11 ; anyio et
typing_extensions (requis par httpx 0.28.1 sous py<3.13) manquaient => import
httpx aurait pu casser a l'install malgre un build vert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:48:40 +02:00
Dom
50f34b5727 merge(client): fixes Lea poste Emilie — httpx embed, capture JPEG, watchdog RDP, MAJ silencieuse (gated OFF)
4 fixes TDD-verts (revue qualite 3 GO + httpx debloque par peuplement embed).
MAJ silencieuse embarquee flag OFF (dormante, quadruple gate). Cible EXE->Julien.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:44:40 +02:00
Dom
a1b3062991 chore(config): pin AGENT_VERSION=1.0.1 (config client + template)
Prerequis merge fixes client Lea : la MAJ silencieuse rapporte AGENT_VERSION
au serveur ; on fige explicitement 1.0.1 (defaut du code) cote config livree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:44:40 +02:00
Dom
a210e5ee32 feat(update): swap atomique + rollback (Lea.bat) + confirmation boot (main.py)
Implémente le SWAP réel de la MAJ silencieuse (DETTE-022), remplace les stubs :
- updater.apply_update : ARME le swap (extrait le ZIP -> agent_v1_new/ +
  marqueur UPDATE_READY, garde-fou zip-slip). N'écrase JAMAIS le vivant.
- updater.write_boot_ok_marker : désarme le rollback (retire PENDING_BOOT).
- Lea.bat (template + embed généré par configure_embed.ps1) : swap ATOMIQUE
  par renames (agent_v1 -> agent_v1_prev backup ; agent_v1_new -> agent_v1)
  + rollback auto si PENDING_BOOT persiste (boot précédent non confirmé).
- main.py : confirme le boot après 90 s de liveness locale OU quit propre
  (évite un faux rollback ; RPA_BOOT_CONFIRM_DELAY_S surchargeable pour les tests).

Testable (Python) : 45 tests verts. Le swap OS (renames Lea.bat) + le câblage
main.py seront validés par le test Win 11 (step 0 pré-canary, dont le rollback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:10:34 +02:00
Dom
5d235e49f1 merge: MAJ silencieuse scaffold 2026-07-01 12:37:21 +02:00
Dom
e679804cfd merge: disparition Lea (watchdog) 2026-07-01 12:37:21 +02:00
Dom
e57b54a100 merge: capture JPEG+chemin 2026-07-01 12:37:21 +02:00
Dom
d34c1f2697 merge: httpx build 2026-07-01 12:37:20 +02:00
Dom
61664c9a36 feat(update): scaffold MAJ silencieuse + canary par machine (DETTE-022, gated OFF, swap encore stub)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:36:48 +02:00
Dom
9ab5ed4671 fix(agent): resilience disparition Lea en RDP/Citrix (watchdog session interactive re-affiche le tray)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:36:47 +02:00
Dom
144a5c288a fix(agent): capture JPEG+downscale (allege CPU/disque, frequence intacte) + robustesse chemin _background/shots
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:36:47 +02:00
Dom
e3f61de4ad fix(agent): embarquer httpx>=0.27 dans le build embed (orchestrateur Lea muet en clinique)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:36:47 +02:00
Dom
2a1b1ed80e feat(stream): dispatch extract_dossier -> handler serveur
Some checks failed
tests / Lint (ruff + black) (push) Failing after 2m3s
tests / Tests unitaires (sans GPU) (push) Failing after 1m51s
tests / Tests sécurité (critique) (push) Has been skipped
Câble le type d'action 'extract_dossier' dans get_next_action (api_stream)
vers _handle_extract_dossier_action (replay_engine). La brique 3 (OCR ->
gate -> persist dossier VWB) était committée mais non atteignable au runtime
faute de dispatch. Import + elif dédié, timeout 180s, exécuteur non bloquant.

Note: le handler utilise encore l'ancienne chaîne (extract_grid + gate maison).
Le remplacement par l'extraction ancrée (map_roles/vlm_client) est une modif
séparée côté replay_engine (ma zone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:05:22 +02:00
Dom
f09b8b8cfd feat(extraction): client vLLM serveur (image+prompt -> texte, post_fn injectable)
Factorise un client propre pour la lecture d'écran : downscale image (fenêtre
max_model_len), thinking off, post_fn injectable (testable sans vLLM). Sert de
vlm_client à extract_dossier_from_image dans le handler runtime. 4 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:03:26 +02:00
Dom
6a78a0059b feat(extraction): extract_dossier_from_image — orchestrateur OCR->VLM->qualite (injectable)
Enchaine ocr_fn -> tokens_from_grid -> map_roles -> assess_quality. OCR et client
VLM injectables (testable hors-ligne, import OCR lazy = module reste pur). C'est la
brique que le handler runtime extract_dossier appellera. 4 tests (35 au total role_mapper).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:26:16 +02:00
Dom
813b33b47e feat(update): DETTE-022 — noyau MAJ silencieuse client Léa (gated, swap en stub)
Logique PURE testée : parse_version semver (R3), decide_update code-only/full (R2),
should_update client (double garde anti-downgrade), download_update (staging only +
SHA256, downloader injectable). Endpoint GET /api/v1/agents/update/check gated
(RPA_AUTO_UPDATE_SERVER_ENABLED). Flags client+serveur OFF par défaut.
Swap fichiers / Lea.bat / restart = STUBS no-op réservés révision humaine.
34 tests TDD. refs DETTE-022

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:21:35 +02:00
Dom
a50057d499 fix(dashboard): DETTE-024 — download fleet, fallback legacy rendu visible
_resolve_lea_zip_template() reste résolu à la volée (full buildé après démarrage OK) ;
ajout d'un WARNING explicite quand le full est absent et qu'on retombe sur le ZIP
léger non autoportant (plus de fallback silencieux). Fonction injectable pour tests.
4 tests + 32 non-régression verts. refs DETTE-024

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:20:28 +02:00
Dom
3ed9798f06 feat(agent_v1): log shipper — remontee auto des logs vers le serveur (gated OFF)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m43s
tests / Tests unitaires (sans GPU) (push) Failing after 1m51s
tests / Tests sécurité (critique) (push) Has been skipped
LogShipperHandler + LogShipper : buffer borne, flush par batch <= max, resilience
0-perte (rejeu sur echec), sender injectable. Flag RPA_LOG_SHIP_ENABLED (defaut
off, activable par config.txt sans rebuild). Sanitizer client = identite (rempart
PII = serveur, cf commit precedent). Wiring gated dans main.py. 8 tests TDD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:30:08 +02:00
Dom
b65710ae43 feat(server): assainissement PII des logs clients à la réception
sanitize_log_entries (réutilise anonymize_text, mapping partagé = tokens cohérents),
branché dans POST /api/v1/agents/logs avant le store : message + logger tokenisés,
ts/level préservés. 7 tests TDD. Rempart PII central du push-log (couvre les postes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:30:08 +02:00
Dom
509a026cfc feat(extraction): assess_quality — statut qualité dossier (4 niveaux)
complete / partial / needs_review / failed (priorité décroissante), matching
rôle requis insensible casse+espaces, seuil min_confidence paramétrable (0.6).
16 tests ajoutés (31 au total, verts). Brique TDD via sous-agent, code révisé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:42:14 +02:00
Dom
a62b720144 feat(extraction): map_roles — orchestrateur VLM ancrage strict (client injectable)
build_role_prompt (modes libre / guidé par rôles), parse_vlm_json (robuste :
tolère les fences, {} si invalide), map_roles (prompt -> VLM -> parse -> reconstruct).
Client VLM injecté => testable hors-ligne. 6 tests unit ajoutés (15 au total).
Non branché au runtime (brique validée isolément).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:34:43 +02:00
Dom
14b1bf844a feat(extraction): role_mapper — reconstruction de champs ancrée OCR (0 hallucination)
Le VLM ne fournit que des value_ids ; la value est reconstruite côté Python
depuis l'OCR (le texte VLM est ignoré) -> 0 hallucination par construction.
9 tests unitaires : ancrage, ids hors plage, dédup ordonnée, value_ids vide,
confidence min, bbox englobante, anti-injection. Module pur, non branché runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:38:11 +02:00
Dom
c82829f2bb feat(server): R1 — import auto du workflow appris vers la DB VWB (gated)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m44s
tests / Tests unitaires (sans GPU) (push) Failing after 1m49s
tests / Tests sécurité (critique) (push) Has been skipped
finalize_session appelle _maybe_import_to_vwb : si RPA_R1_AUTO_IMPORT (OFF par
défaut), le workflow appris est assaini (sanitize_workflow_dict) puis importé en
DB VWB rejouable via le pont idempotent (import_core_workflow_to_db), dans un
app-context VWB lazy mutualisé (vwb_db). NON bloquant : un échec n'interrompt
jamais la finalisation. Rend l'appris rejouable sans geste manuel (R1).
Tests : câblage du seam + gating du flag + non-régression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:44:24 +02:00
Dom
6075717353 feat(server): durcissement sanitizer PII (chevauchements + GXD5 + workflow_dict)
- Résolution des chevauchements par priorité de détecteur + longueur : corrige le
  FN où, sur 'Dossier/Patient NOM (NAISSANCE) Prénom', le nom de naissance fuyait. (Qwen)
- RE_GXD5_DIAG : tokenise le numéro de dossier ([DOSSIER_n]) ET le nom ([NOM_n]) dans
  'GXD5 Diagnostics - <num> - NOM PRENOM' — 3 patients fuyaient en prod clinique, 0 FP. (Qwen)
- sanitize_workflow_dict : assainit les champs texte d'un workflow appris (by_text, noms)
  avant import en DB VWB (canal apprentissage). Utilisé par R1. (Claude)
14 tests verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:44:24 +02:00
Dom
13f760a3b9 feat(extraction): handler extract_dossier + pont worker→DB VWB mutualisé (brique 3)
vwb_db.py : couplage worker→DB VWB lazy (app Flask sur instance/workflows.db)
mutualisé (R1 + extraction), + persist_extracted_dossier (grille → Job/Table/Field).
replay_engine.py : handler _handle_extract_dossier_action — lit le screenshot,
extrait une grille structurée, gate qualité conservatrice (complete|needs_review),
persiste avec preuve (screenshot_ref/bbox/confidence). N'échoue JAMAIS le replay.
Données patient EN CLAIR (canal extraction, non anonymisé).

Réserve : dispatch runtime (api_stream.py) non encore branché — étape suivante,
à coordonner. Brique 3/4 de la verticale extraction dossier patient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:18:08 +02:00
Dom
9883cad012 feat(extraction): modèle DB dossier patient extrait (Job/Table/Field)
ExtractionJob -> ExtractedTable -> ExtractedField (SQLAlchemy, cascade), avec
preuve par cellule (bbox + confidence) réutilisant la sémantique VWBEvidence,
et statut dossier needs_review|complete. Brique 2 de la verticale extraction.
Documenté : ce canal conserve les données patient EN CLAIR (≠ canal
apprentissage anonymisé) — aucune anonymisation ne doit cibler ces colonnes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:47:03 +02:00
Dom
5ed5ae2d4b feat(extraction): lecture de tableau structurée (grille bbox+confiance)
Nouvelle extract_grid_from_image() : reconstruit une grille List[List[cell]]
(lignes ET colonnes par clustering des centres y/x des tokens EasyOCR), en
conservant bbox + confiance + (row,col) par cellule. Contrairement à
extract_table_from_image (liste plate, coordonnée x jetée) — laissé intact.
Brique 1 de la verticale extraction dossier patient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:46:48 +02:00
Dom
7fb58195fb fix(workflow): conserve machine_id au round-trip to_dict/from_dict
Les workflows rechargés du disque retombaient sur machine_id='default' :
to_dict ne sérialisait pas l'attribut d'instance _machine_id et from_dict ne
le reposait pas (il dormait dans metadata['machine_id']). to_dict le sérialise
si présent (pas de 'default' parasite) ; from_dict le restaure depuis le champ
explicite ou metadata (rétrocompat des workflows déjà sur disque).
Test de non-régression round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:05:10 +02:00
Dom
fccc06e4a2 feat(server): floute aussi les focus_* (blind spot PII)
Les screenshots focus_* (plein écran, ~1440 fichiers/350 Mo) contenaient des
titres PII non floutés. La condition de blur serveur les inclut désormais,
au même titre que shot_*_full et heartbeat_*. Brut conservé, version _blurred
produite en parallèle. (blind spot relevé par Qwen, revue 28/06)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:05:10 +02:00
Dom
6461f0a21b feat(server): câble sanitize_event au chokepoint stream_event (PII)
Assainissement PII appliqué une seule fois à l'entrée de stream_event(),
avec un mapping de tokens par session (cohérence intra-session). Les chemins
de persistance et de traitement (jsonl, worker.process_event_direct,
shadow_observe_event, enrichissement SOM) consomment tous la copie assainie
au lieu de l'event brut — plus aucune PII patient en clair côté serveur.

Test de non-régression du câblage: stream_event ne doit jamais écrire de PII
brute (IPP/contenu saisi) dans live_events.jsonl ni la propager au worker/shadow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:39:27 +02:00
Dom
e84cdee393 fix(server): durcissement sanitizer PII suite revue adversariale Qwen
- FN-1/2/3 : ajout RE_PRENOM_NOM (« Prénom NOM » inversé sans parens/crochets,
  ex. « Alix DATTIN ») ; 2e mot tout-majuscules -> 0 FP sur « Mozilla Firefox ».
- FN-4 (majeur, 228 events) : sanitize_event scanne désormais les titres
  RÉCURSIVEMENT (vision_info.window_capture.window_title et tout titre imbriqué),
  au lieu de 3 clés top-level hardcodées.
2 correctifs issus de la revue croisée Qwen. 11 tests verts, 0 FP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:24:52 +02:00
Dom
30d8f65e9a feat(server): sanitize_event — assainissement PII au niveau event
sanitize_event(event, mapping) applique le principe « Léa apprend l'interface,
pas la donnée » (décision Dom 28/06) avant persistance :
- text_input -> contenu (text + raw_keys) remplacé par [SAISIE] (option b) :
  résout la fuite la plus grave (contenu médical) SANS NER ni détection ;
- titres de fenêtre (active_window_title + window/to/from.title) : identité
  patient tokenisée (anonymize_text), app/écran gardés ; cohérence par mapping.
Copie défensive (ne mute pas l'event d'origine). 4 tests (9 au total) verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:53:09 +02:00
Dom
8e4d09594c feat(server): assainissement PII couche regex+structurelle (tokens typés cohérents)
pii_sanitizer.anonymize_text() remplace la PII par des tokens typés et
cohérents ([IPP_1], [AGE_1], [NOM_1]) : protège la donnée ET garde la structure
(type de champ) utile à l'apprentissage des variables. Sans modèle, déployable
partout. Filet regex (IPP/NIR/TEL/EMAIL/AGE, repris de anonymisation) + règles
structurelles cliniques (NOM (NAISSANCE) Prénom ; [Nom Prénom] PACS) + blacklist
logiciels anti-FP. 5 tests verts. Couche NER (noms libres) en complément ensuite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:08:43 +02:00
Dom
46ad5973d1 fix(agent_v1): assainissement PII des logs client a la source (push-log-DGX, brique 4)
Remplace dans les logs/print le contenu utilisateur brut par un equivalent
PII-safe via core/log_safe : titres de fenetre -> _title_hash, reponses VLM ->
[len,has_target], metadonnees -> _sanitize_metadata, chemins -> _path_ext,
workflow_name -> _title_hash. 8 fichiers (executor, recovery, captor, streamer,
main, capture_server, activity_panel, window_info_crossplatform).

Audit Qwen complete : ~17 fuites de titre multi-lignes + 2e fuite VLM (print)
non listees ont ete traitees ; localisation par contenu (refs Qwen derivees).

Preserve volontairement : prompts de grounding VLM (vlm_description) ou le titre
est load-bearing (resolution 100% vision) -> ne PAS hasher.
Differe : window_focus_change (verdict apprentissage).
En attente arbitrage Dom : button_text (~11 captions), patterns, champs detail.

py_compile 8/8 OK, imports OK, helper 6/6 vert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:42:40 +02:00
Dom
4a38000e74 feat(agent_v1): helpers logging PII-safe (push-log-DGX, brique 4)
Module agent_v1/core/log_safe.py — 3 helpers purs pour assainir les logs
client à la source : _title_hash (SHA1[:8], corrélation sans révéler),
_sanitize_metadata (drop title/active_window/window_title), _path_ext
(extension seule). 6 tests unitaires verts. Module inerte (non encore wired) ;
le branchement dans le code runtime suit en étape supervisée.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:24:54 +02:00
Dom
2597ca9110 feat(server): endpoint GET /api/v1/agents/logs/{machine_id} (push-log-DGX, brique 3)
Route de diagnostic dashboard (read-only) : restitue les logs poussés par un
poste, rangés par machine_id. Bearer global ; volontairement sans garde fleet
(consultation d'un poste révoqué/en panne). limit=tail pour borner la réponse.
4 tests d'intégration verts ; store inchangé (briques 1-2 figées).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:47:08 +02:00
Dom
bbe897e614 feat(server): endpoint POST /api/v1/agents/logs (push-log-DGX, brique 2)
Reçoit un batch de logs client, range via AgentLogsStore par machine_id.
Garde-fous : auth Bearer (401), agent actif via _guard_agent_registry_access
(403 si révoqué/inconnu, + touch_last_seen), cap anti-flood 413 (G3 Qwen,
RPA_AGENT_LOGS_MAX_BATCH=1000). TDD 4/4 ; non-régression enroll 16/16.

refs DETTE-020 DETTE-021

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:25:14 +02:00
Dom
a29b7a2f21 feat(server): store de logs clients par machine_id (push-log-DGX, brique 1)
AgentLogsStore : append/read JSONL rangés par machine_id (fichier par jour),
anti path-traversal sur machine_id (entrée réseau), purge_old rétention 30j
(garde-fou G4 Qwen). TDD 3/3 vert. Pas encore wired (endpoint = brique 2).

refs DETTE-020 DETTE-021

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:14:28 +02:00
Dom
105ade959d chore(agent_v1): AGENT_VERSION configurable via RPA_AGENT_VERSION (amorce DETTE-022)
Permet d'identifier la version déployée par poste (préparation MAJ auto).
Inoffensif pour DETTE-021 ; nettoie le working tree avant déploiement Émilie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:50:58 +02:00
Dom
29cb466595 fix(lea): journalisation client vers fichier (DETTE-021)
setup_logging() branche un TimedRotatingFileHandler vers LOG_FILE (rotation
quotidienne + rétention 180j, Règlement IA Art.12) + console. Sous pythonw
(sans console), basicConfig->stderr était perdu => diagnostic terrain aveugle.
main.py appelle setup_logging au démarrage, avec fallback console si le fichier
est indisponible (ne jamais empêcher Léa de démarrer).

TDD: tests/unit/test_agent_v1_logging.py (3 tests RED->GREEN ; module chargé par
chemin pour éviter les imports lourds DETTE-011/013). py_compile main.py OK.

refs DETTE-021

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:44:31 +02:00
Dom
de73cbd404 docs(dette): DETTE-021 (logs client Léa non effectifs) + DETTE-022 (MAJ auto Léa)
DETTE-021: LOG_FILE défini mais jamais branché (basicConfig->stderr perdu sous
pythonw, dossier logs vide) -> diagnostic terrain aveugle + non-conformité
Règlement IA Art.12 (180j). Pendant client du DETTE-020.
DETTE-022: modif client = redéploiement manuel poste par poste -> dérange les
TIM, ne scale pas. Besoin MAJ auto/tâche de fond. Décision Dom 2026-06-25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:32:32 +02:00
Dom
1b491326be docs(dette): DETTE-020 (P1) — incidents silencieux, pas d'alerte composant critique HS
Grounder vLLM (rpa-vllm-grounder) trouvé en crash-loop (×3960) → bascule
silencieuse sur fallback Qwen2.5-VL, sans remontée dashboard/log/alerte.
Découvert par vérif manuelle runtime (DGX clinique, 2026-06-25). Dette = absence
de supervision/alerte des composants critiques (vLLM/Ollama/services rpa-*) ;
la cause SSL/offline du crash se corrige à part.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:37:18 +02:00
Dom
3b592dd867 feat(core): signature de trajectoire PII-safe + normalisée (R1/R2 amendés, QG Qwen)
Anonymisation déterministe de la cible par regex DÉDIÉES (email/date/tél/IPP →
tokens) avant hashing : deux sessions sur le même champ (patients/dates
différents) → même signature. Normalisation casse/accents/espaces (logique
action_executor._norm_text, redéfinie localement pour rester léger).

Choix QG Qwen (2026-06-25) : PAS de pii_blur (il protège les dates qu'on veut
neutraliser), PAS de NER (un hash d'identité doit être déterministe/portable
labo↔DGX). Noms propres sans titre non gérés (stratégie b ; gate = audit
agrégat by_text DGX avant prod). R2 fallback coords RETIRÉ (casserait F1).
R3 (machine_id hors hash) déjà conforme.

TDD: +4 tests (RED→GREEN, 9/9). Primitive non wirée (0 consommateur runtime)
→ changement de calcul sans impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:47:18 +02:00
Dom
c9b7cdabb7 fix(core): signature de trajectoire stable malgre le moteur de grounding (by_text)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m53s
tests / Tests unitaires (sans GPU) (push) Failing after 1m49s
tests / Tests sécurité (critique) (push) Has been skipped
Le champ by_role remontait la methode de detection (yolo/ocr/vlm), instable entre
sessions : deux apprentissages du meme parcours detectes differemment produisaient
deux signatures -> fusion (create-or-update) ratee. On sort by_role de la signature
et on s'appuie sur le texte semantique de la cible (by_text), independant du moteur
de grounding. Fallback quand by_text vide : titre de fenetre / description VLM.

Test TDD: test_signature_stable_despite_grounding_role_difference (RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:35:57 +02:00
Dom
74df0822e2 feat(core): adaptateur workflow->signature de trajectoire (BFS edges, cibles stables)
Extrait d'un workflow core (dict) la sequence ordonnee (action_type, target stable)
via traversee BFS depuis entry_nodes (comme le bridge d'import), en n'utilisant que
des champs stables (by_role/by_text/window) et en ignorant coords/IDs de noeuds.
Branche la primitive trajectory_signature sur de vrais workflows.

Test TDD: tests/unit/test_workflow_trajectory_signature.py (3 tests, RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:22:30 +02:00
Dom
a86c1ebb83 feat(core): signature de trajectoire stable pour identite workflow (Phase 0, F1)
Primitive partagee (SP-4/SP-2/competences) : hashe la sequence ordonnee
(action_type, target) d'un parcours en ignorant les champs session-specifiques
(node_id, timestamp, coordonnees) -> deux apprentissages du meme parcours = meme
signature = base du create-or-update (decision F1). Le target stable peut etre
compose avec screen_signature() existante.

Test TDD: tests/unit/test_trajectory_signature.py (5 tests, RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:14:23 +02:00
Dom
2cabc6cb7e fix(vwb): propage l'image d'ancre aux substeps compound à l'import (SP-1/U-B)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m43s
tests / Tests unitaires (sans GPU) (push) Failing after 1m48s
tests / Tests sécurité (critique) (push) Has been skipped
Les actions compound passaient par _convert_compound_substep qui ne lisait
jamais l'image d'ancre du parent -> substeps anchor_id NULL, "Ancre requise"
sans image dans le VWB. On pose desormais l'ancre du parent (meme fallback que
la branche action simple) sur le 1er substep cliquable uniquement.

Test: test_learned_workflow_bridge.py (TDD, RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:11:32 +02:00
Dom
d686c3ac22 feat(deploy): installation 1-clic non-IT — raccourci Bureau + Demarrage auto
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m45s
tests / Tests unitaires (sans GPU) (push) Failing after 1m47s
tests / Tests sécurité (critique) (push) Has been skipped
Ajoute Installer-Lea.bat (CRLF/ASCII, chcp 65001) au paquet Lea complet :
- copie le paquet (python-embed inclus) vers %LOCALAPPDATA%\Lea (per-user,
  emplacement stable via robocopy, fallback xcopy) ;
- cree un raccourci Bureau + un raccourci dans le dossier Demarrage
  (lancement auto a l'ouverture de session) via WScript.Shell, cibles
  python-embed\pythonw.exe run_agent_v1.py (pas de console) ;
- icone optionnelle si un .ico est present dans le paquet (best-effort,
  sinon icone par defaut) ;
- lance Lea une premiere fois, message de fin clair.

Application SYSTRAY -> pas de service Windows (session 0 sans UI) :
dossier Demarrage + raccourci, per-user, sans admin/UAC.

LISEZMOI.txt du paquet remplacee par LISEZMOI-autonome.txt (le flux
install.bat + Python systeme n'existe plus dans ce paquet). build_package_full.sh
integre ces deux assets et les valide dans le ZIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:04:20 +02:00
Dom
e212f4141c fix(dashboard): servir le ZIP Lea complet autoportant à l'enrôlement Fleet
L'endpoint /api/fleet/download/<machine_id> servait deploy/Lea_v1.0.0.zip
(sources seules, suppose Python système) → installation impossible chez un
utilisateur non-IT sans Python. Désormais il sert en priorité le ZIP complet
deploy/build/Lea_full_v1.0.1.zip (python-embed inclus), avec fallback sur
l'ancien ZIP léger s'il est seul. Résolution du template à la volée (le ZIP
complet peut être buildé après le démarrage du dashboard) + message d'erreur
explicite. L'injection de Lea/config.txt est inchangée.

Le title du bouton de téléchargement ne ment plus : 'installation autonome,
sans Python — dézipper puis double-cliquer Lea.bat'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:58:51 +02:00
Dom
33ddb51c3c feat(deploy): script build ZIP Lea complet autoportant (python-embed + source à jour)
Construit deploy/build/Lea_full_v<version>.zip servi par le dashboard Fleet :
runtime Python 3.12 embedded inclus, source Lea du working tree COURANT
(force --clean pour ne pas réutiliser un deploy/build/Lea/ périmé en cache),
Lea.bat embedded extrait de configure_embed.ps1, _pth patché, config.txt
placeholder CONFIGURE_ME. Pas de install.bat : plus aucun Python système requis.

Garde-fous intégrés : refus de builder si config.py embarqué diffère du repo,
si install.bat présent, ou si python-embed incomplet. Extraction de version
robuste (gère AGENT_VERSION littéral OU os.environ.get).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:58:51 +02:00
Dom
1d6efdb1b7 feat(dashboard): enrôlement lit l'adresse serveur depuis system_config.json
Câble l'éditeur adresses/ports du dashboard (services.streaming) vers le
RPA_SERVER_URL généré pour chaque agent Léa. Priorité config > env > défaut ;
host loopback/vide = non configuré (fallback env → pas de régression).
Permet de changer l'IP serveur (labo .45 → clinique .178) depuis l'UI sans
toucher l'env ni le code. +3 tests TDD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:07:27 +02:00
Dom
cf81ce4c7b feat(vwb): Basic auth LAN sur backend 5002 — creds dashboard, loopback exempté
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m52s
tests / Tests unitaires (sans GPU) (push) Failing after 1m52s
tests / Tests sécurité (critique) (push) Has been skipped
VWB backend exposé au LAN sans auth (point pré-clinique). Ajoute HTTP Basic auth
(mêmes identifiants que le dashboard: DASHBOARD_USER/DASHBOARD_PASSWORD) via
@app.before_request ; exempte loopback (intégration dashboard/agent_chat intacte),
/health et OPTIONS. Frontend = Create React App (pas Vite) → auth backend suffit
(navigateur LAN challengé au 1er XHR vers 5002) ; build statique = cible clinique.

Déployé + vérifié DGX: loopback 200, LAN no-creds 401, LAN+creds 200. 10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:27:15 +02:00
Dom
ec1fb81054 fix(dashboard,worker): vérité produit P0 — dashboard+worker+VWB export
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m46s
tests / Tests unitaires (sans GPU) (push) Failing after 2m0s
tests / Tests sécurité (critique) (push) Has been skipped
War-room clôture DGX 2026-06-18 (recadrage Dom : graphe/apprentissage/mémoire/dashboard = surface produit P0).
Le dashboard et le statut worker affichaient des états faux ; corrige pour refléter la vérité du produit.

- dashboard FAISS: distingue index brut / metadata HMAC invalide / runtime / absent (plus de faux "inactif")
- dashboard process-mining: 503 explicite missing_dependency (plus de message trompeur)
- dashboard /api/workflows + system/status: lecture DB VWB v3 canonique (total réel = 24, plus de 0)
- worker /processing/status: véridique (lit _worker_health.json) + statut "idle/armé (lazy)" distinct de "dégradé (échec)"
- VWB export: N steps -> N actions/edges (dernière action n'est plus perdue)
- tests: dashboard routes, worker status truthfulness, export VWB

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:50:12 +02:00
Dom
6d5ef51c60 fix(server): api_upload load_env_file en setdefault (env systemd prime sur .env.local)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m47s
tests / Tests unitaires (sans GPU) (push) Failing after 1m49s
tests / Tests sécurité (critique) (push) Has been skipped
.env.local etait charge avec override systematique, ecrasant RPA_BIND_HOST
defini par le service systemd -> upload API bindait 0.0.0.0 malgre le drop-in.
setdefault aligne sur la convention dotenv (override=False) : l'env explicite
du service prime, .env.local ne fournit que des defauts. Complete d0c794d92.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:34:43 +02:00
Dom
d0c794d923 fix(systemd): bind upload api to loopback
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m47s
tests / Tests unitaires (sans GPU) (push) Failing after 1m56s
tests / Tests sécurité (critique) (push) Has been skipped
2026-06-17 20:01:27 +02:00
Dom
9605cc9d95 fix(vwb): resolve frontend services from runtime host
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m46s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
2026-06-17 17:53:57 +02:00
Dom
667575c3ad feat(installer): make Lea autonomous for POC 2026-06-17 17:53:46 +02:00
Dom
787dbfb0eb fix(installer): configure_embed saute pip si deps deja embarquees (install offline)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m45s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
Quand l'embed est livre complet (socketio + tkinter pre-embarques),
le bootstrap get-pip.py + pip install echouait hors-ligne. Ajout d'un
guard : si 'import socketio, tkinter' OK -> on saute pip (offline).
Mode online legacy conserve si embed nu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:16:04 +02:00
Dom
86b5ec18c6 chore(installer): prep Lea-Setup-v1.0.1 — socketio dans requirements + exclusion fichiers test du staging
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m43s
tests / Tests unitaires (sans GPU) (push) Failing after 1m47s
tests / Tests sécurité (critique) (push) Has been skipped
- requirements_agent.txt : ajout python-socketio/engineio/websocket-client/simple-websocket
  (FeedbackBus/bulles ; jeu valide en runtime sur la VM)
- build_installer.sh : exclusion test_lea_*, _test_paused_toast.py, tools/test_* du staging
Reste (phase build sur .11) : pre-bundler tkinter+zlib1 dans l'embed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:52:49 +02:00
Dom
b8b963059e fix(vwb): import lit anchor_image_base64 dans target.context_hints
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m44s
tests / Tests unitaires (sans GPU) (push) Failing after 1m47s
tests / Tests sécurité (critique) (push) Has been skipped
Le converter convert_learned_to_vwb_steps ne lisait l'ancre que dans
target/screenshot/action.parameters, jamais dans target.context_hints
où le recorder la range réellement -> anchor_id NULL a l'import.
Ajout de la source context_hints (fallback or, additif, non regressif).
Preuve: import reel 'Explorateur — session' -> 4/5 steps anchor_id non NULL
+ 4 PNG, x_pct/y_pct preserves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:42:03 +02:00
Dom
2b1743c206 fix(poc-agent): ouvrir le chat Lea DGX si Tk est indisponible
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m43s
tests / Tests unitaires (sans GPU) (push) Failing after 1m46s
tests / Tests sécurité (critique) (push) Has been skipped
2026-06-15 21:32:54 +02:00
Dom
48879fb849 fix(vwb): conservation des données de position des anchors Lea lors de l'import
- Supprime le 'pop' de '_anchor_bbox' qui jetait les coordonnées de position (x_pct, y_pct).
- Conserve ces données dans les paramètres du step pour que le frontend puisse les utiliser pour afficher la zone ciblée.
- Évite la création d'une bounding box factice (écran entier) qui rendait le crop de l'ancre inutile.
- Impact isolé à la route d'import, aucun impact sur le runtime d'exécution de Léa ni sur DETTE-015.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-15 18:13:29 +02:00
Dom
c12fd8e1c1 fix(dashboard): VWB import URL dynamique pour éviter hardcoded localhost
- Remplace l'URL hardcodée 'http://localhost:5002' par une construction dynamique basée sur l'origine actuelle.
- Permet les tests d'import depuis la VM ou le poste de test via l'IP du banc (ex: 192.168.1.45) sans échec CORS/routage.
- Respecte la règle POC DGX : pas de localhost comme preuve produit.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-15 18:13:22 +02:00
195 changed files with 27427 additions and 827 deletions

32
.gitignore vendored
View File

@@ -121,12 +121,22 @@ results_vlm_bench.json
# Scripts locaux one-shot d'intervention/bench, non réutilisables tels quels.
tools/bench_qwen35_evidence.py
tools/codex_windows_correction_rapport.py
tools/diagnostic_lea_chat_win11.ps1
tools/poc_lecture_ecran.py
tools/watch_emilie_agent.py
test_sanitizer_live.py
# Verbatims clients (sensibles, à valider avant push)
docs/clients/
.qw-baseline.log
# Coordination ephemeral — inbox messages, active decisions, loop state
docs/coordination/.loop_state/
docs/coordination/.inbox_baseline.txt
docs/coordination/.loop_log.txt
docs/coordination/inbox_qwen/
docs/coordination/inbox_codex/
docs/coordination/inbox_claude/
docs/coordination/active/
# Runtime Python embedded pour l'installateur Inno Setup (local, ~11M, non versionné)
deploy/installer/python-3.12-embed/
@@ -134,3 +144,23 @@ deploy/installer/python-3.12.8-embed-amd64.zip
# Artefacts de build installateur (EXE compilés + staging) — non versionnés
deploy/releases/*.exe
deploy/build/
# Embed tgz working (37M, local build artifact)
deploy/installer/lea_python_embed_working.tgz
# Agent/Codex state (local, session-specific)
.agents/
.codex/
agent_chat/state/
# Graphify tool + generated output (1.2G)
graphify/
graphify-out/
# Local PostScript artifact (webbrowser = 11M DSC)
webbrowser
# Bench predictions (generated, not source)
benchmarks/computer_use/predictions/
# DB backups (instance level, runtime artifact)
**/instance/*.db.bak*

View File

@@ -10,3 +10,15 @@ Rules:
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
## coordination watcher
At the beginning of every session, the coordination watcher is mandatory and must be operational for Codex, Claude, and Qwen before coordination work continues.
Session-start checklist:
- Run `docs/coordination/coordination_loop.sh ensure`.
- Read every pending message relevant to the current agent.
- After messages are processed, run `docs/coordination/coordination_loop.sh ack`.
- If the watcher cannot be started or checked, report that blocker immediately in the handoff/status response.
Every new handoff or restart prompt must include this watcher requirement by default.

View File

@@ -10,7 +10,9 @@ Tu n'es pas en autonomie. Dom valide avant chaque étape. Tu proposes, il décid
## Priorité absolue
**La démo Urgence_aiva_demo doit fonctionner.** Workflow 22+ steps sur Easily Assure, patiente MOREL Catherine, audience mixte DG/DSI/médecins/DIM/TIM. Tout arbitrage technique se tranche par : "est-ce que ça rapproche ou éloigne de la démo qui tourne ?"
**Le POC clinique Wallerstein doit tourner.** 5 postes Léa live ; les TIM travaillent sur leurs **vrais logiciels métier en mode web** (navigateur intégré au logiciel / navigateur du PC, instances **RDP** et **Citrix**), sur **2 écrans** → capture de la **fenêtre active**. Objectif produit : Léa **apprend** ces parcours et les **rejoue intelligemment** (pas du record-and-replay). Tout arbitrage technique se tranche par : « est-ce que ça rapproche ou éloigne du POC clinique qui tourne ? »
> Historique : `Urgence_aiva_demo` (22+ steps) sur la **maquette Easily Assure** (patiente fictive MOREL Catherine) était le banc de démo/test — **maquette abandonnée comme cible** (recadrage Dom 2026-06-25). Ne plus raisonner « Easily ».
## Méthode obligatoire — non négociable

View File

@@ -27,7 +27,7 @@ if platform.system() == "Windows":
except Exception:
pass
AGENT_VERSION = "1.0.0"
AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.2")
# Identifiant unique de la machine (utilisé pour le multi-machine)
# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS
@@ -82,6 +82,38 @@ BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true"
# Configurable via variable d'environnement pour permettre l'ajustement
LOG_RETENTION_DAYS = int(os.environ.get("RPA_LOG_RETENTION_DAYS", "180"))
# Remontée automatique des logs vers le serveur (push-log-DGX).
# Diagnostic des postes clinique SANS AnyDesk : les logs (déjà écrits sur disque)
# sont poussés au serveur, rangés par machine_id, consultables au dashboard.
# Défaut PRUDENT = désactivé : on l'active poste par poste via config.txt /
# variable d'environnement, sans rebuild de l'installateur.
LOG_SHIP_ENABLED = os.environ.get("RPA_LOG_SHIP_ENABLED", "false").lower() in (
"true", "1", "yes",
)
# Intervalle de flush du buffer de logs (secondes).
LOG_SHIP_INTERVAL_S = float(os.environ.get("RPA_LOG_SHIP_INTERVAL_S", "30"))
# Mise à jour silencieuse du client Léa (DETTE-022 v2).
# Le client interroge le serveur (GET /api/v1/agents/update/check), télécharge
# le ZIP en staging et vérifie le SHA256. Le SWAP réel des fichiers / l'édition
# de Lea.bat / le redémarrage restent RÉSERVÉS RÉVISION HUMAINE (voir
# network/updater.py : stubs apply_update / write_boot_ok_marker).
# Défaut PRUDENT = désactivé : activé poste par poste via config.txt / variable
# d'environnement, sans rebuild de l'installateur (même esprit que LOG_SHIP).
AUTO_UPDATE_ENABLED = os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
"true", "1", "yes", "on",
)
# Intervalle entre deux interrogations serveur pour une MAJ (secondes).
# Défaut 1 h : une MAJ n'est jamais urgente ; on interroge peu pour ne pas
# charger le réseau clinique. Le check ne fait de toute façon aucun swap.
AUTO_UPDATE_INTERVAL_S = float(os.environ.get("RPA_AUTO_UPDATE_INTERVAL_S", "3600"))
# Dossier de STAGING des ZIP d'update (jamais les fichiers vivants). Équivalent
# de `Lea_next\\`. Sous LOCALAPPDATA en prod Windows, sinon à côté de l'agent.
AUTO_UPDATE_STAGING_DIR = os.environ.get(
"RPA_AUTO_UPDATE_STAGING_DIR",
str(BASE_DIR / "_update_staging"),
)
# Monitoring
PERF_MONITOR_INTERVAL_S = 30
LOGS_DIR = BASE_DIR / "logs"

View File

@@ -32,6 +32,7 @@ from pynput.keyboard import Key, KeyCode
# Importation relative pour rester dans le module v1
from ..vision.capturer import VisionCapturer
from ..vision.system_info import get_screen_metadata
from .log_safe import _sanitize_metadata
# from ..monitoring.system import SystemMonitor
logger = logging.getLogger(__name__)
@@ -676,7 +677,7 @@ class EventCaptorV1:
metadata = get_screen_metadata()
with self._screen_metadata_lock:
self._screen_metadata = metadata
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
logger.debug(f"Métadonnées système rafraîchies : {_sanitize_metadata(metadata)}")
except Exception as e:
logger.error(f"Erreur refresh métadonnées système : {e}")

View File

@@ -26,6 +26,7 @@ from typing import Any, Dict, Optional
# DPI awareness est configure (SetProcessDpiAwareness(2) sur Windows).
# Sans cela, pynput et mss utilisent des coordonnees logiques (virtualisees).
from ..config import MACHINE_ID as _ # noqa: F401 — side-effect import
from .log_safe import _title_hash
import mss
from pynput.mouse import Button, Controller as MouseController
@@ -862,7 +863,7 @@ class ActionExecutorV1:
)
if handled:
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
f"fenetre -> bouton '{button_text}' "
f"[{resolved.get('method', 'server')}]"
)
@@ -890,7 +891,7 @@ class ActionExecutorV1:
)
if handled:
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
f"fenetre -> bouton '{button_text}' [dialog_window_text_template]"
)
return handled
@@ -917,7 +918,7 @@ class ActionExecutorV1:
)
if handled:
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere par geometrie "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere par geometrie "
f"fenetre -> bouton '{button_text}'"
)
return handled
@@ -967,7 +968,7 @@ class ActionExecutorV1:
if not handled:
continue
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
f"-> bouton '{button_text}' [{resolved.get('method', 'server')}]"
)
return handled
@@ -992,13 +993,13 @@ class ActionExecutorV1:
if not handled:
continue
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
f"-> bouton '{button_text}' [dialog_text_template]"
)
return handled
logger.info(
f"[RUNTIME-DIALOG] Aucun bouton resolu pour '{current_title}'"
f"[RUNTIME-DIALOG] Aucun bouton resolu pour [title_hash={_title_hash(current_title)}]"
)
return None
@@ -1258,7 +1259,7 @@ class ActionExecutorV1:
if dialog_spec.get("skip_current_action_after_handle", False):
logger.info(
f"[RUNTIME-DIALOG] Dialogue '{current_title}' gere -> "
f"[RUNTIME-DIALOG] Dialogue [title_hash={_title_hash(current_title)}] gere -> "
f"action {action.get('action_id', 'unknown')} skippée"
)
return {
@@ -1587,7 +1588,7 @@ class ActionExecutorV1:
]
for pattern in popup_patterns:
if pattern in current_title:
logger.info(f"Observer : popup détectée par titre — '{current_title}'")
logger.info(f"Observer : popup détectée par titre — [title_hash={_title_hash(current_title)}]")
# On ne peut pas résoudre les coords juste par le titre
# → retourner popup sans coords, le caller fera handle_popup_vlm()
return {
@@ -1874,8 +1875,8 @@ class ActionExecutorV1:
)
else:
logger.warning(
f"[LEA] Fenêtre incorrecte : attendu '{expected_title}', "
f"actuel '{current_title}'"
f"[LEA] Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
f"actuel [title_hash={_title_hash(current_title)}]"
)
auto_result = self._maybe_handle_runtime_dialog_before_pause(
action=action,
@@ -1888,8 +1889,8 @@ class ActionExecutorV1:
if auto_result is not None:
return auto_result
print(
f" [PRÉ-VÉRIF] Fenêtre '{current_title}'"
f"attendu '{expected_title}' → mode apprentissage"
f" [PRÉ-VÉRIF] Fenêtre [title_hash={_title_hash(current_title)}]"
f"attendu [title_hash={_title_hash(expected_title)}] → mode apprentissage"
)
try:
self.notifier.replay_learning_mode(
@@ -1936,8 +1937,8 @@ class ActionExecutorV1:
# des coordonnées devenues invalides.
result["success"] = False
result["error"] = (
f"Fenêtre incorrecte : attendu '{expected_title}', "
f"actuel '{current_title}'"
f"Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
f"actuel [title_hash={_title_hash(current_title)}]"
)
result["warning"] = "wrong_window"
result["target_description"] = expected_title
@@ -1945,11 +1946,11 @@ class ActionExecutorV1:
result["screenshot"] = self._capture_screenshot_b64()
logger.warning(
f"[LEA] Wrong window sans correction → pause "
f"(attendu '{expected_title}', actuel '{current_title}')"
f"(attendu [title_hash={_title_hash(expected_title)}], actuel [title_hash={_title_hash(current_title)}])"
)
return result
else:
logger.info(f"[LEA] Pré-vérif OK : '{current_title}'")
logger.info(f"[LEA] Pré-vérif OK : [title_hash={_title_hash(current_title)}]")
# ── OBSERVER : pré-analyse écran avant résolution ──
# Détecte popups, dialogues, états inattendus AVANT de chercher la cible.
@@ -1964,8 +1965,8 @@ class ActionExecutorV1:
# Popup détectée AVANT la résolution — la fermer
popup_label = observation.get("popup_label", "popup")
popup_coords = observation.get("popup_coords")
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
print(f" [OBSERVER] Popup détectée : [title_hash={_title_hash(popup_label)}] — fermeture")
logger.info(f"Observer : popup [title_hash={_title_hash(popup_label)}] détectée avant résolution")
# ── SÉCURITÉ : refuser de cliquer sur un dialogue système ──
# Avant de suivre les coordonnées du serveur (VLM-based,
@@ -2365,8 +2366,8 @@ class ActionExecutorV1:
recheck_title = recheck_info.get("title", "")
if not _matches_expected_window(recheck_title):
logger.warning(
f"P0.9 transition instable : matched '{post_title}' "
f"puis '{recheck_title}' à T+0.5s ≠ '{expected_after}'"
f"P0.9 transition instable : matched [title_hash={_title_hash(post_title)}] "
f"puis [title_hash={_title_hash(recheck_title)}] à T+0.5s ≠ [title_hash={_title_hash(expected_after)}]"
)
matched = False
post_title = recheck_title
@@ -2376,19 +2377,19 @@ class ActionExecutorV1:
result["runtime_dialog"] = runtime_dialog_handled
print(
f" [POST-VÉRIF] Dialogue runtime géré "
f"→ retour '{post_title}'"
f"→ retour [title_hash={_title_hash(post_title)}]"
)
logger.info(
"POST-VÉRIF runtime dialog géré : '%s' -> '%s'",
runtime_dialog_handled.get("dialog_title", ""),
post_title,
"POST-VÉRIF runtime dialog géré : [title_hash=%s] -> [title_hash=%s]",
_title_hash(runtime_dialog_handled.get("dialog_title", "")),
_title_hash(post_title),
)
else:
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — '{post_title}'")
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : '{post_title}'")
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — [title_hash={_title_hash(post_title)}]")
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : [title_hash={_title_hash(post_title)}]")
else:
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — '{post_title}''{expected_after}'")
logger.warning(f"POST-VÉRIF TIMEOUT : '{post_title}''{expected_after}'")
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
logger.warning(f"POST-VÉRIF TIMEOUT : [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
if runtime_dialog_handled:
result["warning"] = (
f"runtime_dialog_handled_post_verify:{post_title}"
@@ -2396,9 +2397,9 @@ class ActionExecutorV1:
result["runtime_dialog"] = runtime_dialog_handled
logger.warning(
"POST-VÉRIF runtime dialog géré mais "
"fenêtre finale inattendue : '%s''%s'",
post_title,
expected_after,
"fenêtre finale inattendue : [title_hash=%s] ≠ [title_hash=%s]",
_title_hash(post_title),
_title_hash(expected_after),
)
# Contrôle strict : si success_strict, on STOP.
# On durcit aussi les vrais changements de fenêtre
@@ -2416,8 +2417,8 @@ class ActionExecutorV1:
if bool(action.get("success_strict")) or requires_transition:
result["success"] = False
result["error"] = (
f"Post-vérif échouée : fenêtre '{post_title}' "
f"au lieu de '{expected_after}'"
f"Post-vérif échouée : fenêtre [title_hash={_title_hash(post_title)}] "
f"au lieu de [title_hash={_title_hash(expected_after)}]"
)
result["warning"] = "wrong_window"
result["needs_human"] = True
@@ -2458,7 +2459,7 @@ class ActionExecutorV1:
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
self._type_text(text, paste=bool(action.get("paste", False)))
print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
logger.info(f"Replay type : [{len(text)} chars] (raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "key_combo":
keys = action.get("keys", [])
@@ -2524,12 +2525,12 @@ class ActionExecutorV1:
if not self._window_title_matches_any(current_title, patterns):
logger.warning(
"[LEA] verify_screen garde KO : attendu un titre "
"contenant %s, actuel '%s'",
patterns, current_title,
"contenant %s, actuel [title_hash=%s]",
patterns, _title_hash(current_title),
)
print(
f" [VERIFY] Garde titre KO "
f"(patterns={patterns}, actuel='{current_title}') "
f"(patterns={patterns}, actuel=[title_hash={_title_hash(current_title)}]) "
"→ apprentissage humain"
)
try:
@@ -2557,15 +2558,15 @@ class ActionExecutorV1:
result["error"] = (
f"verify_screen titre fenêtre KO : attendu "
f"un titre contenant {patterns}, "
f"actuel '{current_title}'"
f"actuel [title_hash={_title_hash(current_title)}]"
)
result["warning"] = "setup_guard_window_mismatch"
result["needs_human"] = True
result["screenshot"] = self._capture_screenshot_b64()
return result
logger.info(
"[LEA] verify_screen garde OK : '%s' matche %s",
current_title, patterns,
"[LEA] verify_screen garde OK : [title_hash=%s] matche %s",
_title_hash(current_title), patterns,
)
print(f" [VERIFY] Termine (verification deferred au serveur).")
@@ -3736,8 +3737,8 @@ Example: x_pct=0.50, y_pct=0.30"""
real_x = int(x_pct * sw)
real_y = int(y_pct * sh)
label = server_result.get("matched_element", {}).get("label", "popup")
print(f" [POPUP-SERVER] Popup détectée ! Clic sur '{label}' → ({real_x}, {real_y})")
logger.info(f"[POPUP-SERVER] Clic popup '{label}' à ({real_x}, {real_y})")
print(f" [POPUP-SERVER] Popup détectée ! Clic sur [title_hash={_title_hash(label)}] → ({real_x}, {real_y})")
logger.info(f"[POPUP-SERVER] Clic popup [title_hash={_title_hash(label)}] à ({real_x}, {real_y})")
self._click((real_x, real_y), "left")
time.sleep(1.0)
return True
@@ -3856,8 +3857,8 @@ Example: x_pct=0.50, y_pct=0.30"""
raw_content = resp.json().get("message", {}).get("content", "")
full_response = prefill + raw_content
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : {full_response.strip()}")
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : {full_response.strip()}")
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : [len={len(full_response)}, has_target={'target' in full_response}]")
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : [len={len(full_response)}, has_target={'target' in full_response}]")
# Extraire le texte du bouton depuis la réponse
button_text = raw_content.strip().strip('"').strip("'").strip(".")
@@ -4172,7 +4173,7 @@ Example: x_pct=0.50, y_pct=0.30"""
try:
self.keyboard.type(char)
except Exception as e:
logger.debug(f"Impossible de taper '{char}': {e}")
logger.debug(f"Impossible de taper [1 char typed]: {e}")
# Délai humain entre les frappes (40-120ms)
time.sleep(random.uniform(0.04, 0.12))

View File

@@ -0,0 +1,48 @@
"""Helpers de logging PII-safe pour le client Léa (agent_v1).
Convention : ne jamais logger le contenu brut d'une variable utilisateur
(texte tapé, titre de fenêtre, nom de workflow, réponse VLM, chemin fichier).
Le remplacer par :
- une longueur ou un hash court (corrélation de diagnostic sans révéler) ;
- un dict de métadonnées filtré (sans titre / fenêtre active).
À importer dans tout module d'agent_v1 qui logge une donnée potentiellement
sensible. Branche feat/push-log-dgx — DETTE-020 (assainissement à la source).
"""
from __future__ import annotations
import hashlib
import os
def _title_hash(title: str) -> str:
"""Hash SHA1 tronqué (8 hex) d'un titre.
Corrélation stable (même titre → même hash → « même popup re-détectée »)
sans exposer le contenu. `errors="replace"` pour ne jamais lever sur un
encodage exotique (titres Windows multi-langues).
"""
return hashlib.sha1((title or "").encode("utf-8", errors="replace")).hexdigest()[:8]
# Clés de métadonnées susceptibles de contenir du contenu utilisateur (PII).
_PII_METADATA_KEYS = ("title", "active_window", "window_title")
def _sanitize_metadata(metadata: dict) -> dict:
"""Copie d'un dict de métadonnées sans les clés porteuses de PII.
Garde les champs techniques (resolution, dpi, theme, langue…), retire
titre / fenêtre active. Ne mute pas le dict d'origine.
"""
return {k: v for k, v in metadata.items() if k not in _PII_METADATA_KEYS}
def _path_ext(path: str) -> str:
"""Extension seule d'un chemin (ex. « .png »), sans nom ni dossier.
Un chemin peut nommer un patient ; l'extension suffit au diagnostic.
Chaîne vide si pas de chemin ou pas d'extension.
"""
return os.path.splitext(path)[1] if path else ""

View File

@@ -24,6 +24,8 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Optional
from .log_safe import _title_hash
logger = logging.getLogger(__name__)
@@ -168,8 +170,8 @@ class RecoveryEngine:
from ..window_info_crossplatform import get_active_window_info
active = get_active_window_info()
active_title = active.get("title", "")
logger.info(f"Recovery : Alt+F4 sur '{active_title}'")
print(f" [RECOVERY] Alt+F4 — fermeture de '{active_title}'")
logger.info(f"Recovery : Alt+F4 sur [title_hash={_title_hash(active_title)}]")
print(f" [RECOVERY] Alt+F4 — fermeture de [title_hash={_title_hash(active_title)}]")
except Exception:
logger.info("Recovery : Alt+F4 (fenêtre active inconnue)")
print(" [RECOVERY] Alt+F4 — fermeture fenêtre indésirable")
@@ -182,7 +184,7 @@ class RecoveryEngine:
return RecoveryResult(
action_taken=RecoveryAction.CLOSE_WINDOW,
success=True,
detail=f"Alt+F4 exécuté sur '{active_title if 'active_title' in dir() else '?'}'",
detail=f"Alt+F4 exécuté sur [title_hash={_title_hash(active_title) if 'active_title' in dir() else '?'}]",
)
elif strategy == RecoveryAction.CLICK_AWAY:

View File

@@ -0,0 +1,56 @@
"""Journalisation client Léa — DETTE-021.
Branche un handler **fichier** (`TimedRotatingFileHandler`) sur le logger racine,
en plus de la console. Sans cela, sous `pythonw.exe` (pas de console), les logs
partent sur stderr et sont **perdus** — diagnostic terrain impossible.
Rotation quotidienne + rétention `retention_days` (Règlement IA Art. 12 :
journalisation automatique + conservation minimum 180 j).
"""
import logging
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
_FMT = "%(asctime)s %(levelname)-7s %(name)-25s %(message)s"
def setup_logging(log_file, level=logging.INFO, retention_days=180):
"""Configure le logging racine : fichier (rotation quotidienne, `retention_days`
fichiers conservés) + console. **Idempotent** : ne réempile pas nos handlers.
Args:
log_file: chemin du fichier de log (`config.LOG_FILE` en prod).
level: niveau racine (INFO par défaut ; DEBUG géré par l'appelant).
retention_days: nb de fichiers quotidiens conservés (180 = Règlement IA Art. 12).
Returns:
Le `TimedRotatingFileHandler` créé.
"""
log_file = Path(log_file)
log_file.parent.mkdir(parents=True, exist_ok=True)
root = logging.getLogger()
root.setLevel(level)
# Idempotence : retirer nos propres handlers posés par un appel précédent.
for h in list(root.handlers):
if getattr(h, "_lea_managed", False):
h.close()
root.removeHandler(h)
file_handler = TimedRotatingFileHandler(
str(log_file), when="midnight", backupCount=retention_days, encoding="utf-8"
)
file_handler.setFormatter(logging.Formatter(_FMT, datefmt="%Y-%m-%d %H:%M:%S"))
file_handler.setLevel(level)
file_handler._lea_managed = True
root.addHandler(file_handler)
# Console conservée (utile en dev / si lancé avec une console).
console = logging.StreamHandler()
console.setFormatter(logging.Formatter(_FMT, datefmt="%H:%M:%S"))
console.setLevel(level)
console._lea_managed = True
root.addHandler(console)
return file_handler

View File

@@ -15,9 +15,10 @@ import time
import logging
import threading
from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT,
STREAMING_ENDPOINT, LOG_SHIP_ENABLED, LOG_SHIP_INTERVAL_S,
AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_S, AUTO_UPDATE_STAGING_DIR,
)
from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1
@@ -29,6 +30,7 @@ from .ui.capture_server import CaptureServer
from .session.storage import SessionStorage
from .vision.capturer import VisionCapturer
from .finalize_contract import dispatch_finalize_result
from .core.log_safe import _title_hash
# Import optionnel du client serveur (pour le chat et les workflows)
# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1)
@@ -43,16 +45,44 @@ except (ImportError, ValueError):
# Configuration du logging — format structuré et lisible pour un TIM
# Niveau de détail : INFO par défaut, DEBUG si RPA_AGENT_DEBUG=1
_log_level = logging.DEBUG if os.environ.get("RPA_AGENT_DEBUG") == "1" else logging.INFO
logging.basicConfig(
level=_log_level,
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
datefmt="%H:%M:%S",
)
# DETTE-021 : journaliser dans un FICHIER (rotation quotidienne + rétention 180 j,
# Règlement IA Art. 12). Sous `pythonw.exe` (sans console), un basicConfig→stderr
# serait perdu. Fallback console si le fichier est indisponible — ne JAMAIS
# empêcher Léa de démarrer pour un problème de log.
try:
from .logging_setup import setup_logging
setup_logging(LOG_FILE, level=_log_level, retention_days=LOG_RETENTION_DAYS)
except Exception:
logging.basicConfig(
level=_log_level,
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
datefmt="%H:%M:%S",
)
# Réduire le bruit de certaines libs
for _noisy in ("urllib3", "requests.packages.urllib3", "PIL", "mss"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
# push-log-DGX : remontée automatique des logs vers le serveur (diagnostic des
# postes SANS AnyDesk). GARDÉ derrière RPA_LOG_SHIP_ENABLED (défaut désactivé) —
# activable poste par poste via config.txt, sans rebuild. Le handler est attaché
# au logger racine APRÈS setup_logging (les logs partent aussi dans le fichier).
_log_shipper = None
if LOG_SHIP_ENABLED:
try:
from .network.log_shipper import LogShipper
_log_shipper = LogShipper(
machine_id=MACHINE_ID,
max_batch=int(os.environ.get("RPA_AGENT_LOGS_MAX_BATCH", "1000")),
flush_interval_s=LOG_SHIP_INTERVAL_S,
)
logging.getLogger().addHandler(_log_shipper.handler)
_log_shipper.start()
except Exception as _e:
# Ne JAMAIS empêcher Léa de démarrer pour un problème de remontée de logs.
logging.getLogger(__name__).warning("Log shipper non démarré : %s", _e)
_log_shipper = None
logger = logging.getLogger(__name__)
# Intervalle de polling replay (secondes)
@@ -129,6 +159,31 @@ class AgentV1:
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
# DETTE-022 v2 : MAJ silencieuse — boucle de check GATED (défaut OFF).
# Interroge le serveur (canary-aware) et télécharge en STAGING ; le swap
# réel reste réservé révision humaine (updater.apply_update = stub no-op).
# Activable poste par poste via RPA_AUTO_UPDATE_ENABLED, sans rebuild.
if AUTO_UPDATE_ENABLED:
threading.Thread(
target=self._auto_update_loop, daemon=True, name="lea-auto-update"
).start()
# MAJ silencieuse — confirmation de boot post-swap. Si Lea.bat vient
# d'appliquer une MAJ (marqueur PENDING_BOOT), on désarme le rollback
# après ~90 s de tourne STABLE (liveness LOCALE, indépendante du DGX).
# Un quit propre avant 90 s confirme aussi (cf. main()). Seul un vrai
# crash laisse PENDING_BOOT → rollback au prochain lancement.
if _pending_boot_marker_exists():
def _boot_confirm():
import os as _os
import time as _time
_time.sleep(float(_os.environ.get("RPA_BOOT_CONFIRM_DELAY_S", "90")))
if self.running:
_confirm_boot_ok()
threading.Thread(
target=_boot_confirm, daemon=True, name="lea-boot-confirm"
).start()
# Mini-serveur HTTP pour captures a la demande (port 5006)
self._capture_server = CaptureServer()
self._capture_server.start()
@@ -253,7 +308,7 @@ class AgentV1:
# Ne PAS en relancer une ici — deux threads poll simultanés causent
# une race condition où les actions sont consommées mais pas exécutées.
logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...")
logger.info(f"Session {self.session_id} [wf_hash={_title_hash(workflow_name)}] sur machine {self.machine_id} en cours...")
def _command_watchdog_loop(self):
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""
@@ -412,6 +467,67 @@ class AgentV1:
logger.debug(f"[HEARTBEAT] Erreur: {e}")
time.sleep(5)
def _auto_update_loop(self):
"""DETTE-022 v2 — boucle de MAJ silencieuse GATED (défaut OFF).
Interroge périodiquement le serveur (endpoint canary-aware), et si une
MAJ est proposée pour CE poste, la télécharge dans le STAGING après
vérif SHA256. Le swap réel N'EST PAS fait ici : `updater.run_update_cycle`
s'arrête au staging (apply_update = stub réservé révision humaine + swap
hors-process par Lea.bat au prochain démarrage).
SÉCURITÉ — « au bon moment » : on NE stage PAS pendant un enregistrement
ou un replay actif (self.session_id / self._replay_active), pour ne pas
perturber le travail utilisateur ni consommer du réseau au mauvais
moment. Best-effort : aucune exception ne remonte (ne casse jamais Léa).
"""
try:
from .network.updater import run_update_cycle
except Exception as e:
logger.warning("[UPDATE] Module updater indisponible : %s", e)
return
logger.info(
"[UPDATE] Boucle MAJ silencieuse démarrée (intervalle=%.0fs, "
"version=%s) — check seul, swap réservé révision humaine",
AUTO_UPDATE_INTERVAL_S, AGENT_VERSION,
)
while self.running:
# Découpe l'attente pour réagir vite à l'arrêt.
waited = 0.0
step = 1.0
while self.running and waited < AUTO_UPDATE_INTERVAL_S:
time.sleep(step)
waited += step
if not self.running:
break
# « Au bon moment » : jamais en plein travail (enregistrement/replay).
if self.session_id or getattr(self, "_replay_active", False):
logger.debug("[UPDATE] Report du check (session/replay active)")
continue
try:
result = run_update_cycle(
local_version=AGENT_VERSION,
machine_id=self.machine_id,
staging_dir=AUTO_UPDATE_STAGING_DIR,
)
status = result.get("status")
if status == "staged":
logger.info(
"[UPDATE] MAJ %s téléchargée en staging (SHA256=%s) — "
"swap réservé révision humaine, non appliqué",
result.get("target_version"),
result.get("sha256_verified"),
)
elif status not in ("up_to_date", "disabled"):
logger.debug("[UPDATE] Cycle: %s", result)
except Exception as e:
# run_update_cycle est déjà best-effort ; double filet ici.
logger.debug("[UPDATE] Erreur boucle MAJ : %s", e)
def stop_session(self):
# Sauvegarder le session_id avant de l'annuler (pour les logs)
ended_session_id = self.session_id
@@ -578,29 +694,20 @@ class AgentV1:
def run(self):
self.ui.run()
def _headless_keepalive(agent):
"""Maintient le main thread vivant quand l'UI tray ne peut pas tourner.
def _install_signal_handlers(agent, watchdog) -> None:
"""Installe SIGTERM/SIGINT/SIGBREAK pour un arrêt propre du main thread.
Sans cela, ``agent.run()`` retourne immédiatement (pystray échoue quand
Léa est lancée via SSH sans session interactive Windows), le main thread
se termine, et TOUS les daemon threads — y compris ``_replay_poll_loop``
— meurent avec lui. Observé 3 fois en 24h les 24/05 :
- SSH ``Permission denied`` (1231)
- polls morts après relance distante (1620)
- polls morts ``replay_sess_506d6fa2`` (1627)
Le keepalive ne se déclenche QUE si ``agent.run()`` est sorti tout en
laissant ``agent.running=True`` (cas anormal). En mode interactif
normal, ``pystray.Icon.run()`` ne sort jamais, donc ce code est
invisible.
Met ``agent.running=False`` (les daemon threads s'arrêtent) et réveille
le watchdog (qui sort de sa boucle de surveillance). Sans session
interactive (pystray.Icon.stop indisponible), c'est le SEUL moyen
d'arrêter Léa proprement : ``kill -TERM <pid>`` ou Ctrl+C.
"""
import signal as _sig
_stop = threading.Event()
def _handler(sig, frame):
logger.info(f"[MAIN] Signal {sig} recu — arret propre")
_stop.set()
agent.running = False
watchdog.stop()
for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"):
sig_obj = getattr(_sig, sig_name, None)
@@ -611,33 +718,78 @@ def _headless_keepalive(agent):
except (ValueError, OSError):
pass
logger.info(
"[MAIN] Keepalive headless actif — main thread bloque pour maintenir "
"les daemon threads (_replay_poll_loop, heartbeat, capture_server) vivants. "
"Pour stopper Lea : kill -TERM <pid> ou Ctrl+C."
)
def _agent_should_live(agent) -> bool:
"""Vrai tant que Léa doit vivre : agent actif ET pas de Quitter explicite.
Un « Quitter » utilisateur (``ui._quit_requested``) doit stopper le
watchdog pour de bon ; une simple déconnexion RDP ne met JAMAIS ce flag
→ le tray revient tout seul à la reconnexion.
"""
if not getattr(agent, "running", False):
return False
ui = getattr(agent, "ui", None)
if ui is not None and getattr(ui, "_quit_requested", False):
return False
return True
def _pending_boot_marker_exists() -> bool:
"""True si Lea.bat a posé PENDING_BOOT (boot post-MAJ à valider)."""
try:
_stop.wait()
except KeyboardInterrupt:
pass
agent.running = False
logger.info("[MAIN] Keepalive termine — agent.running=False, daemon threads vont s'arreter")
from .network.updater import _resolve_app_dir
return (_resolve_app_dir(None) / "PENDING_BOOT").exists()
except Exception:
return False
def _confirm_boot_ok() -> None:
"""Confirme un boot post-MAJ : écrit boot_ok + retire PENDING_BOOT.
Désarme le rollback de Lea.bat. No-op si pas de PENDING_BOOT (boot normal).
Best-effort — ne doit jamais casser l'arrêt/la vie de Léa.
"""
try:
if not _pending_boot_marker_exists():
return
from .network import updater
updater.write_boot_ok_marker(AGENT_VERSION)
logger.info("[MAJ] Boot confirmé (v%s) — rollback désarmé", AGENT_VERSION)
except Exception as e: # noqa: BLE001
logger.debug("confirm_boot_ok: %s", e)
def main():
agent = AgentV1()
try:
agent.run()
except Exception:
logger.exception("[MAIN] agent.run() a leve une exception")
from .ui.session_watchdog import InteractiveSessionWatchdog
if getattr(agent, "running", False):
logger.warning(
"[MAIN] agent.run() est sorti mais agent.running=True — "
"probablement pystray sans session interactive (SSH). "
"Bascule en keepalive headless."
)
_headless_keepalive(agent)
agent = AgentV1()
# Résilience RDP/Citrix : au lieu de bloquer le main thread pour toujours
# quand pystray sort (session interactive perdue), on surveille la
# session et on ré-affiche le tray + le chat à chaque reconnexion.
# agent.run() (== agent.ui.run()) est ré-entrant : les threads de fond
# ne démarrent qu'une fois, seule l'icône est recréée. Les daemon threads
# de capture/heartbeat/replay tournent contre agent.running et restent
# uniques — le watchdog n'y touche pas.
watchdog = InteractiveSessionWatchdog(
run_ui=agent.run,
is_running=lambda: _agent_should_live(agent),
)
_install_signal_handlers(agent, watchdog)
try:
watchdog.run()
# Sortie normale du watchdog = quit propre (tray / session) → le boot
# était sain : on confirme (couvre un quit AVANT les 90 s, évite un faux
# rollback). No-op si ce n'est pas un boot post-MAJ.
_confirm_boot_ok()
except KeyboardInterrupt:
logger.info("[MAIN] Interruption clavier — arret propre")
except Exception:
logger.exception("[MAIN] Le watchdog de session a leve une exception")
finally:
agent.running = False
logger.info("[MAIN] Sortie — agent.running=False, daemon threads vont s'arreter")
if __name__ == "__main__":

View File

@@ -0,0 +1,317 @@
# agent_v1/network/log_shipper.py
"""Remontée AUTOMATIQUE des logs du client Léa vers le serveur (push-log-DGX).
But : diagnostiquer les postes Windows clinique SANS AnyDesk. Les logs déjà
écrits sur disque par `logging_setup.py` (rotation quotidienne, rétention 180 j,
Règlement IA Art. 12) sont en plus poussés au serveur, rangés par `machine_id`,
consultables au dashboard.
Serveur (déjà prêt — NE PAS toucher) :
POST /api/v1/agents/logs
body = {machine_id: str, logs: [{ts, level, logger, message}]}
borne RPA_AGENT_LOGS_MAX_BATCH (défaut 1000) — 413 si dépassée.
Conception :
- `LogShipperHandler(logging.Handler)` : sur `emit(record)`, formate au
schéma EXACT `{ts, level, logger, message}`, applique un assainissement
PII au message (défense en profondeur — la discipline `log_safe` à la
source logue déjà des hashes/longueurs, pas du contenu brut), puis
empile dans un buffer borné.
- `LogShipper` : flush par BATCH (≤ max_batch) via un `sender` callable
INJECTABLE `(machine_id, logs) -> bool`. Défaut = POST réel Bearer
(pattern `streamer.py`).
- Résilience (ZÉRO perte) : si `sender` renvoie False ou lève, les logs
RESTENT dans le buffer et sont rejoués au flush suivant. Le fichier de
log local reste de toute façon la source durable (survit au crash) ; le
buffer RAM est un best-effort de remontée, volontairement NON persisté en
SQLite (le `PersistentBuffer` est session/event-scoped — y mêler des logs
polluerait la DB d'events). Borne mémoire = `max_buffer` (drop des plus
VIEUX au-delà — un log récent vaut mieux qu'un vieux pour le diagnostic).
Pattern d'import PII : on tente `anonymize_text` (server_v1.pii_sanitizer,
source de vérité des tokens typés) via le même import paresseux tolérant que
`ui/messages.py`. Sur un vrai poste (sans server_v1), on retombe sur l'identité :
acceptable car la PII de message est déjà neutralisée à la source par la
discipline `log_safe`. Le sanitizer reste INJECTABLE pour les tests/évolutions.
Branche feat/push-log-dgx.
"""
from __future__ import annotations
import logging
import threading
import time
from collections import deque
from typing import Callable, Deque, Dict, List, Optional
logger = logging.getLogger(__name__)
# Schéma d'une entrée de log poussée au serveur.
# ts : epoch (float) — l'heure de l'évènement
# level : nom du niveau ("INFO", "WARNING"...)
# logger : nom du logger (record.name)
# message : message formaté (args interpolés) ET assaini PII
# Défaut aligné sur la borne serveur RPA_AGENT_LOGS_MAX_BATCH (api_stream.py).
DEFAULT_MAX_BATCH = 1000
# Borne mémoire du buffer : au-delà, on droppe les plus VIEUX (diagnostic =
# on préfère les logs récents). Quelques milliers d'entrées = quelques Mo RAM.
DEFAULT_MAX_BUFFER = 5000
# ---------------------------------------------------------------------------
# Assainissement PII du message (défense en profondeur)
# ---------------------------------------------------------------------------
def _default_message_sanitizer(text: str) -> str:
"""Sanitizer par défaut côté client = identité.
Le **rempart PII des logs est le SERVEUR** : `sanitize_log_entries`
ré-assainit chaque message à la réception (`/api/v1/agents/logs`), via le
même `anonymize_text` que les events. Tenter un import de `server_v1` côté
poste à CHAQUE ligne de log est inutile (absent du bundle client) et coûteux
(exception attrapée par emit). La discipline `log_safe` neutralise déjà la
PII à la source. Reste INJECTABLE pour tests/évolutions.
"""
return text
# ---------------------------------------------------------------------------
# Handler — empile les LogRecords dans un buffer partagé
# ---------------------------------------------------------------------------
class LogShipperHandler(logging.Handler):
"""Handler logging qui sérialise chaque record et l'empile pour envoi.
Ne fait AUCUN réseau : il alimente seulement le buffer du `LogShipper`.
L'envoi est piloté par `LogShipper.flush()` (thread dédié périodique).
"""
def __init__(
self,
buffer: Deque[Dict],
lock: threading.Lock,
message_sanitizer: Callable[[str], str],
max_buffer: int = DEFAULT_MAX_BUFFER,
level=logging.NOTSET,
):
super().__init__(level=level)
self._buffer = buffer
self._lock = lock
self._sanitize = message_sanitizer
self._max_buffer = max_buffer
def _format_record(self, record: logging.LogRecord) -> Dict:
"""Construit l'entrée au schéma EXACT {ts, level, logger, message}.
`record.getMessage()` interpole les args (%s...). Le message est ensuite
passé au sanitizer PII. Tolérant : un message non formatable ne doit pas
faire perdre l'entrée.
"""
try:
message = record.getMessage()
except Exception:
message = str(record.msg)
try:
message = self._sanitize(message)
except Exception:
# Le sanitizer ne doit jamais casser le logging.
pass
return {
"ts": record.created,
"level": record.levelname,
"logger": record.name,
"message": message,
}
def emit(self, record: logging.LogRecord) -> None:
"""Sérialise et empile le record (best-effort, ne lève jamais)."""
try:
entry = self._format_record(record)
with self._lock:
# deque(maxlen) droppe automatiquement le plus VIEUX au-delà
# de la borne — pas de croissance mémoire non bornée.
self._buffer.append(entry)
except Exception:
# handleError respecte logging.raiseExceptions (silencieux en prod).
self.handleError(record)
# ---------------------------------------------------------------------------
# Shipper — flush périodique par batch via un sender injectable
# ---------------------------------------------------------------------------
class LogShipper:
"""Orchestre la remontée des logs : buffer + flush par batch.
Args:
machine_id : identifiant du poste (config.MACHINE_ID en prod).
sender : callable INJECTABLE `(machine_id, logs) -> bool`. True =
accusé de réception serveur. Défaut = POST réel Bearer.
max_batch : taille max d'un batch (≤ borne serveur). Défaut 1000.
max_buffer : borne mémoire du buffer (drop des plus vieux au-delà).
message_sanitizer : assainissement PII du message. Défaut = pii_sanitizer
si disponible, sinon identité.
"""
def __init__(
self,
machine_id: str,
sender: Optional[Callable[[str, List[Dict]], bool]] = None,
max_batch: int = DEFAULT_MAX_BATCH,
max_buffer: int = DEFAULT_MAX_BUFFER,
message_sanitizer: Optional[Callable[[str], str]] = None,
flush_interval_s: float = 30.0,
):
self.machine_id = machine_id
self.max_batch = max(1, int(max_batch))
self.flush_interval_s = flush_interval_s
self._sender = sender if sender is not None else self._default_sender
self._sanitize = message_sanitizer or _default_message_sanitizer
self._lock = threading.Lock()
self._buffer: Deque[Dict] = deque(maxlen=max_buffer)
self.handler = LogShipperHandler(
buffer=self._buffer,
lock=self._lock,
message_sanitizer=self._sanitize,
max_buffer=max_buffer,
)
self._running = False
self._thread: Optional[threading.Thread] = None
# ------------------------------------------------------------------
# Introspection (diagnostic / tests)
# ------------------------------------------------------------------
def peek_buffer(self) -> List[Dict]:
"""Copie des entrées en attente (lecture seule, pour diagnostic/tests)."""
with self._lock:
return list(self._buffer)
def pending(self) -> int:
with self._lock:
return len(self._buffer)
# ------------------------------------------------------------------
# Flush — envoie le buffer par batches ≤ max_batch
# ------------------------------------------------------------------
def flush(self) -> int:
"""Envoie le buffer par batches successifs. Retourne le nb de logs ACK.
Résilience ZÉRO perte : on retire un batch du buffer, on tente l'envoi.
- Succès → les entrées sont définitivement consommées.
- Échec (False ou exception) → on REMET les entrées en tête du buffer
et on ARRÊTE la passe (serveur probablement down) ; rejeu au flush
suivant. Les entrées non encore extraites restent en place.
"""
sent = 0
while True:
with self._lock:
if not self._buffer:
break
batch: List[Dict] = []
for _ in range(min(self.max_batch, len(self._buffer))):
batch.append(self._buffer.popleft())
try:
ok = self._sender(self.machine_id, batch)
except Exception as e:
ok = False
logger.debug("Log shipper sender a levé : %s", e)
if ok:
sent += len(batch)
continue
# Échec : on remet le batch en tête (ordre préservé) et on arrête.
with self._lock:
self._buffer.extendleft(reversed(batch))
break
return sent
# ------------------------------------------------------------------
# Sender réel — POST Bearer (pattern streamer.py)
# ------------------------------------------------------------------
@staticmethod
def _auth_headers() -> dict:
"""Headers Bearer (pattern streamer.py)."""
try:
from ..config import API_TOKEN
except Exception:
API_TOKEN = ""
if API_TOKEN:
return {"Authorization": f"Bearer {API_TOKEN}"}
return {}
def _default_sender(self, machine_id: str, logs: List[Dict]) -> bool:
"""POST réel vers /api/v1/agents/logs. True si HTTP 2xx.
Best-effort : tout échec réseau/serveur → False (logs conservés,
rejoués). Aucune exception ne remonte au-delà du sender.
"""
try:
import requests
from ..config import SERVER_URL
url = f"{SERVER_URL}/agents/logs"
resp = requests.post(
url,
json={"machine_id": machine_id, "logs": logs},
headers=self._auth_headers(),
timeout=5,
allow_redirects=False,
)
return bool(resp.ok)
except Exception as e:
logger.debug("Log shipper POST échoué : %s", e)
return False
# ------------------------------------------------------------------
# Boucle de flush périodique (thread daemon)
# ------------------------------------------------------------------
def start(self) -> None:
"""Démarre le thread de flush périodique (idempotent)."""
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._flush_loop, daemon=True, name="lea-log-shipper"
)
self._thread.start()
logger.info(
"Log shipper démarré (machine_id=%s, intervalle=%.0fs, batch≤%d)",
self.machine_id, self.flush_interval_s, self.max_batch,
)
def stop(self, final_flush: bool = True) -> None:
"""Arrête la boucle et tente un dernier flush (best-effort)."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
if final_flush:
try:
self.flush()
except Exception:
pass
def _flush_loop(self) -> None:
while self._running:
# Découpe l'attente pour réagir vite à stop().
waited = 0.0
step = 0.5
while self._running and waited < self.flush_interval_s:
time.sleep(step)
waited += step
if not self._running:
break
try:
self.flush()
except Exception as e:
logger.debug("Log shipper flush loop : %s", e)

View File

@@ -36,6 +36,7 @@ import requests
from PIL import Image
from ..config import API_TOKEN, BASE_DIR, STREAMING_ENDPOINT
from ..core.log_safe import _title_hash
from .persistent_buffer import MAX_ATTEMPTS, PersistentBuffer
@@ -138,7 +139,7 @@ class TraceStreamer:
target=self._buffer_drain_loop, daemon=True
)
self._drain_thread.start()
logger.info(f"Streamer pour {self.session_id} démarré")
logger.info(f"Streamer démarré")
def stop(self):
"""Arrêter le streaming et finaliser la session côté serveur.
@@ -166,7 +167,7 @@ class TraceStreamer:
self._drain_thread.join(timeout=2.0)
self._finalize_session()
logger.info(f"Streamer pour {self.session_id} arrêté")
logger.info(f"Streamer arrêté")
def push_event(self, event_data: dict):
"""Enfile un événement pour envoi immédiat.
@@ -632,7 +633,7 @@ class TraceStreamer:
self._check_redirect(resp, url)
if resp.ok:
result = resp.json()
logger.info(f"Session finalisée: {result}")
logger.info(f"Session finalisée [status={result.get('status')}, wf_hash={_title_hash(result.get('workflow_name',''))}]")
if self._on_finalize_result is not None:
try:
self._on_finalize_result(result)

View File

@@ -0,0 +1,481 @@
# agent_v1/network/updater.py
"""NOYAU client de la mise à jour silencieuse de Léa (DETTE-022 v2).
GATED — flag `RPA_AUTO_UPDATE_ENABLED` (défaut OFF). Tant qu'il est OFF,
rien ne se déclenche : l'intégration de ce module au runtime (boucle de poll
de `main.py`) ne fait aucune MAJ.
Ce module ne contient que les parties PURES / testables, sans réseau réel :
- `parse_version` / `is_newer` (R3) : self-contained (le bundle client
n'embarque PAS `server_v1` — duplication assumée, même algorithme).
- `should_update(local_version, server_response)` : décide « faut-il
updater ? quelle version/type ? » à partir de la réponse serveur. Double
garde semver côté client (jamais de downgrade) = défense en profondeur.
- `download_update(plan, staging_dir, downloader)` : télécharge le ZIP via un
`downloader` callable INJECTABLE (aucun réseau réel en test), vérifie le
SHA256, écrit le ZIP dans le **staging** (`Lea_next\\`-like) — JAMAIS dans
les fichiers vivants. Retourne un plan d'application.
- `auto_update_enabled()` : lit le flag (défaut OFF).
⚠️ SWAP — répartition claire des responsabilités :
`apply_update` / `write_boot_ok_marker` ci-dessous ne font que l'ARMEMENT côté
Python (extraction vers `agent_v1_new/` + marqueurs) — ils n'écrasent JAMAIS un
fichier vivant. Le remplacement ATOMIQUE (renames), le redémarrage et le
rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (revu ligne à ligne).
Pattern d'import / résilience aligné sur `log_shipper.py` (même branche).
Branche feat/push-log-dgx.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import shutil
from pathlib import Path
from typing import Callable, Optional, Tuple
logger = logging.getLogger(__name__)
# Niveaux de livraison (R2). `code-only` par défaut = 99 % des MAJ (~500 Ko).
VALID_UPDATE_TYPES = ("code-only", "full")
DEFAULT_UPDATE_TYPE = "code-only"
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
# ---------------------------------------------------------------------------
# Flag d'activation — OFF par défaut (lu à chaque appel pour faciliter tests)
# ---------------------------------------------------------------------------
def auto_update_enabled() -> bool:
"""True si la MAJ auto client est activée (flag RPA_AUTO_UPDATE_ENABLED).
Défaut PRUDENT = OFF. On l'active poste par poste via config.txt / variable
d'environnement, sans rebuild de l'installateur (même esprit que
LOG_SHIP_ENABLED).
"""
return os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
"true", "1", "yes", "on",
)
# ---------------------------------------------------------------------------
# R3 — parse_version self-contained (le bundle client n'a pas server_v1)
# ---------------------------------------------------------------------------
def parse_version(v) -> Tuple[int, ...]:
"""Parse une version semver en tuple d'entiers. Voir server_v1/update_check.
"1.0.2" → (1, 0, 2) ; "1.0.10" → (1, 0, 10) ; "v1.2.3" → (1, 2, 3).
Tolérant et SANS exception : invalide → fallback `(0,)`.
"""
if not isinstance(v, str):
return _FALLBACK_VERSION
s = v.strip().lstrip("vV").strip()
if not s:
return _FALLBACK_VERSION
try:
from packaging.version import Version
return tuple(Version(s).release)
except Exception:
pass
try:
return tuple(int(x) for x in s.split("."))
except (ValueError, AttributeError):
return _FALLBACK_VERSION
def is_newer(candidate: str, baseline: str) -> bool:
"""True si `candidate` strictement plus récent que `baseline` (semver)."""
return parse_version(candidate) > parse_version(baseline)
def _normalize_update_type(update_type) -> str:
if update_type in VALID_UPDATE_TYPES:
return update_type
return DEFAULT_UPDATE_TYPE
# ---------------------------------------------------------------------------
# Décision client : faut-il updater ?
# ---------------------------------------------------------------------------
def should_update(local_version: str, server_response) -> Optional[dict]:
"""Décide à partir de la réponse serveur s'il faut updater.
Args:
local_version : version courante du client (config.AGENT_VERSION).
server_response : dict renvoyé par l'endpoint serveur
{update_available, latest_version, update_type, url, [sha256]}.
Returns:
Un PLAN d'update `{target_version, update_type, url, sha256}` si une MAJ
valide est à faire, sinon None.
Défense en profondeur : même si `update_available` est True, le client
REVÉRIFIE en semver (`is_newer`) — il ne descend JAMAIS vers une version
<= locale. Tolérant : réponse malformée → None (jamais d'exception).
"""
if not isinstance(server_response, dict):
return None
if not server_response.get("update_available"):
return None
target = server_response.get("latest_version")
url = server_response.get("url")
if not target or not url:
return None
# Double garde semver : pas de downgrade, pas d'égalité.
if not is_newer(target, local_version):
return None
return {
"target_version": target,
"update_type": _normalize_update_type(server_response.get("update_type")),
"url": url,
"sha256": server_response.get("sha256"),
}
# ---------------------------------------------------------------------------
# Téléchargement — downloader INJECTABLE, SHA256, staging only
# ---------------------------------------------------------------------------
def _default_downloader(url: str) -> bytes:
"""Téléchargement réel du ZIP (best-effort, pattern streamer/log_shipper).
Résout l'URL relative contre SERVER_BASE, ajoute le Bearer si présent.
INJECTABLE : remplacé par un fake en test (aucun réseau réel).
"""
import requests # import tardif (absent de certains envs de test)
full_url = url
headers = {}
try:
from ..config import SERVER_BASE, API_TOKEN
if url.startswith("/"):
full_url = f"{SERVER_BASE}{url}"
if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
except Exception:
# Hors package (test isolé) : on utilise l'URL telle quelle.
pass
resp = requests.get(full_url, headers=headers, timeout=30, stream=False)
resp.raise_for_status()
return resp.content
def download_update(
plan: dict,
staging_dir,
downloader: Optional[Callable[[str], bytes]] = None,
) -> dict:
"""Télécharge le ZIP d'update dans le staging et vérifie son intégrité.
NE TOUCHE PAS aux fichiers vivants : écrit uniquement dans `staging_dir`
(équivalent de `Lea_next\\`). L'application réelle (swap) est un stub
réservé révision humaine (voir `apply_update`).
Args:
plan : sortie de `should_update` (target_version, update_type, url, sha256).
staging_dir : dossier de staging (créé si absent).
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
Returns:
Succès : {ok: True, staged_zip: str, update_type, target_version,
sha256_verified: bool}
Échec : {ok: False, error: str}
Best-effort : aucune exception ne remonte ; un échec laisse le staging propre
(pas de ZIP corrompu).
"""
dl = downloader if downloader is not None else _default_downloader
staging = Path(staging_dir)
try:
data = dl(plan["url"])
except Exception as e:
logger.warning("Téléchargement update échoué : %s", e)
return {"ok": False, "error": f"download_failed: {e}"}
expected_sha = (plan.get("sha256") or "").strip().lower()
sha256_verified = False
if expected_sha:
actual = hashlib.sha256(data).hexdigest()
if actual != expected_sha:
logger.warning(
"SHA256 mismatch update (attendu=%s, obtenu=%s) — rejeté",
expected_sha, actual,
)
return {"ok": False, "error": "sha256 mismatch — ZIP rejeté"}
sha256_verified = True
else:
# Best-effort : pas de SHA fourni → on accepte mais on le signale.
logger.info("Pas de SHA256 fourni pour l'update — intégrité non vérifiée")
try:
staging.mkdir(parents=True, exist_ok=True)
target_version = plan.get("target_version", "unknown")
staged_zip = staging / f"lea_update_{target_version}.zip"
staged_zip.write_bytes(data)
except Exception as e:
logger.warning("Écriture ZIP staging échouée : %s", e)
return {"ok": False, "error": f"staging_write_failed: {e}"}
return {
"ok": True,
"staged_zip": str(staged_zip),
"update_type": _normalize_update_type(plan.get("update_type")),
"target_version": plan.get("target_version"),
"sha256_verified": sha256_verified,
}
# ---------------------------------------------------------------------------
# Interrogation serveur — checker INJECTABLE (GET /agents/update/check)
# ---------------------------------------------------------------------------
def _default_update_checker(local_version: str, machine_id: str):
"""Interroge le serveur : y a-t-il une MAJ ? (best-effort, INJECTABLE).
GET SERVER_URL/agents/update/check?current_version=..&machine_id=..
(endpoint gated côté serveur — 503 si RPA_AUTO_UPDATE_SERVER_ENABLED OFF,
auquel cas on renvoie None : pas de MAJ). Bearer si présent. Pattern aligné
sur `log_shipper._default_sender`. INJECTABLE : remplacé par un fake en test.
Returns:
Le dict réponse serveur (`should_update` sait le lire), ou None si
indisponible / gated / erreur (jamais d'exception ne remonte).
"""
try:
import requests # import tardif
headers = {}
try:
from ..config import SERVER_URL, API_TOKEN
base = SERVER_URL
if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
except Exception:
base = ""
url = f"{base}/agents/update/check"
resp = requests.get(
url,
params={"current_version": local_version, "machine_id": machine_id},
headers=headers,
timeout=10,
allow_redirects=False,
)
# 503 = endpoint gated OFF côté serveur → pas de MAJ (silencieux).
if resp.status_code == 503:
return None
if not resp.ok:
logger.debug("update/check HTTP %s", resp.status_code)
return None
return resp.json()
except Exception as e:
logger.debug("update/check indisponible : %s", e)
return None
# ---------------------------------------------------------------------------
# Orchestrateur GATED — check → décide → download (staging) → stub apply
# ---------------------------------------------------------------------------
def run_update_cycle(
local_version: str,
machine_id: str,
staging_dir,
checker: Optional[Callable[[str, str], object]] = None,
downloader: Optional[Callable[[str], bytes]] = None,
app_dir=None,
) -> dict:
"""Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap.
Enchaîne :
1. GATE `auto_update_enabled()` (RPA_AUTO_UPDATE_ENABLED, défaut OFF) —
si OFF, ne fait STRICTEMENT rien (aucun appel réseau).
2. `checker(local_version, machine_id)` → réponse serveur (canary-aware).
3. `should_update(...)` → plan (double garde semver, jamais de downgrade).
4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche
JAMAIS les fichiers vivants.
5. `apply_update` ARME le swap (extraction `agent_v1_new/` + marqueur
UPDATE_READY) mais NE swappe PAS : le remplacement atomique + le
redémarrage sont faits par Lea.bat au prochain démarrage. `applied`
reste False tant que Léa n'a pas redémarré sur la nouvelle version.
Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict
d'état pour le diagnostic / le log :
status ∈ {disabled, check_failed, up_to_date, download_failed, staged}
Args:
checker : callable `(local_version, machine_id) -> dict|None`
INJECTABLE (défaut = HTTP réel vers l'endpoint gated).
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
"""
if not auto_update_enabled():
return {"status": "disabled", "applied": False}
chk = checker if checker is not None else _default_update_checker
try:
server_response = chk(local_version, machine_id)
except Exception as e:
logger.warning("update check a levé : %s", e)
return {"status": "check_failed", "applied": False, "error": str(e)}
plan = should_update(local_version, server_response)
if plan is None:
return {"status": "up_to_date", "applied": False}
staged = download_update(plan, staging_dir, downloader=downloader)
if not staged.get("ok"):
return {
"status": "download_failed",
"applied": False,
"error": staged.get("error"),
}
# Armement du swap : extraction du ZIP vers agent_v1_new\ + marqueur
# UPDATE_READY. Le swap ATOMIQUE (renames) et le redémarrage sont faits
# HORS-PROCESS par Lea.bat au prochain démarrage — JAMAIS depuis ici
# (on n'écrase pas les fichiers d'un Léa en cours d'exécution).
armed = apply_update(staged, app_dir=app_dir)
return {
"status": "armed" if armed.get("armed") else "arm_failed",
"applied": False, # le swap effectif est fait par Lea.bat, pas ici
"armed": bool(armed.get("armed", False)),
"target_version": staged.get("target_version"),
"update_type": staged.get("update_type"),
"staged_zip": staged.get("staged_zip"),
"sha256_verified": staged.get("sha256_verified", False),
"marker": armed.get("marker"),
"error": armed.get("error"),
}
# ===========================================================================
# SWAP — côté Python : ARMEMENT SEULEMENT (extraction + marqueurs).
# Le remplacement ATOMIQUE des fichiers vivants + le redémarrage + le
# rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (renames).
# Python n'écrase JAMAIS les fichiers d'un Léa en cours d'exécution.
# ===========================================================================
def _resolve_app_dir(app_dir) -> Path:
"""Répertoire d'install (contient `agent_v1/`, `run_agent_v1.py`, `Lea.bat`).
INJECTABLE (tests : tmp_path). Défaut = parent du package agent_v1.
"""
if app_dir is not None:
return Path(app_dir)
try:
from ..config import BASE_DIR # BASE_DIR = dossier du package agent_v1
return Path(BASE_DIR).parent
except Exception:
return Path(__file__).resolve().parent.parent.parent
def apply_update(prepared: dict, app_dir=None) -> dict:
"""ARME le swap : extrait le ZIP staging vers `agent_v1_new/` + marqueur.
NE swappe PAS et NE redémarre PAS (c'est le rôle de `Lea.bat`). Écrit
uniquement à côté des fichiers vivants (dossier neuf + marqueur), donc
l'opération est sûre même sur un Léa en cours d'exécution.
1. Extrait `prepared["staged_zip"]` → `<app_dir>/agent_v1_new/`
(nettoyé au préalable ; garde-fou zip-slip).
2. Écrit `<app_dir>/UPDATE_READY` (JSON : version, type, chemins) que
`Lea.bat` lira au prochain démarrage pour faire le swap atomique.
Best-effort : aucune exception ne remonte (ne doit jamais casser Léa).
Returns:
succès : {armed: True, applied: False, target_version, update_type,
marker, extracted_to}
échec : {armed: False, applied: False, error}
"""
if not isinstance(prepared, dict):
return {"armed": False, "applied": False, "error": "prepared invalide"}
staged_zip = prepared.get("staged_zip")
target_version = prepared.get("target_version", "unknown")
update_type = _normalize_update_type(prepared.get("update_type"))
try:
root = _resolve_app_dir(app_dir)
zip_path = Path(staged_zip) if staged_zip else None
if zip_path is None or not zip_path.is_file():
return {"armed": False, "applied": False, "error": "ZIP staging introuvable"}
new_dir = root / "agent_v1_new"
if new_dir.exists():
shutil.rmtree(new_dir, ignore_errors=True) # nettoie un staging partiel
new_dir.mkdir(parents=True, exist_ok=True)
import zipfile
new_root = new_dir.resolve()
with zipfile.ZipFile(zip_path) as zf:
for name in zf.namelist(): # garde-fou zip-slip (chemins ../)
dest = (new_dir / name).resolve()
if not str(dest).startswith(str(new_root)):
shutil.rmtree(new_dir, ignore_errors=True)
return {"armed": False, "applied": False,
"error": f"zip-slip refusé : {name}"}
zf.extractall(new_dir)
marker = root / "UPDATE_READY"
marker.write_text(json.dumps({
"target_version": target_version,
"update_type": update_type,
"extracted_to": str(new_dir),
"staged_zip": str(zip_path),
}), encoding="utf-8")
logger.info(
"Update ARMÉ : %s (%s) → %s ; swap au prochain démarrage (Lea.bat)",
target_version, update_type, new_dir,
)
return {"armed": True, "applied": False, "target_version": target_version,
"update_type": update_type, "marker": str(marker),
"extracted_to": str(new_dir)}
except Exception as e: # noqa: BLE001
logger.warning("apply_update (armement) a échoué : %s", e)
return {"armed": False, "applied": False, "error": f"arm_failed: {e}"}
def write_boot_ok_marker(version: str, app_dir=None) -> dict:
"""Confirme un boot sain : écrit `boot_ok_{version}` + désarme le rollback.
Appelé par `main.py` après ~90 s de tourne STABLE (liveness LOCALE,
indépendante du DGX — évite un faux rollback quand le réseau est coupé).
Retirer `PENDING_BOOT*` dit à `Lea.bat` que la nouvelle version a démarré
correctement (sinon, au prochain lancement, Lea.bat rollback vers la
version précédente).
Best-effort : aucune exception ne remonte.
"""
try:
root = _resolve_app_dir(app_dir)
marker = root / f"boot_ok_{version}"
marker.write_text("ok", encoding="utf-8")
cleared = []
for p in root.glob("PENDING_BOOT*"):
try:
p.unlink()
cleared.append(p.name)
except OSError:
pass
logger.info("boot_ok écrit (%s) ; PENDING_BOOT retiré : %s",
version, cleared or "aucun")
return {"written": True, "marker": str(marker), "cleared_pending": cleared}
except Exception as e: # noqa: BLE001
logger.warning("write_boot_ok_marker a échoué : %s", e)
return {"written": False, "error": str(e)}

View File

@@ -3,6 +3,7 @@ mss>=9.0.1 # Capture d'écran haute performance
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
Pillow>=10.0.0 # Crops et processing image
requests>=2.31.0 # Streaming réseau
httpx>=0.27 # Client HTTP orchestrateur Léa (POST /api/learn/start) — brique conversationnelle
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
psutil>=5.9.0 # Monitoring CPU/RAM
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets

View File

@@ -29,6 +29,8 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from ..core.log_safe import _title_hash
logger = logging.getLogger(__name__)
@@ -132,7 +134,7 @@ class ActivityPanel:
)
self._notifier_changement()
self._rafraichir_ui()
logger.info(f"[ACTIVITY] Workflow démarré : {nom} ({nb_etapes} étapes)")
logger.info(f"[ACTIVITY] Workflow démarré : [wf_hash={_title_hash(nom)}] ({nb_etapes} étapes)")
def mettre_a_jour(
self,

View File

@@ -27,6 +27,8 @@ import os
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from ..core.log_safe import _path_ext
logger = logging.getLogger(__name__)
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
@@ -312,7 +314,7 @@ class _FileActionHandlerLocal:
})
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
logger.info(f"Liste dossier [ext={_path_ext(path_str)}] : {len(files)} fichiers")
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
def _create_dir(self, params: dict) -> dict:
@@ -328,7 +330,7 @@ class _FileActionHandlerLocal:
target = _Path(path_str)
existed = target.exists()
target.mkdir(parents=True, exist_ok=True)
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
logger.info(f"Dossier [ext={_path_ext(path_str)}] {'existait deja' if existed else 'cree'}")
return {"created": not existed, "path": path_str, "already_existed": existed}
def _move_file(self, params: dict) -> dict:
@@ -350,7 +352,7 @@ class _FileActionHandlerLocal:
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
_shutil.move(src, dst)
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
logger.info(f"Fichier deplace : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
return {"moved": True, "source": src, "destination": dst}
def _copy_file(self, params: dict) -> dict:
@@ -376,7 +378,7 @@ class _FileActionHandlerLocal:
_shutil.copytree(src, dst)
else:
_shutil.copy2(src, dst)
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
logger.info(f"Fichier copie : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
return {"copied": True, "source": src, "destination": dst}
def _sort_by_extension(self, params: dict) -> dict:
@@ -425,7 +427,7 @@ class _FileActionHandlerLocal:
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
f"Classement par extension [ext={_path_ext(source_dir_str)}] : {len(moved)} fichiers"
)
return {
"moved": moved,

View File

@@ -5,6 +5,9 @@ Fenetre de chat Lea integree au systray — version tkinter native.
Remplace l'approche Edge browser par une vraie fenetre tkinter integree.
Design professionnel, theme clair, ancree en bas a droite de l'ecran.
Tourne dans son propre thread daemon pour ne pas bloquer pystray.
Le runtime Python embedded Windows ne contient pas toujours Tcl/Tk. Dans ce
cas, le menu "Discuter avec Lea" ouvre le chat DGX dans le navigateur.
"""
import logging
@@ -13,6 +16,8 @@ import math
import threading
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
@@ -219,7 +224,10 @@ class ChatWindow:
def toggle(self) -> None:
"""Afficher/masquer la fenetre de chat."""
if self._destroyed or self._root is None:
if self._destroyed:
return
if self._root is None:
self._open_browser_fallback()
return
if self._visible:
self.hide()
@@ -228,7 +236,10 @@ class ChatWindow:
def show(self) -> None:
"""Afficher la fenetre."""
if self._destroyed or self._root is None:
if self._destroyed:
return
if self._root is None:
self._open_browser_fallback()
return
self._root.after(0, self._do_show)
@@ -257,6 +268,79 @@ class ChatWindow:
"""Mettre a jour le client serveur (appele si cree apres la fenetre)."""
self._server_client = server_client
def _chat_url(self) -> str:
"""Retourne l'URL web du chat, derivee de la config serveur."""
configured_url = self._chat_url_from_server_url(self._configured_server_url())
if self._server_client is not None:
chat_base = getattr(self._server_client, "_chat_base", None)
if chat_base:
chat_base = str(chat_base).rstrip("/")
if not self._is_local_url(chat_base):
return chat_base
if configured_url:
return configured_url
if configured_url:
return configured_url
host = (self._server_host or "localhost").strip()
if host.startswith(("http://", "https://")):
parsed = urlparse(host)
scheme = parsed.scheme or "http"
hostname = parsed.hostname or "localhost"
return f"{scheme}://{hostname}:{self._chat_port}"
return f"http://{host}:{self._chat_port}"
@staticmethod
def _is_local_url(url: str) -> bool:
try:
host = urlparse(url).hostname
except Exception:
return False
return host in {"localhost", "127.0.0.1", "::1"}
def _chat_url_from_server_url(self, server_url: Optional[str]) -> Optional[str]:
if not server_url:
return None
try:
parsed = urlparse(server_url.strip())
except Exception:
return None
if not parsed.hostname or parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
return None
scheme = parsed.scheme or "http"
return f"{scheme}://{parsed.hostname}:{self._chat_port}"
def _configured_server_url(self) -> Optional[str]:
env_url = os.environ.get("RPA_SERVER_URL", "").strip()
if env_url:
return env_url
try:
# Installed layout: <app>/agent_v1/ui/chat_window.py.
for parent in Path(__file__).resolve().parents:
cfg = parent / "config.txt"
if cfg.exists():
for line in cfg.read_text(encoding="utf-8", errors="ignore").splitlines():
if line.startswith("RPA_SERVER_URL="):
return line.split("=", 1)[1].strip()
except Exception:
logger.debug("Lecture config.txt pour chat_url impossible", exc_info=True)
return None
def _open_browser_fallback(self) -> None:
"""Fallback POC quand tkinter est absent du Python embedded."""
url = self._chat_url()
try:
import webbrowser
if webbrowser.open(url, new=1):
logger.info("ChatWindow indisponible, chat ouvert dans le navigateur: %s", url)
else:
logger.warning("ChatWindow indisponible, ouverture navigateur refusee: %s", url)
except Exception as exc:
logger.error("Impossible d'ouvrir le chat dans le navigateur (%s): %s", url, exc)
def _on_shared_state_change(self, state) -> None:
"""Callback appele quand l'etat partage change (depuis le systray ou ailleurs).

View File

@@ -0,0 +1,197 @@
# agent_v1/ui/session_watchdog.py
"""Watchdog de session interactive Windows — résilience RDP/Citrix.
Problème résolu (preuve poste clinique Émilie, 01/07) :
09:46:28 [MAIN] agent.run() est sorti mais agent.running=True — probablement
pystray sans session interactive (SSH)
09:46:28 [MAIN] Keepalive headless actif — main thread bloque...
Sur les postes cliniques (tous RDP/Citrix), la session interactive
disparaît quand l'utilisateur se déconnecte / la session bascule en
verrouillage. `pystray.Icon.run()` sort alors immédiatement (plus de
bureau interactif `WinSta0\\Default` pour recevoir les entrées et afficher
l'icône). L'ancien `_headless_keepalive` bloquait le main thread *pour
toujours* : l'icône tray + la fenêtre chat DISPARAISSAIENT et ne
revenaient JAMAIS, même après reconnexion RDP. Les soignants croyaient
que Léa avait planté (la capture continuait pourtant en fond).
Solution : un watchdog qui surveille la disponibilité du bureau
interactif via `OpenInputDesktop()` (signal Win32 canonique — échoue quand
la session est déconnectée/verrouillée, réussit à la reconnexion) et
(re)lance l'UI tray dès qu'une session redevient disponible. Les threads
de fond (heartbeat, replay poll, capture_server) NE SONT JAMAIS touchés :
ils tournent contre `agent.running` et restent uniques. On ne relance
JAMAIS un second `AgentV1` — seulement la couche UI (tray + chat).
État de l'art (recherche 01/07) :
- `OpenInputDesktop()` échoue (ERROR_ACCESS_DENIED / ERROR_INVALID_...)
quand le processus n'est pas rattaché au windowstation interactif
`WinSta0` — c'est exactement le cas quand la session RDP est
déconnectée. C'est la méthode fiable recommandée (comparer les
*noms* de bureau via GetUserObjectInformation n'apporte rien de plus
ici : on a juste besoin d'un booléen « input desktop dispo ? »).
- `WTSGetActiveConsoleSessionId` renvoie une pseudo-session même sans
login → PAS fiable pour ce besoin.
- `pystray.Icon.run()` ne sort jamais en session interactive normale ;
il sort immédiatement sinon → c'est notre signal de « session perdue ».
"""
from __future__ import annotations
import logging
import platform
import threading
from typing import Callable, Optional
logger = logging.getLogger(__name__)
# Intervalle de sondage du bureau interactif (secondes).
# 3s = compromis : réactif à la reconnexion sans marteler l'API Win32.
POLL_INTERVAL_S = 3.0
def is_interactive_desktop_available() -> bool:
"""Retourne True si un bureau interactif Windows est disponible.
Utilise `OpenInputDesktop()` : succès => le windowstation interactif
(`WinSta0\\Default`) est accessible et peut afficher un tray. Échec =>
session RDP/Citrix déconnectée ou verrouillée sans bureau d'entrée.
Hors Windows (Linux/dev/tests) : renvoie toujours True (pas de notion
de bureau interactif verrouillable ici — on laisse l'UI tourner).
Toute erreur d'appel Win32 est traitée comme « indisponible » (prudent)
SAUF l'indisponibilité de l'API elle-même (pywin32 absent) → True pour
ne pas priver un poste de son tray à cause d'une dépendance manquante.
"""
if platform.system() != "Windows":
return True
try:
import win32con # type: ignore
import win32service # type: ignore
except Exception:
# pywin32 indisponible : on ne peut pas sonder → on suppose dispo
# (comportement historique : tenter l'UI plutôt que la bloquer).
logger.debug("pywin32 indisponible — sondage bureau interactif ignoré")
return True
hdesk = None
try:
# DESKTOP_SWITCHDESKTOP (0x0100) = droit minimal, aligné sur l'usage
# documenté pour tester la présence du bureau d'entrée.
hdesk = win32service.OpenInputDesktop(0, False, win32con.DESKTOP_SWITCHDESKTOP)
return hdesk is not None
except Exception:
# OpenInputDesktop lève quand aucun bureau d'entrée n'est accessible
# (session déconnectée / verrouillée). C'est le cas « indisponible ».
return False
finally:
if hdesk is not None:
try:
# PyHANDLE se ferme via .Close() (pywin32) ; fallback silencieux.
hdesk.Close()
except Exception:
pass
class InteractiveSessionWatchdog:
"""Surveille la session interactive et (re)lance l'UI tray à la reconnexion.
Ne détient AUCUN état de capture. Sa seule responsabilité : garantir
qu'il existe au plus UN tray vivant à la fois, et le ressusciter quand
une session interactive redevient disponible. Les daemon threads de
l'agent (heartbeat/replay/capture) sont indépendants et intacts.
Paramètres :
run_ui : callable bloquant qui lance le tray (typiquement
``agent.ui.run`` / ``agent.run``). Retourne quand le
tray sort (normal en fin de session interactive).
is_running : callable -> bool ; True tant que l'agent doit vivre
(typiquement ``lambda: agent.running``).
is_available : callable -> bool de détection de session (injectable
pour les tests). Défaut = is_interactive_desktop_available.
poll_interval_s : période de sondage quand la session est absente.
"""
def __init__(
self,
run_ui: Callable[[], None],
is_running: Callable[[], bool],
is_available: Optional[Callable[[], bool]] = None,
poll_interval_s: float = POLL_INTERVAL_S,
) -> None:
self._run_ui = run_ui
self._is_running = is_running
self._is_available = is_available or is_interactive_desktop_available
self._poll_interval_s = poll_interval_s
self._wake = threading.Event()
# Sérialise le lancement de l'UI : jamais deux trays en parallèle.
self._ui_lock = threading.Lock()
def stop(self) -> None:
"""Réveille le watchdog pour qu'il réévalue ``is_running`` et sorte."""
self._wake.set()
def _run_ui_once(self) -> None:
"""Lance l'UI tray une fois (bloquant) sous verrou, avec garde d'erreur.
Le verrou empêche formellement qu'un second appel démarre un tray
alors qu'un premier tourne encore (invariant « un seul tray »).
"""
with self._ui_lock:
try:
self._run_ui()
except Exception:
# Un crash du tray ne doit jamais tuer le watchdog : on log et
# on laisse la boucle décider (retry ou sortie selon is_running).
logger.exception("[WATCHDOG] Le tray UI a levé une exception")
def run(self) -> None:
"""Boucle principale (bloque le main thread à la place du keepalive).
Cycle :
1. Attendre qu'un bureau interactif soit disponible.
2. (Re)lancer le tray — bloque jusqu'à sa sortie (déconnexion RDP).
3. Recommencer tant que ``is_running`` est vrai.
Ne consomme pas de CPU en boucle serrée : sonde toutes les
``poll_interval_s`` via un Event interruptible (réveil immédiat au stop).
"""
logger.info(
"[WATCHDOG] Surveillance session interactive active "
"(re-affichage auto du tray + chat à la reconnexion RDP/Citrix)."
)
first_cycle = True
while self._is_running():
if not self._is_available():
# Session absente : sonder périodiquement sans brûler le CPU.
if first_cycle:
logger.warning(
"[WATCHDOG] Aucune session interactive — Léa reste active "
"en fond (capture/heartbeat), tray masqué. En attente de "
"reconnexion RDP/Citrix pour ré-afficher l'interface."
)
# Event.wait renvoie True si stop() a été appelé → on sort.
if self._wake.wait(timeout=self._poll_interval_s):
break
first_cycle = False
continue
# Session disponible : (re)lancer le tray.
if not first_cycle:
logger.info(
"[WATCHDOG] Session interactive détectée — ré-affichage du "
"tray et de la fenêtre chat de Léa."
)
first_cycle = False
# Bloque jusqu'à la sortie du tray (fin de session interactive).
self._run_ui_once()
# Le tray est sorti. Si l'agent doit vivre, on reboucle (le
# prochain tour re-sondera la session et re-affichera le tray).
if not self._is_running():
break
logger.info("[WATCHDOG] Arrêt de la surveillance de session interactive.")

View File

@@ -137,6 +137,15 @@ class SmartTrayV1:
self._state_lock = threading.Lock()
self._stop_event = threading.Event()
# Résilience RDP/Citrix : run() peut être rappelé plusieurs fois par le
# watchdog de session (ré-affichage du tray à la reconnexion). Les
# threads de fond (connexion, cache workflows, hotkey) et l'accueil ne
# doivent démarrer QU'UNE fois — sinon on duplique les threads.
self._bg_started = False
# Signalé quand l'utilisateur a demandé Quitter : le watchdog ne doit
# alors PAS relancer le tray.
self._quit_requested = False
# Notifications
self._notifier = NotificationManager()
@@ -709,6 +718,11 @@ class SmartTrayV1:
"""Arrete proprement l'agent et quitte."""
logger.info("Arret demande par l'utilisateur")
# Marquer l'arret volontaire : le watchdog de session ne doit PAS
# relancer le tray après un Quitter explicite (à distinguer d'une
# simple déconnexion RDP où le tray doit revenir tout seul).
self._quit_requested = True
# Arreter la session si en cours
if self.is_recording:
self.on_stop()
@@ -885,17 +899,24 @@ class SmartTrayV1:
# ------------------------------------------------------------------
def run(self) -> None:
"""Demarre le tray, les threads de fond, et entre dans la boucle principale."""
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
self._notifier.greet()
"""Demarre (ou ré-affiche) le tray et entre dans la boucle pystray.
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
self._start_hotkey()
Ré-entrant : le watchdog de session (session_watchdog.py) rappelle
cette méthode à chaque reconnexion RDP/Citrix pour ré-afficher le
tray + la fenêtre chat. Les initialisations one-shot (accueil,
hotkey, threads de fond connexion/cache) sont protégées par
``_bg_started`` pour ne PAS dupliquer les threads. Seule l'icône
pystray est recréée à chaque appel (l'ancienne est morte avec la
session précédente).
"""
self._start_background_once()
# Tooltip avec identifiant machine pour le multi-machine
tray_title = f"Agent V1 - {self.machine_id}"
# Menu statique — reconstruit via _update_icon() quand l'état change
# Menu statique — reconstruit via _update_icon() quand l'état change.
# Nouvelle icône à chaque (ré)affichage : l'objet pystray précédent
# est invalide une fois sa boucle sortie (session interactive perdue).
self.icon = pystray.Icon(
"AgentV1",
self._current_icon(),
@@ -903,6 +924,33 @@ class SmartTrayV1:
menu=pystray.Menu(*self._get_menu_items()),
)
# Rafraîchir les workflows au (ré)affichage — utile après reconnexion.
if self._bg_started and self.server_client is not None:
threading.Thread(target=self._fetch_workflows, daemon=True).start()
# Boucle principale pystray (bloquante). Sort quand la session
# interactive disparaît (RDP déconnecté) OU sur _on_quit → le
# watchdog décide alors de relancer ou non.
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
self.icon.run()
def _start_background_once(self) -> None:
"""Initialisations one-shot : accueil, hotkey, threads de fond.
Idempotent : les appels suivants (ré-affichage tray) sont des no-op.
Garantit qu'on n'accumule pas de threads connexion/cache à chaque
reconnexion RDP.
"""
if self._bg_started:
return
self._bg_started = True
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
self._notifier.greet()
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
self._start_hotkey()
# Demarrer le thread de verification connexion
if self.server_client is not None:
conn_thread = threading.Thread(
@@ -924,7 +972,3 @@ class SmartTrayV1:
threading.Thread(
target=self._fetch_workflows, daemon=True
).start()
# Boucle principale pystray (bloquante)
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
self.icon.run()

View File

@@ -0,0 +1,110 @@
"""Politique de sauvegarde des captures — réduction du poids disque.
Constat : tous les shots étaient sauvés en PNG plein écran lossless
(``img.save(path, "PNG", quality=...)`` — PNG ignore ``quality``), d'
~90 Go pour 13 sessions. La majorité de ce poids n'a aucune valeur de
grounding (full + full_blurred en doublon, heartbeats plein écran).
Cette politique distingue le **type** de shot et écrit le format adapté :
- ``crop`` → PNG lossless. C'est la cible de grounding qwen3-vl ; on
préserve chaque pixel (perte JPEG = bruit sur de petites icônes). Le crop
fait 80×80 → poids négligeable, aucun intérêt à le dégrader.
- ``full`` / ``window`` / ``context`` → JPEG ``quality=SCREENSHOT_QUALITY,
optimize=True``. Ce sont des vues contextuelles / humaines : la
compression JPEG (~5-10x) est sans impact fonctionnel.
- ``heartbeat`` → JPEG **downscalé** (largeur max ``HEARTBEAT_MAX_WIDTH``,
ratio préservé). C'est de la *liveness* (le serveur vérifie juste qu'un
écran a changé), pas du grounding → la pleine résolution est du gaspillage.
``save_capture`` retourne le chemin RÉELLEMENT écrit, extension ajustée selon
le format. L'appelant doit utiliser ce retour (et non un chemin ``.png``
présumé) pour streamer / référencer le bon fichier.
⚠️ Contrat avec le serveur : l'extension du crop NE DOIT PAS changer (le
serveur retrouve le crop par basename via ``vision_info.crop`` — voir
``stream_processor._extract_crop_b64`` stratégie 1). C'est pourquoi ``crop``
reste PNG. Les full/window/context/heartbeat sont retrouvés par
``screenshot_id`` avec extension ``.png`` hardcodée côté serveur, mais le
serveur réécrit toujours l'upload sous ``{shot_id}.png`` (le suffixe envoyé
sur le fil est ignoré) → changer l'extension LOCALE de ces types est sûr.
"""
from __future__ import annotations
import os
from typing import Iterable
from PIL import Image
from ..config import SCREENSHOT_QUALITY
# Types sauvés en JPEG (vue contextuelle / humaine, pas de grounding pixel).
_JPEG_KINDS: frozenset = frozenset({"full", "window", "context"})
# Largeur max d'un heartbeat downscalé. 1280 px suffit largement pour de la
# liveness (détecter qu'un écran a changé) ; on divise le poids d'un 2560 px
# par ~4 (surface) avant compression JPEG.
HEARTBEAT_MAX_WIDTH = 1280
def _ensure_jpeg_ready(img: Image.Image) -> Image.Image:
"""Convertit en RGB si nécessaire (JPEG ne supporte ni alpha ni palette)."""
if img.mode in ("RGBA", "LA", "P"):
return img.convert("RGB")
return img
def _downscale_to_width(img: Image.Image, max_width: int) -> Image.Image:
"""Réduit l'image à ``max_width`` en préservant le ratio (no-op si plus petite)."""
if img.width <= max_width:
return img
new_height = max(1, round(img.height * max_width / img.width))
return img.resize((max_width, new_height), Image.LANCZOS)
def save_capture(img: Image.Image, path_base: str, kind: str) -> str:
"""Sauve ``img`` selon la politique du ``kind`` et retourne le chemin écrit.
Args:
img: image PIL à sauvegarder.
path_base: chemin SANS extension (ex.
``.../shots/shot_0001_full``). L'extension finale (``.png`` ou
``.jpg``) est ajoutée par la politique.
kind: type de shot — ``"crop"`` | ``"full"`` | ``"window"`` |
``"context"`` | ``"heartbeat"``.
Returns:
Le chemin RÉELLEMENT écrit, avec la bonne extension.
Raises:
ValueError: si ``kind`` n'est pas reconnu (fail-closed : on refuse
d'écrire un fichier dont la politique est indéterminée).
"""
if kind == "crop":
out_path = f"{path_base}.png"
img.save(out_path, "PNG")
return out_path
if kind in _JPEG_KINDS:
out_path = f"{path_base}.jpg"
_ensure_jpeg_ready(img).save(
out_path, "JPEG", quality=SCREENSHOT_QUALITY, optimize=True
)
return out_path
if kind == "heartbeat":
out_path = f"{path_base}.jpg"
small = _downscale_to_width(_ensure_jpeg_ready(img), HEARTBEAT_MAX_WIDTH)
small.save(out_path, "JPEG", quality=SCREENSHOT_QUALITY)
return out_path
raise ValueError(
f"kind de capture inconnu : {kind!r} "
f"(attendu: crop, full, window, context, heartbeat)"
)
def known_kinds() -> Iterable[str]:
"""Retourne les ``kind`` supportés (utile pour la validation appelant)."""
return ("crop", *sorted(_JPEG_KINDS), "heartbeat")

View File

@@ -18,8 +18,9 @@ import platform
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
from ..config import TARGETED_CROP_SIZE, BLUR_SENSITIVE
from .blur_sensitive import blur_sensitive_regions
from .capture_io import save_capture
logger = logging.getLogger(__name__)
@@ -425,6 +426,18 @@ class VisionCapturer:
# On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows
self.last_img_hash = None
def _ensure_shots_dir(self) -> None:
"""Garantit l'existence de `shots/` avant toute écriture.
Le dossier est créé dans `__init__`, mais l'auto-cleanup de
`SessionStorage` (`shutil.rmtree` par âge/taille) peut supprimer tout
le dossier de session — y compris la session permanente `_background`.
Sans ce garde, la capture suivante lève `[Errno 2] No such file or
directory` (bug observé poste Émilie). On recrée donc le répertoire
cible juste avant chaque sauvegarde.
"""
os.makedirs(self.shots_dir, exist_ok=True)
def capture_full_context(self, name_suffix: str, force=False) -> str:
"""
Capture l'écran complet.
@@ -460,9 +473,15 @@ class VisionCapturer:
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
return path
# Politique d'écriture : les heartbeats sont de la liveness pure
# (le serveur vérifie juste qu'un écran a changé) → JPEG downscalé.
# Les autres contextes (focus_change, result_of_*) → JPEG q85.
kind = "heartbeat" if "heartbeat" in name_suffix else "context"
self._ensure_shots_dir()
path_base = os.path.join(
self.shots_dir, f"context_{int(time.time())}_{name_suffix}"
)
return save_capture(img, path_base, kind)
except Exception as e:
logger.error(f"Erreur Context Capture: {e}")
return ""
@@ -506,10 +525,10 @@ class VisionCapturer:
return result
return {}
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
full_base = os.path.join(self.shots_dir, f"{screenshot_id}_full")
# Capture du Crop (Cœur de l'apprentissage qwen3-vl)
crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png")
crop_base = os.path.join(self.shots_dir, f"{screenshot_id}_crop")
w, h = TARGETED_CROP_SIZE
left = max(0, x - w // 2)
top = max(0, y - h // 2)
@@ -523,8 +542,11 @@ class VisionCapturer:
blur_sensitive_regions(img)
blur_sensitive_regions(crop_img)
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)
# Politique d'écriture : full = vue contextuelle → JPEG q85 ;
# crop = cible de grounding qwen3-vl → PNG lossless (contrat serveur).
self._ensure_shots_dir()
full_path = save_capture(img, full_base, "full")
crop_path = save_capture(crop_img, crop_base, "crop")
# Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img)
@@ -648,11 +670,12 @@ class VisionCapturer:
if BLUR_SENSITIVE:
blur_sensitive_regions(window_img)
# Sauvegarde
window_path = os.path.join(
self.shots_dir, f"{screenshot_id}_window.png"
# Sauvegarde — fenêtre = vue contextuelle → JPEG q85 (politique).
self._ensure_shots_dir()
window_base = os.path.join(
self.shots_dir, f"{screenshot_id}_window"
)
window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY)
window_path = save_capture(window_img, window_base, "window")
result = {
"window_image": window_path,

View File

@@ -19,6 +19,8 @@ import platform
import subprocess
from typing import Any, Dict, Optional
from .core.log_safe import _title_hash
def _run_cmd(cmd: list[str]) -> Optional[str]:
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
@@ -372,7 +374,7 @@ if __name__ == "__main__":
for i in range(5):
info = get_active_window_info()
rect = get_active_window_rect()
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
print(f"[{i+1}] App: {info['app_name']:20s} | Title: [title_hash={_title_hash(info['title'])}]")
if rect:
print(f" Rect: {rect['rect']} | Size: {rect['size']}")
else:

View File

@@ -0,0 +1,77 @@
"""Store des logs poussés par les clients Léa (push-log-DGX).
Persiste les logs reçus du client, rangés par `machine_id`, pour consultation
au dashboard (diagnostic des postes sans AnyDesk). Stockage fichier JSONL
(un fichier par jour et par machine_id), rétention configurable.
DETTE-020/021 (observabilité). Branche feat/push-log-dgx.
"""
from __future__ import annotations
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
# machine_id = entrée réseau → neutraliser tout caractère hors liste blanche
# (anti path-traversal : '/', '\\', '..' ne doivent pas s'échapper du base_dir).
_SAFE_MACHINE_ID_RE = re.compile(r"[^A-Za-z0-9._-]")
class AgentLogsStore:
"""Persiste et relit les logs clients rangés par machine_id (JSONL)."""
def __init__(self, base_dir: str | Path = "data/agent_logs"):
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
def _machine_dir(self, machine_id: str) -> Path:
safe = _SAFE_MACHINE_ID_RE.sub("_", machine_id or "").strip("._") or "unknown"
d = self.base_dir / safe
d.mkdir(parents=True, exist_ok=True)
return d
def append(self, machine_id: str, entries: list[dict]) -> int:
"""Ajoute un batch de logs pour un poste. Retourne le nb de lignes écrites."""
if not entries:
return 0
now = datetime.now(timezone.utc)
day_file = self._machine_dir(machine_id) / f"{now.date().isoformat()}.jsonl"
with day_file.open("a", encoding="utf-8") as f:
for entry in entries:
record = dict(entry)
record.setdefault("received_at", now.isoformat())
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return len(entries)
def read(self, machine_id: str) -> list[dict]:
"""Relit toutes les entrées d'un poste, triées par fichier (date) puis ordre d'écriture."""
d = self._machine_dir(machine_id)
out: list[dict] = []
for jsonl in sorted(d.glob("*.jsonl")):
with jsonl.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
out.append(json.loads(line))
return out
def purge_old(self, retention_days: int = 30, now: datetime | None = None) -> int:
"""Supprime les fichiers-jour antérieurs à la rétention. Retourne le nb supprimé.
Rétention basée sur la date encodée dans le nom du fichier (`YYYY-MM-DD.jsonl`),
pas sur le mtime (déterministe, non altérable). `now` injectable pour les tests.
"""
now = now or datetime.now(timezone.utc)
cutoff = (now - timedelta(days=retention_days)).date()
removed = 0
for jsonl in self.base_dir.rglob("*.jsonl"):
try:
file_date = datetime.strptime(jsonl.stem, "%Y-%m-%d").date()
except ValueError:
continue # nom inattendu → on ne touche pas
if file_date < cutoff:
jsonl.unlink()
removed += 1
return removed

View File

@@ -27,6 +27,7 @@ from fastapi import BackgroundTasks, Depends, FastAPI, File, HTTPException, Requ
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from .pii_sanitizer import sanitize_event, sanitize_log_entries
from .replay_failure_logger import log_replay_failure
from .replay_verifier import ReplayVerifier, VerificationResult
from .replay_learner import ReplayLearner
@@ -419,9 +420,11 @@ from .replay_engine import (
_edge_to_normalized_actions,
_substitute_variables,
_resolve_runtime_vars,
_coerce_action_coords,
_SERVER_SIDE_ACTION_TYPES,
_handle_extract_text_action,
_handle_extract_table_action,
_handle_extract_dossier_action,
_handle_t2a_decision_action,
_handle_llm_generate_action,
_handle_concat_text_vars_action,
@@ -434,6 +437,9 @@ from .replay_engine import (
_notify_error_callback as _notify_error_callback_impl,
)
# Navigate handler — import direct depuis core/navigation (pas via replay_engine)
from core.navigation import _handle_navigate_action
# Wrappers pour les fonctions replay_engine qui accèdent aux variables globales du module.
@@ -555,6 +561,7 @@ LIVE_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
_DATA_DIR = ROOT_DIR / "data" / "training"
WORKER_QUEUE_FILE = _DATA_DIR / "_worker_queue.txt"
REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
WORKER_HEALTH_FILE = _DATA_DIR / "_worker_health.json"
# Instance globale partagée (le StreamProcessor reste dans le serveur HTTP
# pour le CLIP, l'indexation FAISS, la gestion des sessions, le replay —
@@ -582,6 +589,17 @@ _AGENTS_DB_PATH = os.environ.get(
)
agent_registry = AgentRegistry(db_path=_AGENTS_DB_PATH)
# push-log-DGX : store des logs poussés par les clients, rangés par machine_id
# (observabilité des postes sans AnyDesk — DETTE-020/021).
from .agent_logs_store import AgentLogsStore # noqa: E402
_AGENT_LOGS_DIR = os.environ.get(
"RPA_AGENT_LOGS_DIR", str(ROOT_DIR / "data" / "agent_logs")
)
# Garde-fou anti-flood (G3) : nb max d'entrées acceptées par batch.
_AGENT_LOGS_MAX_BATCH = int(os.environ.get("RPA_AGENT_LOGS_MAX_BATCH", "1000"))
agent_logs_store = AgentLogsStore(base_dir=_AGENT_LOGS_DIR)
def _agent_registry_has_entries() -> bool:
try:
@@ -807,7 +825,7 @@ def _memory_window_title_for_action(action_meta: Dict[str, Any]) -> str:
def _get_worker_queue_status() -> Dict[str, Any]:
"""Retourne l'état de la queue du worker VLM (pour le monitoring)."""
"""Retourne l'état réel de la queue et du worker VLM (pour le monitoring)."""
queue = []
if WORKER_QUEUE_FILE.exists():
try:
@@ -819,16 +837,108 @@ def _get_worker_queue_status() -> Dict[str, Any]:
except Exception:
pass
health = None
health_error = None
health_age_seconds = None
if WORKER_HEALTH_FILE.exists():
try:
health = json.loads(WORKER_HEALTH_FILE.read_text(encoding="utf-8"))
health_age_seconds = max(0.0, time.time() - WORKER_HEALTH_FILE.stat().st_mtime)
except Exception as exc:
health_error = str(exc)
health_stale = health_age_seconds is None or health_age_seconds > 180
components = (health or {}).get("components") or {}
components_ready = bool(components) and all(bool(v) for v in components.values())
health_status = (health or {}).get("status")
running = bool(health) and not health_stale and health_status != "stopped"
# Distinction VEILLE (armé, lazy) vs DÉGRADÉ (vrai échec).
#
# Les composants lourds (ScreenAnalyzer/CLIP/FAISS/StateEmbedding) sont
# chargés en lazy par run_worker : le processor n'est instancié qu'au
# premier _process_session (cf. run_worker._get_processor / _process_session).
# Un worker neuf qui n'a jamais reçu de session écrit donc status="healthy"
# avec tous les composants à false — c'est l'état NORMAL « en veille », pas
# une panne. L'étiqueter "degraded" fait lire une panne là où il n'y en a pas.
#
# Signal retenu pour « init jamais tentée » : TOUS les composants à false ET
# sessions_processed == 0 ET sessions_failed == 0. Justification : run_worker
# n'appelle _get_processor() (donc l'init lazy) que dans _process_session, qui
# incrémente toujours exactement un compteur (processed / failed / skipped).
# Tant que processed == 0 ET failed == 0, aucune session n'a déclenché une
# init suivie d'un traitement — le worker est armé en attente. Un simple skip
# (dossier/shots absents) passe quand même par _get_processor() : les
# composants se chargent, donc tous-à-false devient faux et on n'entre pas ici.
# run_worker._health_components() écrit toujours les 4 clés (jamais un dict
# vide), d'où le test sur les VALEURS et non sur la présence des clés.
# Si run_worker a lui-même forcé status="degraded" (VLM + ScreenAnalyzer
# absent, cf. run_worker._write_health), c'est un VRAI échec : on le conserve.
stats = (health or {}).get("stats") or {}
init_attempted = bool(stats.get("sessions_processed", 0)) or bool(
stats.get("sessions_failed", 0)
)
components_all_false = bool(components) and not any(
bool(v) for v in components.values()
)
armed = (
running
and not components_ready
and health_status == "healthy"
and components_all_false # aucun composant lourd encore chargé
and not init_attempted
)
status = health_status or "unknown"
if not running:
status = "stale" if health else "unknown"
elif armed:
# En veille : worker sain, composants chargés à la 1re session.
status = "idle"
elif not components_ready:
status = "degraded"
return {
"running": True, # On ne sait pas si le worker process tourne, mais la queue existe
"running": running,
"status": status,
"armed": armed,
"queue_length": len(queue),
"queue": queue,
"replay_lock_active": REPLAY_LOCK_FILE.exists(),
"queue_file": str(WORKER_QUEUE_FILE),
"note": "Le worker VLM tourne dans un process séparé (run_worker.py)",
"health_file": str(WORKER_HEALTH_FILE),
"health_error": health_error,
"health_age_seconds": health_age_seconds,
"health_stale": health_stale,
"worker_pid": (health or {}).get("pid"),
"last_cycle": (health or {}).get("last_cycle"),
"current_session": (health or {}).get("current_session"),
"components": components,
"components_ready": components_ready,
"processing_ready": running and not REPLAY_LOCK_FILE.exists() and components_ready,
"status_hint": _worker_status_hint(status, armed),
"stats": stats,
"note": "Le worker VLM tourne dans un process séparé (agent_v0.server_v1.run_worker).",
}
def _worker_status_hint(status: str, armed: bool) -> str:
"""Message humain pour le statut worker (consommé par le dashboard)."""
if armed or status == "idle":
return "En veille — composants chargés à la 1re session."
if status == "degraded":
return "Worker apprentissage dégradé — init des composants en échec."
if status == "stale":
return "Health file périmé (> 180s) — worker peut-être arrêté."
if status == "stopped":
return "Worker arrêté."
if status == "busy":
return "Traitement d'une session en cours."
if status == "healthy":
return "Worker prêt — composants chargés."
return "État worker inconnu."
# =========================================================================
# Compteur d'analyses en cours par session (pour attendre avant finalize)
# =========================================================================
@@ -1469,6 +1579,16 @@ class AgentUninstallRequest(BaseModel):
reason: Optional[str] = None
class AgentLogsRequest(BaseModel):
"""Batch de logs poussé par un client Léa (push-log-DGX).
`logs` = liste d'entrées {ts, level, logger, message} (format libre côté
serveur ; le client garantit le PII-safe avant push).
"""
machine_id: str
logs: list[dict] = []
# Thread de nettoyage périodique des replays terminés et sessions expirées
_cleanup_thread: Optional[threading.Thread] = None
_cleanup_running = False
@@ -1614,13 +1734,14 @@ async def startup():
threading.Thread(target=_smoke_model_health, name="model-health-smoke", daemon=True).start()
# Afficher le token API au démarrage pour que l'utilisateur puisse configurer l'agent
# Ne jamais imprimer le token complet dans journald/stdout.
_token_source = "env RPA_API_TOKEN" if os.environ.get("RPA_API_TOKEN") else "auto-généré"
logger.info(f"API Token ({_token_source}): {API_TOKEN}")
_token_hint = f"{API_TOKEN[:8]}{API_TOKEN[-4:]}" if API_TOKEN else "<absent>"
logger.info("API Token (%s): %s — auth Bearer obligatoire", _token_source, _token_hint)
print(f"\n{'='*60}")
print(f" API Token ({_token_source}):")
print(f" {API_TOKEN}")
print(f" Configurer l'agent : export RPA_API_TOKEN={API_TOKEN}")
print(f" {_token_hint} (masqué)")
print(" Configurer l'agent via .env.local ou l'enrollment; ne pas copier depuis les logs.")
print(f"{'='*60}\n")
worker.start(blocking=False)
@@ -1807,6 +1928,11 @@ async def stream_event(data: StreamEvent):
# Auto-enregistrer la session si inconnue (robustesse au redémarrage serveur)
_ensure_session_registered(session_id, machine_id=machine_id)
# ── Assainissement PII : sanitize une fois, les 3 chemins reçoivent la copie ──
sanitized_event = sanitize_event(
data.event, mapping=_session_pii_mapping[session_id]
)
# Persister sur disque (journal JSONL, dans un sous-dossier par machine si multi-machine)
if machine_id and machine_id != "default":
session_path = LIVE_SESSIONS_DIR / machine_id / session_id
@@ -1815,21 +1941,26 @@ async def stream_event(data: StreamEvent):
session_path.mkdir(parents=True, exist_ok=True)
event_file = session_path / "live_events.jsonl"
with open(event_file, "a", encoding="utf-8") as f:
f.write(json.dumps(data.dict()) + "\n")
f.write(json.dumps({
"session_id": data.session_id,
"timestamp": data.timestamp,
"event": sanitized_event,
"machine_id": machine_id,
}) + "\n")
# Traitement direct via StreamProcessor
result = worker.process_event_direct(session_id, data.event)
result = worker.process_event_direct(session_id, sanitized_event)
# ── Observation Shadow (si mode Shadow activé pour cette session) ──
# L'appel est protégé et non bloquant : si l'observer n'est pas
# actif, ou s'il lève, la capture continue normalement.
shadow_observe_event(session_id, data.event)
shadow_observe_event(session_id, sanitized_event)
# ── Enrichissement SomEngine temps réel pour les mouse_click ──
# Après l'enregistrement de l'event, tenter l'enrichissement si le
# screenshot est déjà arrivé. Sinon, l'event est mis en attente et
# sera enrichi quand le screenshot arrivera (voir stream_image).
event = data.event
event = sanitized_event
if event.get("type") == "mouse_click" and event.get("screenshot_id"):
session = processor.session_manager.get_session(session_id)
if session:
@@ -1847,6 +1978,9 @@ async def stream_event(data: StreamEvent):
# =========================================================================
# Ensemble des screenshots déjà analysés (évite les doublons de retry)
# Mapping PII par session — tokens cohérents intra-session (même patient → même [NOM_1])
_session_pii_mapping: Dict[str, Dict] = defaultdict(dict)
_analyzed_shots: Dict[str, set] = defaultdict(set)
# Hash du dernier screenshot analysé par session (déduplication par similarité)
@@ -2243,9 +2377,12 @@ async def stream_image(
# Le fichier brut (shot_XXXX_full.png) reste intact pour le replay,
# le grounding VLM et l'entraînement. La version floutée est écrite en
# parallèle sous shot_XXXX_full_blurred.png.
# focus_* : plein écran avec PII dans les titres (blind spot Qwen 28/06,
# 1440 fichiers/350 Mo non floutés) — désormais inclus dans le blur.
if _PII_BLUR_ENABLED and _blur_pii_on_image is not None and (
("_full" in shot_id and shot_id.startswith("shot_"))
or shot_id.startswith("heartbeat_")
or shot_id.startswith("focus_")
):
_pii_blur_executor.submit(_produce_blurred_version, file_path_str, shot_id)
@@ -4198,6 +4335,9 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
if runtime_vars:
action = _resolve_runtime_vars(action, runtime_vars)
# Coercion coords: cast x_pct/y_pct en float après resolver
action = _coerce_action_coords(action)
type_ = action.get("type")
# pause_for_human : pause supervisée si safety_level/safety_checks ou mode supervised,
@@ -4311,6 +4451,24 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
),
timeout=180,
)
elif type_ == "extract_dossier":
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_extract_dossier_action,
action, owning_replay, session_id,
),
timeout=180,
)
elif type_ == "navigate":
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_navigate_action,
action, owning_replay, session_id,
),
timeout=180,
)
elif type_ == "t2a_decision":
await asyncio.wait_for(
loop.run_in_executor(
@@ -7106,6 +7264,62 @@ async def agents_fleet():
}
@app.post("/api/v1/agents/logs")
async def agents_logs(request: AgentLogsRequest):
"""Réception des logs poussés par un client Léa (push-log-DGX).
Range les logs par machine_id (AgentLogsStore) pour consultation au
dashboard — diagnostic des postes sans AnyDesk. Mêmes garde-fous fleet
que stream/poll : un poste révoqué/inconnu est refusé (403).
"""
machine_id = (request.machine_id or "").strip()
if not machine_id:
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
if len(request.logs) > _AGENT_LOGS_MAX_BATCH:
raise HTTPException(
status_code=413,
detail={
"error": "batch_too_large",
"max_batch": _AGENT_LOGS_MAX_BATCH,
"received": len(request.logs),
},
)
# Bloque les postes révoqués/désinstallés + met à jour last_seen_at.
_guard_agent_registry_access(machine_id, endpoint="agents/logs")
# Assainissement PII côté serveur avant persistance (couche 1 regex, sans NER).
# Un mapping partagé sur le batch garantit la cohérence des tokens ([NOM_1]…).
safe_logs = sanitize_log_entries(request.logs)
received = agent_logs_store.append(machine_id, safe_logs)
return {"status": "ok", "received": received, "machine_id": machine_id}
@app.get("/api/v1/agents/logs/{machine_id}")
async def get_agents_logs(machine_id: str, limit: int = 1000):
"""Lecture des logs poussés par un poste (push-log-DGX, brique 3).
Route de diagnostic dashboard : restitue les logs rangés par machine_id
(poste sans AnyDesk). Lecture admin read-only — volontairement SANS garde
fleet : on doit pouvoir consulter un poste révoqué ou en panne. Seul le
Bearer (dépendance globale `_verify_token`) protège l'accès.
`limit` borne la réponse aux N entrées les plus récentes (tail) pour éviter
de renvoyer plusieurs jours de logs d'un coup.
"""
entries = agent_logs_store.read(machine_id)
total = len(entries)
if limit and limit > 0:
entries = entries[-limit:]
return {
"machine_id": machine_id,
"total": total,
"count": len(entries),
"logs": entries,
}
# =========================================================================
# R2 MVP P0 — DialogResolver (catalogue centralisé des modaux runtime)
# Flag OFF par défaut. Activer en posant RPA_DIALOG_RESOLVER_ENABLED=true.
@@ -7642,6 +7856,81 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request):
return payload_out
# =========================================================================
# DETTE-022 v2 — GET /api/v1/agents/update/check (MAJ silencieuse client Léa)
# Flag OFF par défaut (RPA_AUTO_UPDATE_SERVER_ENABLED). Best-effort, additif :
# expose la DÉCISION d'update (logique PURE dans update_check.py, testée hors
# serveur — DETTE-013). NE FAIT PAS le swap (réservé révision humaine côté
# client + Lea.bat).
# =========================================================================
from .update_check import decide_update as _decide_update # noqa: E402
from .update_policy import ( # noqa: E402
resolve_target_version_from_env as _resolve_target_version_from_env,
)
def _auto_update_server_enabled() -> bool:
"""Flag d'activation serveur — lu à chaque appel (faciliter les tests)."""
return os.environ.get("RPA_AUTO_UPDATE_SERVER_ENABLED", "").lower() in (
"1", "true", "yes", "on",
)
def _latest_agent_version(machine_id: Optional[str] = None) -> str:
"""Version d'agent cible POUR CE POSTE (canary-aware, DETTE-022 v2).
⭐ SÉCURITÉ flotte ⭐ — la version servie est résolue PAR MACHINE via la
politique canary (`update_policy.resolve_target_version_from_env`) : un
poste canary (Émilie `lea-4zbgwxty`) reçoit la nouvelle version en premier ;
tous les autres restent sur le floor stable. Piloté 100 % par env, sans
rebuild :
RPA_AGENT_STABLE_VERSION (défaut 1.0.1) — servi à toute la flotte.
RPA_AGENT_CANARY_VERSION — servi AUX SEULS postes canary.
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
Rétrocompat : si `RPA_AGENT_LATEST_VERSION` (ancienne var globale) est
positionnée, elle prime — évite toute régression d'un déploiement existant.
"""
legacy = os.environ.get("RPA_AGENT_LATEST_VERSION")
if legacy:
return legacy
return _resolve_target_version_from_env(machine_id)
@app.get("/api/v1/agents/update/check")
async def check_agent_update(
current_version: str,
machine_id: Optional[str] = None,
update_type: Optional[str] = None,
):
"""Indiquer au client Léa si une MAJ est disponible (DETTE-022 v2).
Réponse : {update_available, latest_version, update_type, url}.
La version cible est résolue PAR MACHINE (canary) : voir
`_latest_agent_version`. Un poste hors canary ne se voit JAMAIS proposer la
version canary (blast radius borné à la liste canary).
GATED : si RPA_AUTO_UPDATE_SERVER_ENABLED n'est pas positionné → 503
(aucun effet sur le pipeline existant — anti-régression). Auth Bearer
requise (dépendance globale `_verify_token`).
"""
if not _auto_update_server_enabled():
raise HTTPException(
status_code=503,
detail=(
"MAJ auto désactivée (flag RPA_AUTO_UPDATE_SERVER_ENABLED). "
"DETTE-022 : endpoint exposé mais OFF par défaut."
),
)
return _decide_update(
current_version=current_version,
latest_version=_latest_agent_version(machine_id),
update_type=update_type,
machine_id=machine_id,
)
if __name__ == "__main__":
import uvicorn

View File

@@ -0,0 +1,273 @@
"""Assainissement PII des données capturées (titres de fenêtre, texte saisi, OCR).
Côté serveur. Remplace la PII par des **tokens typés et cohérents**
(`[IPP_1]`, `[AGE_1]`, `[NOM_1]`…) : on protège la donnée **et** on garde la
structure (champ de type NOM/IPP) utile à l'apprentissage des variables.
Couche 1 (ce module, sans modèle) : filet **regex** sur la PII structurée
(IPP, NIR, téléphone, email, âge) + règles **structurelles** des titres
cliniques (`NOM (NAISSANCE) Prénom`, `[Nom Prénom]` des fenêtres PACS). Regex
réutilisées du projet `anonymisation`.
Couche 2 (à venir) : NER CamemBERT-bio (ONNX) pour les noms libres que la
couche 1 ne capte pas — branchée plus tard, ce module marche sans.
Branche feat/push-log-dgx — assainissement PII clinique.
"""
from __future__ import annotations
import copy
import re
from typing import Dict, List, Optional, Tuple
# --- Filet regex (réutilisé de anonymisation/anonymizer_core_refactored_onnx.py) ---
RE_IPP = re.compile(r"\b(?:I\.?P\.?P\.?|IPP|N°\s*Ipp)\s*[:\-]?\s*([A-Za-z0-9]{6,})\b", re.IGNORECASE)
RE_NIR = re.compile(r"(?<!\d)[12]\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{3}\s?\d{3}\s?\d{2}(?!\d)")
RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
RE_TEL = re.compile(r"(?<!\d)(?:\+33\s?|0)\d(?:[ .\-]?\d){8}(?!\d)")
# Âge format « titre » (« 90 ans »), plus large que le regex prose de anonymisation.
RE_AGE = re.compile(r"\b(\d{1,3})\s*ans\b", re.IGNORECASE)
_MAJ = r"A-ZÉÈÀÂÊÎÔÛÄËÏÖÜÇ"
_MIN = r"a-zàâäéèêëïîôöùûüç"
# Format clinique « NOM (NOM_NAISSANCE) Prénom » (ex. « ROSSIGNOL (SOUBIE) Pierrette »).
RE_NOM_NAISSANCE = re.compile(
rf"\b[{_MAJ}][{_MAJ}\-']+\s+\([{_MAJ}][{_MAJ}\-']+\)\s+[{_MAJ}][{_MIN}\-']+\b"
)
# Patient entre crochets des fenêtres PACS (ex. « [DATTIN Alix] »), ≥ 2 tokens capitalisés.
RE_NOM_BRACKET = re.compile(
rf"\[((?:[{_MAJ}][\w{_MIN}'\-]*\s+){{1,3}}[{_MAJ}][\w{_MIN}'\-]*)\]"
)
# « Prénom NOM » inversé, sans parenthèses ni crochets (ex. « Alix DATTIN »).
# 2e mot tout en MAJUSCULES → faible risque de FP (« Mozilla Firefox » ne matche pas).
RE_PRENOM_NOM = re.compile(rf"\b[{_MAJ}][{_MIN}]+\s+[{_MAJ}][{_MAJ}\-']+\b")
# GXD5 Diagnostics : numéro de dossier + nom patient tout-majuscules.
# Format réel : « GXD5 Diagnostics - 128008 - BENVENISTE MARIE-LAURENCE »
# Le numéro (128008) = ID dossier patient (PII). Le nom = PII.
# 2 groupes de capture : (1)=numéro, (2)=nom complet.
RE_GXD5_DIAG = re.compile(
rf"GXD5\s+Diagnostics\s*-\s*(\d+)\s*-\s*([{_MAJ}][{_MAJ}\-' ]+)"
)
# Ordre = priorité ; group = portion à remplacer (0 = match entier).
_DETECTORS: List[Tuple[re.Pattern, str, int]] = [
(RE_NOM_NAISSANCE, "NOM", 0),
(RE_NOM_BRACKET, "NOM", 0),
(RE_GXD5_DIAG, "DOSSIER", 1), # numéro de dossier
(RE_PRENOM_NOM, "NOM", 0),
(RE_EMAIL, "EMAIL", 0),
(RE_NIR, "NIR", 0),
(RE_IPP, "IPP", 1),
(RE_TEL, "TEL", 0),
(RE_AGE, "AGE", 0),
]
# GXD5 nom (groupe 2) traité séparément — même regex, priorité juste après.
_DETECTORS.append((RE_GXD5_DIAG, "NOM", 2))
# Anti-faux-positifs : termes logiciels/UI à ne jamais prendre pour un nom.
# (Sous-ensemble inline ; les gazetteers complets arrivent avec la couche NER.)
_SOFTWARE_BLACKLIST = {
"FIREFOX", "MOZILLA", "CHROME", "EDGE", "EXPERT", "SANTE", "SANTÉ", "PACS",
"CIM", "ARES", "EASILY", "CONSULTATION", "URGENCES", "SAISIE", "COURRIER",
"DOSSIER", "PATIENT", "FENETRE", "FENÊTRE", "GXD", "WINDOWS", "CITRIX",
}
def _normalize(etype: str, value: str) -> str:
"""Clé de cohérence : même entité -> même token."""
if etype in ("IPP", "NIR", "TEL"):
return re.sub(r"\s+", "", value)
if etype == "EMAIL":
return value.lower()
return re.sub(r"\s+", " ", value).strip().upper()
def _is_blacklisted_name(value: str) -> bool:
toks = [t for t in re.split(r"[^\wÀ-ÿ]+", value) if t]
return bool(toks) and all(t.upper() in _SOFTWARE_BLACKLIST for t in toks)
def _assign_token(mapping: Dict, etype: str, norm: str) -> str:
key = (etype, norm)
if key in mapping:
return mapping[key]
n = 1 + sum(1 for k in mapping if isinstance(k, tuple) and k[0] == etype)
token = f"[{etype}_{n}]"
mapping[key] = token
return token
def anonymize_text(
text: str, *, mapping: Optional[Dict] = None
) -> Tuple[str, List[Dict]]:
"""Remplace la PII de `text` par des tokens typés cohérents.
`mapping` : table de cohérence partagée (ex. à l'échelle d'une session) —
la même valeur PII reçoit le même token d'un appel à l'autre. Mutée en place ;
si None, une table locale est utilisée.
Retourne `(texte_assaini, entités)` où chaque entité =
`{"type", "original", "token", "start", "end"}` (positions dans le texte source).
"""
if not text:
return text, []
if mapping is None:
mapping = {}
# 1) collecte des candidats (start, end, type, valeur)
spans: List[Tuple[int, int, str, str]] = []
for pattern, etype, group in _DETECTORS:
for m in pattern.finditer(text):
start, end = m.span(group)
if start == end:
continue
value = m.group(group)
if etype == "NOM" and _is_blacklisted_name(value):
continue
spans.append((start, end, etype, value))
# 2) résolution des chevauchements (priorité = rang détecteur, puis -longueur)
# _DETECTORS est ordonné par priorité ; le rang dans cette liste détermine
# qui gagne quand deux patterns chevauchent. Plus prioritaire + plus long
# = accepté en premier, les plus courts/moins prioritaires sont éliminés.
# Fix FN « Dossier VIOLA (VIOLA) Liliane » : RE_PRENOM_NOM captait
# « Dossier VIOLA » (rang 2) et bloquait RE_NOM_NAISSANCE « VIOLA (VIOLA)
# Liliane » (rang 0, plus prioritaire et plus long).
det_rank = {p: i for i, (p, _, _) in enumerate(_DETECTORS)}
spans.sort(key=lambda s: (det_rank.get(s[2], 999), -(s[1] - s[0]), s[0]))
occupied: List[Tuple[int, int]] = []
accepted: List[Tuple[int, int, str, str]] = []
for start, end, etype, value in spans:
if all(start >= oe or end <= os for os, oe in occupied):
accepted.append((start, end, etype, value))
occupied.append((start, end))
# 3) substitution (de droite à gauche pour préserver les indices)
entities: List[Dict] = []
out = text
for start, end, etype, value in sorted(accepted, key=lambda s: s[0], reverse=True):
token = _assign_token(mapping, etype, _normalize(etype, value))
out = out[:start] + token + out[end:]
entities.append(
{"type": etype, "original": value, "token": token, "start": start, "end": end}
)
entities.reverse()
return out, entities
# Clés portant un titre de fenêtre, où qu'elles soient imbriquées dans l'event
# (top-level `active_window_title`, `window/to/from.title`, et surtout
# `vision_info.window_capture.window_title` — blind spot signalé par Qwen).
_TITLE_KEYS = ("title", "window_title", "active_window_title")
_PLACEHOLDER_SAISIE = "[SAISIE]"
def _walk_titles(obj, mapping: Dict) -> None:
"""Parcourt récursivement l'event et assainit toute valeur de titre de fenêtre."""
if isinstance(obj, dict):
for k, v in obj.items():
if k in _TITLE_KEYS and isinstance(v, str):
obj[k] = anonymize_text(v, mapping=mapping)[0]
else:
_walk_titles(v, mapping)
elif isinstance(obj, list):
for item in obj:
_walk_titles(item, mapping)
def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict:
"""Assainit un event capturé avant persistance (copie, ne mute pas l'original).
Principe « Léa apprend l'interface, pas la donnée » (décision Dom 28/06) :
- `text_input` : le **contenu tapé** (`text`, `raw_keys`) = donnée de santé →
remplacé par `[SAISIE]` (on garde le champ, pas la valeur — option b) ;
- **titres de fenêtre** (`active_window_title`, et `title` dans `window`/`to`/
`from`) : l'**identité patient** est tokenisée, l'app/écran est gardé
(contexte d'apprentissage), via `anonymize_text` + `mapping` partagé (cohérence).
"""
if mapping is None:
mapping = {}
ev = copy.deepcopy(event)
# text_input : on ne garde pas le contenu
if ev.get("type") == "text_input":
for k in ("text", "raw_keys"):
if ev.get(k) not in (None, ""):
ev[k] = _PLACEHOLDER_SAISIE
# tous les titres de fenêtre, où qu'ils soient imbriqués
# (active_window_title, window/to/from.title, vision_info.window_capture.window_title…)
_walk_titles(ev, mapping)
return ev
def sanitize_log_entries(
entries: List[Dict], *, mapping: Optional[Dict] = None
) -> List[Dict]:
"""Assainit un batch de log-entries reçues d'un client Léa avant persistance.
Pour chaque entrée, renvoie une **copie** où les champs texte porteurs de PII
sont passés par `anonymize_text` :
- `message` (str) : assaini par `anonymize_text`.
- `logger` (str) : assaini de la même façon (peut porter un chemin patient).
- `ts` et `level` : préservés à l'identique, jamais touchés.
Un `mapping` partagé est utilisé pour **toutes** les entrées du batch afin de
garantir la cohérence des tokens (même PII → même token). Si `mapping` est
None, un mapping local est créé et partagé entre toutes les entrées du batch.
Tolère les valeurs absentes, None ou non-str sans lever d'exception.
N'utilise que `anonymize_text` — aucune regex supplémentaire.
"""
if not entries:
return []
if mapping is None:
mapping = {}
result: List[Dict] = []
for entry in entries:
item = copy.copy(entry) # copie superficielle suffit (valeurs scalaires)
for field in ("message", "logger"):
v = item.get(field)
if isinstance(v, str):
item[field] = anonymize_text(v, mapping=mapping)[0]
result.append(item)
return result
# Clés d'un workflow core portant du texte potentiellement PII : cible OCR
# (`by_text`), noms d'écrans/labels dérivés des titres. Le contenu saisi est
# déjà neutralisé à la source (sanitize_event → [SAISIE]).
_WORKFLOW_TEXT_KEYS = ("by_text", "name", "label")
def _walk_workflow_text(obj, mapping: Dict) -> None:
"""Parcourt un workflow core et tokenise la PII des champs texte (cibles, noms)."""
if isinstance(obj, dict):
for k, v in obj.items():
if k in _WORKFLOW_TEXT_KEYS and isinstance(v, str) and v:
obj[k] = anonymize_text(v, mapping=mapping)[0]
else:
_walk_workflow_text(v, mapping)
elif isinstance(obj, list):
for item in obj:
_walk_workflow_text(item, mapping)
def sanitize_workflow_dict(workflow_dict: Dict, *, mapping: Optional[Dict] = None) -> Dict:
"""Assainit un workflow core (JSON appris) avant import/persistance en DB VWB.
Tokenise la PII des champs texte (cible OCR `by_text`, noms d'écrans, labels)
via `anonymize_text`, en gardant l'interface intacte (« Léa apprend
l'interface, pas la donnée »). Copie — l'original n'est pas muté.
Limite (couche 1) : ne capte que la PII structurée (IPP, NOM clinique…) ;
les noms libres relèvent de la couche 2 NER.
"""
if mapping is None:
mapping = {}
wf = copy.deepcopy(workflow_dict)
_walk_workflow_text(wf, mapping)
return wf

View File

@@ -40,6 +40,8 @@ _ALLOWED_ACTION_TYPES = {
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
"extract_table", # OCR serveur + filtre regex → liste structurée (boucle)
"extract_dossier", # OCR grille structurée → dossier patient persisté (brique 3)
"navigate", # Navigation visuelle → coords login/recherche (brique navigation)
"extract_text_scroll", # Marker côté graphe — expansé en sous-actions par _edge_to_normalized_actions
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
@@ -53,6 +55,8 @@ _ALLOWED_ACTION_TYPES = {
_SERVER_SIDE_ACTION_TYPES = {
"extract_text",
"extract_table",
"extract_dossier",
"navigate",
"t2a_decision",
"llm_generate",
"_concat_text_vars",
@@ -1944,6 +1948,21 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
normalized["parameters"]["temperature"] = action_params.get("temperature")
return [normalized]
elif action_type == "navigate":
normalized["type"] = "navigate"
normalized["parameters"] = {
"action": action_params.get("action", "login"),
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
}
login_config_keys = ("login_field", "password_field", "submit_button",
"success_elements", "context")
for key in login_config_keys:
if action_params.get(key) is not None:
normalized["parameters"][key] = action_params[key]
return [normalized]
else:
logger.warning(f"Type d'action inconnu : {action_type}")
return []
@@ -2041,6 +2060,38 @@ def _resolve_runtime_vars(value: Any, variables: Dict[str, Any]) -> Any:
return value
def _coerce_action_coords(action: dict) -> dict:
"""Cast x_pct/y_pct en float après template resolution par _resolve_runtime_vars.
Politique : si string non convertible ou template encore present → skip + pause_for_human.
Idempotent sur les actions qui ont déjà des floats (mouse_click existant).
Jamais fallback 0.0/0.0 — un clic sur coords (0,0) = top-left = potentiellement dangereux.
Appelé APRÈS _resolve_runtime_vars dans la boucle dispatch (api_stream.py).
"""
for key in ("x_pct", "y_pct"):
val = action.get(key)
if val is None:
continue
if isinstance(val, float):
continue # déjà float, idempotent
if isinstance(val, str):
# Template encore présent = non résolu par _resolve_runtime_vars
if val.startswith("{{") and val.endswith("}}"):
action["_skip_reason"] = f"coords_var non résolu: {key}={val}"
action["type"] = "pause_for_human"
action["safety_level"] = "high"
return action
try:
action[key] = float(val)
except (ValueError, TypeError):
action["_skip_reason"] = f"coords invalide: {key}={val}"
action["type"] = "pause_for_human"
action["safety_level"] = "high"
return action
return action
# =========================================================================
# Handlers pour les actions exécutées côté serveur (extract_text, t2a_decision)
# =========================================================================
@@ -2216,6 +2267,146 @@ def _handle_extract_table_action(
return bool(rows)
def _resolve_screenshot_path(replay_state: Dict[str, Any]) -> Optional[str]:
"""Résout le chemin du dernier screenshot (path disque ou base64 → temp).
Calque la source utilisée par extract_text/extract_table : priorité au
``last_screenshot`` (path ou data-URI base64). Retourne None si absent.
"""
raw_screenshot = replay_state.get("last_screenshot") or ""
if not raw_screenshot:
return None
if raw_screenshot.startswith("data:"):
try:
import base64 as _b64, tempfile
header, b64data = raw_screenshot.split(",", 1)
suffix = ".jpg" if "jpeg" in header else ".png"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.write(_b64.b64decode(b64data))
tmp.close()
return tmp.name
except Exception as e:
logger.warning("extract_dossier: décodage base64 screenshot échoué: %s", e)
return None
if os.path.isfile(raw_screenshot):
return raw_screenshot
return None
def _gate_dossier_quality(
grid: List[List[Dict[str, Any]]],
*,
min_confidence: float,
expected_cols: Optional[int],
) -> str:
"""Gate qualité simple → 'complete' ou 'needs_review'.
'complete' SSI : grille non vide ET confiance médiane ≥ seuil ET (si
expected_cols fourni) au moins une ligne avec ce nombre de colonnes.
Sinon 'needs_review'. Volontairement conservatrice (default-review).
"""
confs = [
cell.get("confidence")
for row in grid for cell in row
if isinstance(cell.get("confidence"), (int, float))
]
if not confs:
return "needs_review"
confs.sort()
median = confs[len(confs) // 2]
if median < min_confidence:
return "needs_review"
if expected_cols is not None:
if not any(len(row) == expected_cols for row in grid):
return "needs_review"
return "complete"
def _handle_extract_dossier_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
session_id: str,
) -> bool:
"""Traite une action extract_dossier côté serveur (brique 3).
Lit le dernier screenshot, extrait une grille structurée via
``extract_grid_from_image``, applique une gate qualité, puis PERSISTE un
« dossier patient extrait » (Job/Table/Field) dans la DB VWB avec preuve
(screenshot_ref + screen_bbox + confidences). Le job_id est stocké dans
``replay_state["variables"][output_var]``.
Paramètres reconnus (action.parameters) :
output_var : nom de variable runtime (default "extracted_dossier")
patient_ref : référence patient EN CLAIR (volontaire) — non tokenisée
region : (x, y, w, h) px pour cropper avant OCR (None = plein)
min_confidence : seuil de confiance médiane pour 'complete' (default 0.6)
expected_cols : nb de colonnes attendu (optionnel) pour la gate
N'ÉCHOUE JAMAIS le replay : toute erreur → log + needs_review.
Retourne True SSI le dossier est persisté avec statut 'complete'.
"""
params = action.get("parameters") or {}
output_var = (params.get("output_var") or params.get("variable_name") or "extracted_dossier").strip()
patient_ref = params.get("patient_ref")
region = params.get("region") or None
try:
min_confidence = float(params.get("min_confidence", 0.6))
except (TypeError, ValueError):
min_confidence = 0.6
expected_cols = params.get("expected_cols")
if isinstance(expected_cols, str):
try:
expected_cols = int(expected_cols)
except ValueError:
expected_cols = None
job_id = ""
status = "needs_review"
try:
path = _resolve_screenshot_path(replay_state)
grid: List[List[Dict[str, Any]]] = []
if path:
from core.llm import extract_grid_from_image
grid = extract_grid_from_image(
path, region=tuple(region) if region else None
)
else:
logger.warning(
"extract_dossier : pas de screenshot pour session %s — needs_review",
session_id,
)
status = _gate_dossier_quality(
grid, min_confidence=min_confidence, expected_cols=expected_cols
)
from . import vwb_db
with vwb_db.vwb_app_context():
job_id = vwb_db.persist_extracted_dossier(
grid,
patient_ref=patient_ref,
source_session_id=session_id,
screenshot_ref=path,
screen_bbox=({"x": region[0], "y": region[1], "width": region[2], "height": region[3]}
if region and len(region) == 4 else None),
status=status,
)
except Exception as e:
# Ne JAMAIS échouer le replay : on log, on marque needs_review.
logger.warning(
"extract_dossier : échec persistance (%s) — needs_review, replay %s",
e, replay_state.get("replay_id", "?"),
)
status = "needs_review"
replay_state.setdefault("variables", {})[output_var] = job_id
logger.info(
"extract_dossier → variable '%s' job=%s statut=%s replay %s",
output_var, job_id or "?", status, replay_state.get("replay_id", "?"),
)
return status == "complete"
def _handle_t2a_decision_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],

View File

@@ -3066,6 +3066,8 @@ class StreamProcessor:
saved_path = self._persist_workflow(workflow, session_id, machine_id=machine_id)
# Stocker le machine_id dans le workflow pour le filtrage
workflow._machine_id = machine_id
# R1 : import auto en DB VWB (rejouable) — gated RPA_R1_AUTO_IMPORT, non bloquant.
self._maybe_import_to_vwb(workflow, session_id, machine_id)
# Récupérer les métadonnées applicatives de la session
session_state = self.session_manager.get_session(session_id)
@@ -4444,6 +4446,45 @@ class StreamProcessor:
logger.error(f"Erreur sauvegarde workflow {session_id}: {e}")
return None
def _import_workflow_to_vwb(self, workflow, session_id: str, machine_id: str) -> Dict[str, Any]:
"""Importer le workflow appris dans la DB VWB rejouable (Maillon A / R1).
Rend l'appris rejouable sans geste manuel, de façon idempotente (fusion
par signature de trajectoire). Suppose un app-context VWB actif fournissant
``db.session`` (créé par l'appelant côté worker).
"""
from .pii_sanitizer import sanitize_workflow_dict
from services.learned_workflow_bridge import import_core_workflow_to_db
from db.models import db
# Assainir la PII (cibles OCR `by_text`, noms) avant dépôt en DB VWB.
core_dict = sanitize_workflow_dict(workflow.to_dict())
return import_core_workflow_to_db(
core_dict,
machine_id=machine_id,
source_session_id=session_id,
db_session=db.session,
)
def _vwb_app_context(self):
"""Couplage worker→DB VWB mutualisé (un seul pont, cf. vwb_db).
Délègue au helper module ``vwb_db.vwb_app_context`` partagé entre R1 et
l'extraction métier — pas de duplication de l'app Flask/init_app.
"""
from .vwb_db import vwb_app_context
return vwb_app_context()
def _maybe_import_to_vwb(self, workflow, session_id: str, machine_id: str) -> None:
"""Import auto de l'appris en DB VWB, gated par RPA_R1_AUTO_IMPORT (OFF
par défaut) et NON bloquant : un échec ne casse jamais la finalisation."""
if os.environ.get("RPA_R1_AUTO_IMPORT", "false").lower() not in ("true", "1", "yes"):
return
try:
with self._vwb_app_context():
self._import_workflow_to_vwb(workflow, session_id, machine_id)
except Exception as e:
logger.warning("[R1] import VWB auto échoué (non bloquant): %s", e)
def _build_raw_session_fallback(self, session, raw_dict):
"""Construire un RawSession manuellement si from_dict échoue."""
from core.models.raw_session import RawSession, Event, Screenshot, RawWindowContext

View File

@@ -0,0 +1,138 @@
# agent_v0/server_v1/update_check.py
"""Logique PURE de décision de mise à jour du client Léa (DETTE-022 v2).
But : centraliser, SANS dépendance FastAPI, le cœur testable de la MAJ
silencieuse :
- `parse_version()` (R3) : parse une version semver en tuple d'entiers, pour
une comparaison correcte ("1.0.2" < "1.0.10" — le piège lexicographique
classique). Tolérant : préfixe « v », espaces, et format invalide → fallback
`(0,)` (la plus basse) SANS jamais lever.
- `decide_update()` (R2) : compare la version courante à la dernière dispo,
choisit l'`update_type` (`code-only` par défaut, ~500 Ko / `full` ~33 Mo
rare) et construit la réponse
`{update_available, latest_version, update_type, url}`.
Ce module est volontairement IMPORTABLE seul (aucun import lourd, pas de
`api_stream`) pour être testé sans démarrer le serveur (DETTE-013). Le
branchement HTTP (endpoint gated) vit dans `api_stream.py`.
⚠️ Cette brique ne fait QUE décider. Le swap réel des fichiers, l'édition de
Lea.bat et le redémarrage sont HORS de ce module (réservé révision humaine).
Branche feat/push-log-dgx.
"""
from __future__ import annotations
from typing import Optional, Tuple
# Niveaux de livraison valides (R2). `code-only` par défaut = 99 % des MAJ.
VALID_UPDATE_TYPES = ("code-only", "full")
DEFAULT_UPDATE_TYPE = "code-only"
# Fallback de version « la plus basse » pour une chaîne illisible : ainsi une
# version valide est toujours > à une version invalide, et une *latest* illisible
# ne déclenche jamais de MAJ douteuse.
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
def parse_version(v) -> Tuple[int, ...]:
"""Parse une version semver en tuple d'entiers (R3).
"1.0.2" → (1, 0, 2), "1.0.10" → (1, 0, 10), "v1.2.3" → (1, 2, 3).
Tolérant et SANS exception : préfixe « v/V » et espaces tolérés ; tout
format non numérique (vide, None, "abc", "1.x.3") retombe sur `(0,)`.
Stratégie : `packaging.version` si présent (déjà dans le venv via
setuptools/pip), sinon parse manuel. Aucune nouvelle dépendance.
"""
if not isinstance(v, str):
return _FALLBACK_VERSION
s = v.strip().lstrip("vV").strip()
if not s:
return _FALLBACK_VERSION
try:
from packaging.version import Version
return tuple(Version(s).release)
except Exception:
# packaging absent (python-embed minimal) OU version non-PEP440.
pass
try:
return tuple(int(x) for x in s.split("."))
except (ValueError, AttributeError):
return _FALLBACK_VERSION
def is_newer(candidate: str, baseline: str) -> bool:
"""True si `candidate` est strictement plus récent que `baseline` (semver)."""
return parse_version(candidate) > parse_version(baseline)
def _normalize_update_type(update_type: Optional[str]) -> str:
"""Normalise l'update_type sur un niveau valide (défaut code-only)."""
if update_type in VALID_UPDATE_TYPES:
return update_type
return DEFAULT_UPDATE_TYPE
def build_download_url(
machine_id: Optional[str],
version: str,
update_type: str,
) -> str:
"""Construit l'URL de téléchargement RELATIVE (R2, 2 niveaux).
Forme alignée sur les endpoints fleet existants :
/api/fleet/download/<machine_id>?type=<update_type>&version=<version>
On garde une URL relative : le client la résout contre son SERVER_BASE.
`machine_id` absent → segment « default » (rétrocompatible).
"""
mid = (machine_id or "default").strip() or "default"
return f"/api/fleet/download/{mid}?type={update_type}&version={version}"
def decide_update(
current_version: str,
latest_version: str,
update_type: Optional[str] = None,
machine_id: Optional[str] = None,
) -> dict:
"""Décision PURE de mise à jour (R2 + R3).
Compare `current_version` à `latest_version` en semver. Si la dernière est
strictement plus récente, construit une réponse d'update ; sinon réponse
« à jour ». Aucune exception : versions illisibles → pas de MAJ (prudence).
Returns:
{
"update_available": bool,
"latest_version": str,
"update_type": "code-only" | "full" | None, # None si pas de MAJ
"url": str | None, # None si pas de MAJ
}
"""
no_update = {
"update_available": False,
"latest_version": latest_version,
"update_type": None,
"url": None,
}
# latest illisible → on ne propose RIEN (pas de MAJ douteuse).
if parse_version(latest_version) == _FALLBACK_VERSION:
return no_update
if not is_newer(latest_version, current_version):
return no_update
chosen_type = _normalize_update_type(update_type)
return {
"update_available": True,
"latest_version": latest_version,
"update_type": chosen_type,
"url": build_download_url(machine_id, latest_version, chosen_type),
}

View File

@@ -0,0 +1,139 @@
# agent_v0/server_v1/update_policy.py
"""Politique de déploiement CANARY de la MAJ silencieuse Léa (DETTE-022 v2).
⭐ Brique de SÉCURITÉ centrale ⭐ — 10+ postes cliniques live (Wallerstein).
Une MAJ ratée peut briquer toute la flotte. La règle non négociable : on ne
pousse JAMAIS une nouvelle version sur tous les postes d'un coup. On la déploie
d'abord sur UN poste (canary = Émilie `lea-4zbgwxty`), on vérifie, puis on
élargit. Ce module résout, PAR MACHINE, la version cible :
- poste dans la liste canary → `canary_version` (la nouvelle) ;
- tous les autres postes → `stable_version` (le floor, inchangé).
Piloté 100 % par variables d'environnement (config serveur, sans rebuild) :
RPA_AGENT_STABLE_VERSION — version servie à toute la flotte (défaut floor).
RPA_AGENT_CANARY_VERSION — version servie AUX SEULS postes canary (optionnel).
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
Promotion = quand le canary est validé, on met RPA_AGENT_STABLE_VERSION à la
version canary (toute la flotte suit) et on vide RPA_AGENT_CANARY_MACHINES.
Rollback canary = on remet RPA_AGENT_CANARY_VERSION à l'ancienne / on vide la
liste : le prochain check ne proposera plus la MAJ (le swap réel côté client
reste réservé révision humaine — cf. updater.py).
Module PUR (aucun import FastAPI, aucune IO) → importable et testable seul
(DETTE-013). Le branchement HTTP vit dans api_stream.py.
Branche feat/push-log-dgx.
"""
from __future__ import annotations
import os
from typing import Optional, Set
# Réutilise le comparateur semver de la décision (même module serveur, pas de
# duplication) : "1.0.2" < "1.0.10" correctement, tolérant aux formats invalides.
try: # import relatif quand chargé comme package
from .update_check import is_newer
except Exception: # chargé par chemin (tests importlib) : import du voisin
import importlib.util as _ilu
from pathlib import Path as _Path
_uc_path = _Path(__file__).resolve().parent / "update_check.py"
_spec = _ilu.spec_from_file_location("_rpa_update_check_for_policy", _uc_path)
_uc = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_uc)
is_newer = _uc.is_newer
# Séparateurs tolérés dans l'allow-list canary (CSV, espaces, point-virgule).
_CANARY_SEPARATORS = (",", ";")
def parse_canary_machines(raw: Optional[str]) -> Set[str]:
"""Parse l'allow-list canary en un ensemble de machine_id.
Tolérant : virgule / point-virgule / espace comme séparateurs, entrées
vides ignorées. `None` ou chaîne vide → ensemble vide (aucun canary).
"""
if not raw or not isinstance(raw, str):
return set()
normalized = raw
for sep in _CANARY_SEPARATORS:
normalized = normalized.replace(sep, " ")
return {tok for tok in (t.strip() for t in normalized.split()) if tok}
def resolve_target_version(
machine_id: Optional[str],
stable_version: str,
canary_version: Optional[str],
canary_machines: Set[str],
) -> str:
"""Résout la version cible POUR CE POSTE (cœur canary — sécurité).
Règles (toutes prudentes par défaut) :
1. Poste HORS liste canary → `stable_version` (jamais la nouvelle).
2. machine_id absent / liste vide / pas de canary_version → `stable_version`.
3. Poste DANS la liste canary ET `canary_version` fournie ET STRICTEMENT
plus récente que stable → `canary_version`.
4. Garde-fou : si `canary_version` <= `stable_version` (config douteuse,
ex. downgrade), on sert quand même `stable_version` (jamais de recul).
Ne lève jamais. Une version illisible retombe naturellement sur le stable
via le comparateur semver tolérant.
"""
# Cas 1/2 : hors canary → stable.
if not machine_id or machine_id not in canary_machines:
return stable_version
if not canary_version:
return stable_version
# Cas 4 : garde-fou anti-recul — le canary doit être STRICTEMENT plus récent.
if not is_newer(canary_version, stable_version):
return stable_version
# Cas 3 : poste canary → nouvelle version.
return canary_version
# ---------------------------------------------------------------------------
# Lecture de la politique depuis l'environnement (pilotage sans rebuild).
# ---------------------------------------------------------------------------
# Défaut historique aligné sur AGENT_VERSION client (config.py) et sur le
# fallback de _latest_agent_version().
_DEFAULT_STABLE_VERSION = "1.0.1"
def stable_version_from_env() -> str:
"""Version servie à toute la flotte (floor). Défaut = 1.0.1."""
return os.environ.get("RPA_AGENT_STABLE_VERSION", _DEFAULT_STABLE_VERSION)
def canary_version_from_env() -> Optional[str]:
"""Version canary (nouvelle), servie aux seuls postes canary. Optionnel."""
val = os.environ.get("RPA_AGENT_CANARY_VERSION", "").strip()
return val or None
def canary_machines_from_env() -> Set[str]:
"""Allow-list canary (machine_id) depuis RPA_AGENT_CANARY_MACHINES."""
return parse_canary_machines(os.environ.get("RPA_AGENT_CANARY_MACHINES", ""))
def resolve_target_version_from_env(machine_id: Optional[str]) -> str:
"""Raccourci : résout la version cible pour `machine_id` d'après l'env.
C'est le point d'entrée que l'endpoint serveur appelle. Il isole toute la
lecture d'environnement ici (testable en injectant les paramètres via
`resolve_target_version`).
"""
return resolve_target_version(
machine_id=machine_id,
stable_version=stable_version_from_env(),
canary_version=canary_version_from_env(),
canary_machines=canary_machines_from_env(),
)

View File

@@ -0,0 +1,106 @@
"""Couplage worker → DB VWB (mutualisé) + persistance « dossier patient extrait ».
Le worker/serveur streaming est un process distinct du backend VWB : il n'a
pas d'app Flask en mémoire. Ce module fournit :
- ``vwb_app_context()`` : un app-context Flask lazy (singleton module) lié au
fichier SQLite VWB ``visual_workflow_builder/backend/instance/workflows.db``,
avec ``db.init_app`` (db de ``db.models``). Réutilisable par tout module
serveur qui doit écrire dans la DB VWB (R1, extraction métier, …).
- ``persist_extracted_dossier(...)`` : depuis une grille OCR
(``List[List[cell]]``), crée ExtractionJob → ExtractedTable → ExtractedField
et commit. Suppose un app-context actif (comme le pont R1 existant).
⚠️ CANAL EXTRACTION = données patient EN CLAIR (volontaire) : aucune
tokenisation/assainissement PII ici (cf. note dans db/models.py).
"""
import sys
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, List, Optional
# Ajout du backend VWB au sys.path à l'import → rend ``db.models`` importable
# (couplage worker→DB VWB mutualisé ; identique au pattern stream_processor).
_VWB_BACKEND = Path(__file__).resolve().parents[2] / "visual_workflow_builder" / "backend"
if str(_VWB_BACKEND) not in sys.path:
sys.path.insert(0, str(_VWB_BACKEND))
# App Flask lazy (singleton module) — un seul db.init_app pour tout le process.
_vwb_app = None
@contextmanager
def vwb_app_context():
"""App-context Flask VWB (lazy singleton) sur instance/workflows.db.
À utiliser via ``with vwb_app_context(): ...`` autour des appels qui
nécessitent ``db.session`` (ex. persist_extracted_dossier).
"""
global _vwb_app
if _vwb_app is None:
from flask import Flask
from db.models import db
db_path = _VWB_BACKEND / "instance" / "workflows.db"
app = Flask("worker_vwb")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
_vwb_app = app
with _vwb_app.app_context():
yield
def persist_extracted_dossier(
grid: List[List[Dict[str, Any]]],
*,
patient_ref: Optional[str],
source_session_id: Optional[str],
screenshot_ref: Optional[str],
screen_bbox: Optional[Dict[str, Any]],
status: str,
) -> str:
"""Persiste un « dossier patient extrait » et retourne le job_id.
Crée 1 ExtractionJob → 1 ExtractedTable → N ExtractedField (une par
cellule de la grille), puis commit. Suppose un app-context VWB actif
(fourni par ``vwb_app_context()`` ou par l'appelant, comme le pont R1).
⚠️ ``patient_ref`` et ``cell["text"]`` sont stockés EN CLAIR (volontaire) :
le but est de constituer le dossier, pas d'anonymiser.
"""
from db.models import db, ExtractionJob, ExtractedTable, ExtractedField
job = ExtractionJob(
id=uuid.uuid4().hex,
patient_ref=patient_ref,
source_session_id=source_session_id,
status=status,
)
db.session.add(job)
table = ExtractedTable(
id=uuid.uuid4().hex,
job_id=job.id,
screen_bbox=screen_bbox,
screenshot_ref=screenshot_ref,
)
db.session.add(table)
for row in grid or []:
for cell in row or []:
db.session.add(ExtractedField(
id=uuid.uuid4().hex,
table_id=table.id,
row=cell.get("row"),
col=cell.get("col"),
value=cell.get("text"),
bbox=cell.get("bbox"),
confidence=cell.get("confidence"),
))
db.session.commit()
return job.id

View File

@@ -0,0 +1,21 @@
{"case_id": "easily_rec_shot_0001_72_538", "screenshot_path": "/tmp/easily_session/shots/shot_0001_full.png", "task": {"intent": "cliquer sur « 25003362 »", "target_text": "25003362", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003362 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.3362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003362", "ocr_dist": 0.0067, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0002_380_919", "screenshot_path": "/tmp/easily_session/shots/shot_0002_full.png", "task": {"intent": "cliquer sur « iméicamentset-substancs »", "target_text": "iméicamentset-substancs", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « iméicamentset-substancs » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1484, "y_pct": 0.5744, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "iméicamentset-substancs", "ocr_dist": 0.0107, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0003_388_380", "screenshot_path": "/tmp/easily_session/shots/shot_0003_full.png", "task": {"intent": "cliquer sur « cliniques »", "target_text": "cliniques", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « cliniques » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1516, "y_pct": 0.2375, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "cliniques", "ocr_dist": 0.0075, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0004_552_381", "screenshot_path": "/tmp/easily_session/shots/shot_0004_full.png", "task": {"intent": "cliquer sur « Imagerie »", "target_text": "Imagerie", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Imagerie » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2156, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Imagerie", "ocr_dist": 0.009, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0005_685_385", "screenshot_path": "/tmp/easily_session/shots/shot_0005_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2676, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0127, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0007_947_381", "screenshot_path": "/tmp/easily_session/shots/shot_0007_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3699, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0057, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0011_72_288", "screenshot_path": "/tmp/easily_session/shots/shot_0011_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.18, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0109, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0012_95_613", "screenshot_path": "/tmp/easily_session/shots/shot_0012_full.png", "task": {"intent": "cliquer sur « 25003451 »", "target_text": "25003451", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003451 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0371, "y_pct": 0.3831, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003451", "ocr_dist": 0.0111, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0014_910_378", "screenshot_path": "/tmp/easily_session/shots/shot_0014_full.png", "task": {"intent": "cliquer sur « Synthèse »", "target_text": "Synthèse", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Synthèse » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3555, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Synthèse", "ocr_dist": 0.0172, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0015_638_381", "screenshot_path": "/tmp/easily_session/shots/shot_0015_full.png", "task": {"intent": "cliquer sur « Notes »", "target_text": "Notes", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Notes » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2492, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Notes", "ocr_dist": 0.0051, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0016_970_393", "screenshot_path": "/tmp/easily_session/shots/shot_0016_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3789, "y_pct": 0.2456, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.008, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0020_695_379", "screenshot_path": "/tmp/easily_session/shots/shot_0020_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2715, "y_pct": 0.2369, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0057, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0021_127_395", "screenshot_path": "/tmp/easily_session/shots/shot_0021_full.png", "task": {"intent": "cliquer sur « d'admission »", "target_text": "d'admission", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « d'admission » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0496, "y_pct": 0.2469, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "d'admission", "ocr_dist": 0.0089, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0022_73_304", "screenshot_path": "/tmp/easily_session/shots/shot_0022_full.png", "task": {"intent": "cliquer sur « IPP: »", "target_text": "IPP:", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP: » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "IPP:", "ocr_dist": 0.0147, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0024_84_269", "screenshot_path": "/tmp/easily_session/shots/shot_0024_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0328, "y_pct": 0.1681, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0018, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0025_67_790", "screenshot_path": "/tmp/easily_session/shots/shot_0025_full.png", "task": {"intent": "cliquer sur « 25012257 »", "target_text": "25012257", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25012257 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0262, "y_pct": 0.4938, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25012257", "ocr_dist": 0.0053, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0028_770_385", "screenshot_path": "/tmp/easily_session/shots/shot_0028_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3008, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0166, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0029_766_378", "screenshot_path": "/tmp/easily_session/shots/shot_0029_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2992, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0196, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0032_954_375", "screenshot_path": "/tmp/easily_session/shots/shot_0032_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3727, "y_pct": 0.2344, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0095, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0037_2028_1290", "screenshot_path": "/tmp/easily_session/shots/shot_0037_full.png", "task": {"intent": "cliquer sur « terminé »", "target_text": "terminé", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « terminé » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7922, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "terminé", "ocr_dist": 0.0395, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0041_2010_1013", "screenshot_path": "/tmp/easily_session/shots/shot_0041_full.png", "task": {"intent": "cliquer sur « mémorisées »", "target_text": "mémorisées", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « mémorisées » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7852, "y_pct": 0.6331, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "mémorisées", "ocr_dist": 0.0454, "needs_human_check": false}}

View File

@@ -0,0 +1,15 @@
{"case_id": "easily_shot_0001_72_538", "screenshot_path": "/tmp/easily_session/shots/shot_0001_full.png", "task": {"intent": "cliquer sur « 25003362 »", "target_text": "25003362", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003362 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.3362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0003_388_380", "screenshot_path": "/tmp/easily_session/shots/shot_0003_full.png", "task": {"intent": "cliquer sur « cliniques »", "target_text": "cliniques", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « cliniques » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1516, "y_pct": 0.2375, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0004_552_381", "screenshot_path": "/tmp/easily_session/shots/shot_0004_full.png", "task": {"intent": "cliquer sur « Imagerie »", "target_text": "Imagerie", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Imagerie » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2156, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0005_685_385", "screenshot_path": "/tmp/easily_session/shots/shot_0005_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2676, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0012_95_613", "screenshot_path": "/tmp/easily_session/shots/shot_0012_full.png", "task": {"intent": "cliquer sur « 25003451 »", "target_text": "25003451", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003451 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0371, "y_pct": 0.3831, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0013_2393_1215", "screenshot_path": "/tmp/easily_session/shots/shot_0013_full.png", "task": {"intent": "cliquer sur « Oui »", "target_text": "Oui", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Oui » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9348, "y_pct": 0.7594, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0014_910_378", "screenshot_path": "/tmp/easily_session/shots/shot_0014_full.png", "task": {"intent": "cliquer sur « Synthèse »", "target_text": "Synthèse", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Synthèse » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3555, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0015_638_381", "screenshot_path": "/tmp/easily_session/shots/shot_0015_full.png", "task": {"intent": "cliquer sur « Notes »", "target_text": "Notes", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Notes » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2492, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0020_695_379", "screenshot_path": "/tmp/easily_session/shots/shot_0020_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2715, "y_pct": 0.2369, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0021_127_395", "screenshot_path": "/tmp/easily_session/shots/shot_0021_full.png", "task": {"intent": "cliquer sur « d'admission »", "target_text": "d'admission", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « d'admission » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0496, "y_pct": 0.2469, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0022_73_304", "screenshot_path": "/tmp/easily_session/shots/shot_0022_full.png", "task": {"intent": "cliquer sur « IPP: »", "target_text": "IPP:", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP: » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": false, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0023_73_304", "screenshot_path": "/tmp/easily_session/shots/shot_0023_full.png", "task": {"intent": "cliquer sur « IPP »", "target_text": "IPP", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": false, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0025_67_790", "screenshot_path": "/tmp/easily_session/shots/shot_0025_full.png", "task": {"intent": "cliquer sur « 25012257 »", "target_text": "25012257", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25012257 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0262, "y_pct": 0.4938, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0028_770_385", "screenshot_path": "/tmp/easily_session/shots/shot_0028_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3008, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
{"case_id": "easily_shot_0037_2028_1290", "screenshot_path": "/tmp/easily_session/shots/shot_0037_full.png", "task": {"intent": "cliquer sur « terminé »", "target_text": "terminé", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « terminé » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7922, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": false, "ocr_occurrences": 1}}

View File

@@ -0,0 +1,41 @@
{"case_id": "easily_rec_shot_0001_72_538", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0001_full.png", "task": {"intent": "cliquer sur « 25003362 »", "target_text": "25003362", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003362 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.3362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003362", "ocr_dist": 0.0067, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0002_380_919", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0002_full.png", "task": {"intent": "cliquer sur « iméicamentset-substancs »", "target_text": "iméicamentset-substancs", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « iméicamentset-substancs » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1484, "y_pct": 0.5744, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "iméicamentset-substancs", "ocr_dist": 0.0107, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0003_388_380", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0003_full.png", "task": {"intent": "cliquer sur « cliniques »", "target_text": "cliniques", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « cliniques » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1516, "y_pct": 0.2375, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "cliniques", "ocr_dist": 0.0075, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0004_552_381", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0004_full.png", "task": {"intent": "cliquer sur « Imagerie »", "target_text": "Imagerie", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Imagerie » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2156, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Imagerie", "ocr_dist": 0.009, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0005_685_385", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0005_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2676, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0127, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0006_2547_962", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0006_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9949, "y_pct": 0.6012, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0007_947_381", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0007_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3699, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0057, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0008_903_552", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0008_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3527, "y_pct": 0.345, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0009_903_552", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0009_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3527, "y_pct": 0.345, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0010_2546_1042", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0010_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9945, "y_pct": 0.6512, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0011_72_288", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0011_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.18, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0109, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0012_95_613", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0012_full.png", "task": {"intent": "cliquer sur « 25003451 »", "target_text": "25003451", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003451 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0371, "y_pct": 0.3831, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003451", "ocr_dist": 0.0111, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0013_2393_1215", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0013_full.png", "task": {"intent": "cliquer sur « Oui »", "target_text": "Oui", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Oui » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9348, "y_pct": 0.7594, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Oui", "ocr_dist": 0.0036, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0014_910_378", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0014_full.png", "task": {"intent": "cliquer sur « Synthèse »", "target_text": "Synthèse", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Synthèse » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3555, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Synthèse", "ocr_dist": 0.0172, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0015_638_381", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0015_full.png", "task": {"intent": "cliquer sur « Notes »", "target_text": "Notes", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Notes » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2492, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Notes", "ocr_dist": 0.0051, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0016_970_393", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0016_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3789, "y_pct": 0.2456, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.008, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0017_2506_1205", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0017_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9789, "y_pct": 0.7531, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0018_2549_1203", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0018_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9957, "y_pct": 0.7519, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0019_2557_244", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0019_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9988, "y_pct": 0.1525, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0020_695_379", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0020_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2715, "y_pct": 0.2369, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0057, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0021_127_395", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0021_full.png", "task": {"intent": "cliquer sur « d'admission »", "target_text": "d'admission", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « d'admission » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0496, "y_pct": 0.2469, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "d'admission", "ocr_dist": 0.0089, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0022_73_304", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0022_full.png", "task": {"intent": "cliquer sur « IPP: »", "target_text": "IPP:", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP: » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "IPP:", "ocr_dist": 0.0147, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0023_73_304", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0023_full.png", "task": {"intent": "cliquer sur « IPP »", "target_text": "IPP", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "IPP", "ocr_dist": 0.0163, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0024_84_269", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0024_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0328, "y_pct": 0.1681, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0018, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0025_67_790", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0025_full.png", "task": {"intent": "cliquer sur « 25012257 »", "target_text": "25012257", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25012257 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0262, "y_pct": 0.4938, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25012257", "ocr_dist": 0.0053, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0026_2545_1356", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0026_full.png", "task": {"intent": "cliquer sur « 95 »", "target_text": "95", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 95 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9941, "y_pct": 0.8475, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "95", "ocr_dist": 0.0291, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0027_2541_284", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0027_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9926, "y_pct": 0.1775, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0028_770_385", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0028_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3008, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0166, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0029_766_378", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0029_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2992, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0196, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0030_2546_595", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0030_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9945, "y_pct": 0.3719, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0031_2558_1415", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0031_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9992, "y_pct": 0.8844, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0032_954_375", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0032_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3727, "y_pct": 0.2344, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0095, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0033_2544_704", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0033_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9938, "y_pct": 0.44, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
{"case_id": "easily_rec_shot_0034_2188_1570", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0034_full.png", "task": {"intent": "cliquer sur « a »", "target_text": "a", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « a » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8547, "y_pct": 0.9812, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "a", "ocr_dist": 0.0391, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0035_2166_1296", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0035_full.png", "task": {"intent": "cliquer sur « 0 »", "target_text": "0", "current_window": "Fenêtre de dépassement de capacité de la barre détat système.", "expected_next_window": "", "question": "L'élément « 0 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8461, "y_pct": 0.81, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "0", "ocr_dist": 0.0133, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0036_2196_1285", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0036_full.png", "task": {"intent": "cliquer sur « - »", "target_text": "-", "current_window": "Fenêtre de dépassement de capacité de la barre détat système.", "expected_next_window": "", "question": "L'élément « - » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8578, "y_pct": 0.8031, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "-", "ocr_dist": 0.0239, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0037_2028_1290", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0037_full.png", "task": {"intent": "cliquer sur « terminé »", "target_text": "terminé", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « terminé » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7922, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "terminé", "ocr_dist": 0.0395, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0038_2031_1283", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0038_full.png", "task": {"intent": "cliquer sur « 0 »", "target_text": "0", "current_window": "Fenêtre de dépassement de capacité de la barre détat système.", "expected_next_window": "", "question": "L'élément « 0 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7934, "y_pct": 0.8019, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "0", "ocr_dist": 0.0407, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0039_2192_1298", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0039_full.png", "task": {"intent": "cliquer sur « 0 »", "target_text": "0", "current_window": "Fenêtre de dépassement de capacité de la barre détat système.", "expected_next_window": "", "question": "L'élément « 0 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8562, "y_pct": 0.8113, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "0", "ocr_dist": 0.0235, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0040_2131_1290", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0040_full.png", "task": {"intent": "cliquer sur « 9 0 - »", "target_text": "9 0 -", "current_window": "Fenêtre de dépassement de capacité de la barre détat système.", "expected_next_window": "", "question": "L'élément « 9 0 - » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8324, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "9 0 -", "ocr_dist": 0.0125, "needs_human_check": false}}
{"case_id": "easily_rec_shot_0041_2010_1013", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0041_full.png", "task": {"intent": "cliquer sur « mémorisées »", "target_text": "mémorisées", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « mémorisées » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7852, "y_pct": 0.6331, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "mémorisées", "ocr_dist": 0.0454, "needs_human_check": false}}

View File

@@ -0,0 +1,156 @@
"""Signature de trajectoire — identité stable d'un parcours appris (décision F1).
Une trajectoire = séquence ordonnée d'actions sur des cibles stables. La signature
hashe uniquement `(action_type, target)` de chaque étape, dans l'ordre, en **ignorant
les champs session-spécifiques** (IDs de nœuds, timestamps, coordonnées). Deux
apprentissages du même parcours produisent donc la même signature → create-or-update.
Primitive partagée (Phase 0) : consommée par SP-4 (dédup/persist), SP-2 (rejeu) et le
cycle compétences (dédup des skills). Pour composer avec un descripteur d'écran stable,
passer `core.execution.screen_signature.screen_signature(...)` comme valeur de `target`.
"""
import hashlib
import re
import unicodedata
from typing import Any, Iterable, Mapping
_FIELD_SEP = "\x1f" # sépare action_type et target dans une étape
_STEP_SEP = "\x1e" # sépare les étapes
# --- Cible stable : anonymisation PII + normalisation déterministes ----------
# Verdict QG Qwen (2026-06-25) : regex DÉDIÉES à la signature (PAS `pii_blur`,
# qui protège les dates alors qu'ici on les NEUTRALISE), PAS de NER (un hash
# d'identité doit être déterministe et identique labo↔DGX, donc indépendant
# d'un modèle versionné). Les noms propres sans titre ne sont pas neutralisés
# ici (stratégie « (b) » : impact 0 sur l'audit labo ; gate = audit agrégat
# `by_text` DGX avant prod, ajouter une regex ciblée si des noms apparaissent).
_WS_RE = re.compile(r"\s+")
# Ordre d'application : motifs structurés d'abord, identifiant numérique long
# en dernier (sinon il mangerait des fragments de date/téléphone).
_RE_EMAIL = re.compile(r"\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
_RE_DATE = re.compile(r"\b\d{1,4}[/.\-]\d{1,2}[/.\-]\d{1,4}\b")
_RE_PHONE = re.compile(r"\b(?:\+?33|0)\s?[1-9](?:[\s.\-]?\d{2}){4}\b")
_RE_LONGNUM = re.compile(r"\d{6,}") # IPP / NIR collé / autre identifiant long
def _anonymize_pii(text: str) -> str:
"""Neutralise la PII structurée par des tokens stables : deux sessions sur le
même champ (patients/dates différents) → même texte cible → même signature."""
text = _RE_EMAIL.sub("[email]", text)
text = _RE_DATE.sub("[date]", text)
text = _RE_PHONE.sub("[tel]", text)
text = _RE_LONGNUM.sub("[ipp]", text)
return text
def _norm_text(text: str) -> str:
"""Normalisation déterministe (même logique que `action_executor._norm_text`,
redéfinie ici pour garder ce module léger et sans effet de bord d'import) :
minuscules, suppression des accents (NFKD), espaces normalisés."""
if not text:
return ""
text = text.replace(" ", " ").strip().lower()
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
return _WS_RE.sub(" ", text).strip()
def _normalize_target(target: str) -> str:
"""Cible stable : PII neutralisée PUIS normalisée (casse/accents/espaces)."""
return _norm_text(_anonymize_pii(target))
def _normalize_step(step: Mapping[str, Any]) -> str:
action_type = str(step.get("action_type", "unknown")).strip().lower()
target = _normalize_target(str(step.get("target", "")))
return f"{action_type}{_FIELD_SEP}{target}"
def trajectory_signature(steps: Iterable[Mapping[str, Any]]) -> str:
"""Retourne la signature SHA-256 (hex, 64 car.) d'une séquence d'étapes.
Chaque étape est un mapping ; seuls `action_type` et `target` sont pris en compte.
Tous les autres champs (node_id, timestamp, coordonnées…) sont ignorés afin de
garantir la stabilité de la signature entre deux sessions du même parcours.
"""
canonical = _STEP_SEP.join(_normalize_step(step) for step in steps)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Adaptateur : workflow core (dict) → signature de trajectoire
# ---------------------------------------------------------------------------
def _stable_target(target: Any) -> str:
"""Descripteur de cible **stable** entre sessions.
S'appuie sur le texte sémantique de la cible (`by_text`), volontairement
indépendant du moteur de grounding : `by_role` peut valoir 'yolo'/'ocr'/'vlm'
(méthode de détection, instable entre sessions) et n'entre donc PAS dans la
signature. Fallback quand `by_text` est absent : titre de fenêtre / description VLM.
"""
if not isinstance(target, Mapping):
return ""
by_text = str(target.get("by_text") or "").strip()
if by_text:
return by_text
hints = target.get("context_hints")
if isinstance(hints, Mapping):
return str(hints.get("window_title") or hints.get("vlm_description") or "").strip()
return ""
def _ordered_edges(workflow: Mapping[str, Any]) -> list:
"""Edges dans l'ordre du parcours (BFS depuis entry_nodes), comme le bridge d'import."""
edges = list(workflow.get("edges") or [])
if not edges:
return []
by_from: dict = {}
for edge in edges:
by_from.setdefault((edge or {}).get("from_node"), []).append(edge)
entry = list(workflow.get("entry_nodes") or [])
nodes = workflow.get("nodes") or []
if not entry and nodes:
entry = [(nodes[0] or {}).get("node_id")]
if not entry:
return edges # pas de point d'entrée : ordre brut de la liste
ordered: list = []
seen_edges: set = set()
visited: set = set()
queue = list(entry)
while queue:
node = queue.pop(0)
if node in visited:
continue
visited.add(node)
for edge in by_from.get(node, []):
key = id(edge)
if key in seen_edges:
continue
seen_edges.add(key)
ordered.append(edge)
to_node = (edge or {}).get("to_node")
if to_node and to_node not in visited:
queue.append(to_node)
for edge in edges: # edges non atteints : ajout déterministe en fin
if id(edge) not in seen_edges:
ordered.append(edge)
return ordered
def workflow_step_descriptors(workflow: Mapping[str, Any]) -> list:
"""Séquence ordonnée de descripteurs `(action_type, target stable)` d'un workflow core."""
descriptors: list = []
for edge in _ordered_edges(workflow):
action = (edge or {}).get("action") or {}
descriptors.append({
"action_type": action.get("type", "unknown"),
"target": _stable_target(action.get("target")),
})
return descriptors
def workflow_trajectory_signature(workflow: Mapping[str, Any]) -> str:
"""Signature de trajectoire d'un workflow core (dict). Cf. `trajectory_signature`."""
return trajectory_signature(workflow_step_descriptors(workflow))

View File

@@ -0,0 +1,279 @@
"""role_mapper — reconstruction de champs ANCRÉS sur l'OCR.
Principe cardinal (gate validé le 30/06 sur DPI urgences réel) :
le VLM ne fournit QUE des ids de tokens OCR (`value_ids`) ; la valeur est
reconstruite ici depuis l'OCR. Aucun texte produit par le VLM ne peut entrer
dans une valeur → **0 hallucination par construction**.
Ce module est volontairement PUR (pas d'appel réseau/VLM) : il prend les tokens
OCR (issus de `core.llm.ocr_extractor.extract_grid_from_image`) et la réponse
déjà désérialisée du VLM, et produit des champs ancrés. L'appel VLM lui-même
est orchestré ailleurs (et mockable), pour rester testable hors-ligne.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Callable, List, Optional, Sequence, Tuple
BBox = Tuple[int, int, int, int] # (x_min, y_min, x_max, y_max)
@dataclass
class OcrToken:
"""Un token OCR indexé par un id stable."""
id: int
text: str
confidence: float = 1.0
bbox: Optional[BBox] = None
@dataclass
class MappedField:
"""Un champ {rôle → valeur} dont la valeur est 100% issue de l'OCR."""
label: str
value: str
value_ids: List[int]
confidence: float
bbox: Optional[BBox]
anchored: bool
invalid_ids: List[int]
def _norm_bbox(bbox) -> Optional[BBox]:
"""Normalise une bbox en (x_min, y_min, x_max, y_max).
Accepte soit 4 points EasyOCR `[[x,y], ...]`, soit un quadruplet déjà plat.
"""
if bbox is None:
return None
if len(bbox) == 4 and all(isinstance(v, (int, float)) for v in bbox):
return (int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]))
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
return (int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys)))
def tokens_from_grid(grid: Sequence[Sequence[dict]]) -> List[OcrToken]:
"""Convertit une grille `extract_grid_from_image` en tokens indexés (id séquentiel).
L'ordre des ids suit l'ordre de lecture de la grille (lignes top→bottom,
colonnes left→right), ce qui donne au VLM un référentiel stable.
"""
tokens: List[OcrToken] = []
tid = 0
for row in grid:
for cell in row:
tokens.append(OcrToken(
id=tid,
text=cell["text"],
confidence=float(cell.get("confidence", 1.0)),
bbox=_norm_bbox(cell.get("bbox")),
))
tid += 1
return tokens
def _enclosing_bbox(bboxes: Sequence[Optional[BBox]]) -> Optional[BBox]:
present = [b for b in bboxes if b is not None]
if not present:
return None
return (
min(b[0] for b in present),
min(b[1] for b in present),
max(b[2] for b in present),
max(b[3] for b in present),
)
def reconstruct_fields(
tokens: Sequence[OcrToken],
vlm_fields: Sequence[dict],
) -> List[MappedField]:
"""Reconstruit les champs à partir des tokens OCR et des `value_ids` du VLM.
Pour chaque champ VLM `{label, value_ids:[...]}` :
- déduplique les ids en préservant l'ordre de lecture donné par le VLM ;
- filtre les ids hors OCR (listés dans `invalid_ids`) ;
- reconstruit la valeur par concaténation des `text` des tokens valides ;
- confidence = min des tokens ancrés (le plus prudent), bbox = englobante.
Tout champ `value`/texte fourni par le VLM est IGNORÉ : seule la liste
d'ids fait foi (anti-hallucination).
"""
by_id = {t.id: t for t in tokens}
out: List[MappedField] = []
for vf in vlm_fields:
label = vf.get("label", "")
seen: List[int] = []
for i in (vf.get("value_ids") or []):
if i not in seen:
seen.append(i)
valid = [i for i in seen if i in by_id]
invalid = [i for i in seen if i not in by_id]
toks = [by_id[i] for i in valid]
out.append(MappedField(
label=label,
value=" ".join(t.text for t in toks),
value_ids=valid,
confidence=min((t.confidence for t in toks), default=0.0),
bbox=_enclosing_bbox([t.bbox for t in toks]),
anchored=bool(valid),
invalid_ids=invalid,
))
return out
# --- Orchestration VLM (client injectable pour rester testable hors-ligne) ---
# Un client VLM est un callable (image_path, prompt) -> texte de réponse.
VlmClient = Callable[[str, str], str]
def build_role_prompt(
tokens: Sequence[OcrToken],
roles: Optional[Sequence[str]] = None,
) -> str:
"""Construit le prompt d'attribution de rôles (ancrage strict par ids).
Mode *guidé* si `roles` est fourni (rôles attendus de l'écran), sinon *libre*
(le VLM nomme lui-même les champs). Dans les deux cas le VLM ne renvoie que
des `value_ids` — jamais de texte recopié.
"""
ocr_list = [{"id": t.id, "text": t.text} for t in tokens]
if roles:
roles_line = (
"Rôles attendus sur cet écran (associe chacun s'il est présent) : "
+ ", ".join(roles) + ".\n"
)
else:
roles_line = (
"Identifie librement les champs présents — le 'label' est le rôle du champ.\n"
)
return (
"Tu reçois une capture d'écran d'un dossier patient et la liste des tokens "
"détectés par OCR (chaque token : id, text).\n"
+ roles_line +
"Pour chaque champ, désigne les tokens OCR qui composent sa VALEUR.\n"
"RÈGLES STRICTES :\n"
"- Tu ne recopies AUCUN texte. Tu renvoies seulement 'value_ids' : la liste "
"des id de tokens OCR (dans l'ordre de lecture) qui forment la valeur.\n"
"- 'label' = le rôle du champ. N'invente aucun champ.\n"
"- Réponds UNIQUEMENT en JSON PLAT :\n"
'{"ecran":"<type en 3 mots>","champs":[{"label":"...","value_ids":[<int>,...]}]}\n\n'
"Tokens OCR :\n" + json.dumps(ocr_list, ensure_ascii=False)
)
def parse_vlm_json(text: str) -> dict:
"""Extrait le 1er objet JSON d'une réponse VLM (tolère les fences ```json).
Robuste : renvoie `{}` si la réponse n'est pas du JSON exploitable (pas de
crash en batch).
"""
if not text:
return {}
s = text.strip()
if "```" in s:
parts = s.split("```")
if len(parts) >= 2:
s = parts[1]
if s.lstrip().lower().startswith("json"):
s = s.lstrip()[4:]
a, b = s.find("{"), s.rfind("}")
if a < 0 or b <= a:
return {}
try:
return json.loads(s[a:b + 1])
except (ValueError, TypeError):
return {}
def _norm_label(label: str) -> str:
"""Normalise un label pour comparaison : minuscules + strip espaces."""
return label.strip().lower()
def assess_quality(
fields: Sequence[MappedField],
required_roles: Optional[Sequence[str]] = None,
min_confidence: float = 0.6,
) -> str:
"""Évalue la qualité d'extraction d'un dossier à partir des champs reconstruits.
Renvoie l'un des 4 statuts (par priorité décroissante) :
- "failed" : aucun champ, OU aucun champ ancré.
- "needs_review" : au moins un rôle requis absent ou non ancré.
- "partial" : rôles requis ok mais confidence insuffisante OU champs non ancrés.
- "complete" : tout ancré, toutes confidences >= min_confidence, aucun non ancré.
Le matching required_role ↔ field.label est insensible à la casse et aux espaces.
"""
# --- failed : aucun champ du tout, ou aucun ancré ---
anchored = [f for f in fields if f.anchored]
if not fields or not anchored:
return "failed"
# --- needs_review : rôle requis absent ou non ancré ---
if required_roles:
anchored_labels = {_norm_label(f.label) for f in anchored}
for role in required_roles:
if _norm_label(role) not in anchored_labels:
return "needs_review"
# --- partial : confidence basse sur un champ ancré OU champs non ancrés ---
has_low_confidence = any(f.confidence < min_confidence for f in anchored)
has_unanchored = any(not f.anchored for f in fields)
if has_low_confidence or has_unanchored:
return "partial"
# --- complete ---
return "complete"
def map_roles(
image_path: str,
tokens: Sequence[OcrToken],
vlm_client: VlmClient,
roles: Optional[Sequence[str]] = None,
) -> List[MappedField]:
"""Orchestre l'attribution de rôles : prompt → VLM → parse → reconstruction ancrée.
`vlm_client` est injecté (testable hors-ligne). Le résultat est toujours
ancré sur l'OCR via `reconstruct_fields`.
"""
prompt = build_role_prompt(tokens, roles)
raw = vlm_client(image_path, prompt)
data = parse_vlm_json(raw)
vlm_fields = data.get("champs", []) if isinstance(data, dict) else []
return reconstruct_fields(tokens, vlm_fields)
def extract_dossier_from_image(
image_path: str,
vlm_client: VlmClient,
roles: Optional[Sequence[str]] = None,
ocr_fn: Optional[Callable[[str], Sequence[Sequence[dict]]]] = None,
min_confidence: float = 0.6,
required_roles: Optional[Sequence[str]] = None,
) -> dict:
"""Orchestre l'extraction d'un dossier depuis une capture : OCR → rôles → qualité.
Enchaîne `ocr_fn` (grille OCR) → `tokens_from_grid` → `map_roles` (VLM, ancrage
strict) → `assess_quality`. C'est la brique que le handler runtime
`_handle_extract_dossier_action` appellera, avec le vrai OCR et le vrai client
vLLM. `ocr_fn` et `vlm_client` sont INJECTABLES (testable hors-ligne).
`ocr_fn` par défaut = `core.llm.ocr_extractor.extract_grid_from_image` (import
LAZY : le module reste pur quand l'OCR est injecté en test).
Returns:
{fields: List[MappedField], status: str, n_tokens: int}
"""
if ocr_fn is None:
from core.llm.ocr_extractor import extract_grid_from_image as ocr_fn
grid = ocr_fn(image_path)
tokens = tokens_from_grid(grid)
fields = map_roles(image_path, tokens, vlm_client, roles)
status = assess_quality(fields, required_roles=required_roles, min_confidence=min_confidence)
return {"fields": fields, "status": status, "n_tokens": len(tokens)}

View File

@@ -0,0 +1,86 @@
"""Client vLLM serveur : (image_path, prompt) -> texte de réponse.
Petit client réutilisable pour la lecture d'écran (extraction de dossier). Le
grounder (`resolve_engine`) fait déjà un POST vers vLLM:8001 mais en INLINE, non
exposé ; on factorise ici un client propre, configurable et testable.
- Image downscalée (largeur max) avant envoi : la fenêtre vLLM est limitée
(`max_model_len`), un écran plein déborde sinon (vu 30/06 : 6193+2000 > 8192).
- `thinking` désactivé (vérifié : think=on -> sortie vide/lente sur ce modèle).
- `post_fn` injectable -> testable sans vLLM réel.
Branche feat/push-log-dgx.
"""
from __future__ import annotations
import base64
import os
from io import BytesIO
from typing import Callable, Optional
VlmClient = Callable[[str, str], str]
_DEFAULT_PORT = os.environ.get("VLLM_PORT", "8001")
DEFAULT_URL = f"http://localhost:{_DEFAULT_PORT}/v1/chat/completions"
DEFAULT_MODEL = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
def img_data_url(image_path: str, max_w: int = 1280) -> str:
"""Encode l'image en data-URL PNG base64, downscalée à `max_w` si plus large."""
from PIL import Image
img = Image.open(image_path).convert("RGB")
if img.width > max_w:
h = int(img.height * max_w / img.width)
img = img.resize((max_w, h), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def build_chat_body(
image_path: str,
prompt: str,
model: str = DEFAULT_MODEL,
max_tokens: int = 1500,
max_w: int = 1280,
) -> dict:
"""Construit le body chat/completions (image + prompt, thinking off)."""
return {
"model": model,
"messages": [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": img_data_url(image_path, max_w)}},
{"type": "text", "text": prompt},
],
}],
"temperature": 0.0,
"max_tokens": max_tokens,
"chat_template_kwargs": {"enable_thinking": False},
}
def make_vllm_client(
url: str = DEFAULT_URL,
model: str = DEFAULT_MODEL,
max_tokens: int = 1500,
max_w: int = 1280,
timeout: float = 120,
post_fn: Optional[Callable] = None,
) -> VlmClient:
"""Construit un client `(image_path, prompt) -> texte`, branché sur vLLM.
`post_fn` (signature `requests.post`) est injectable pour les tests.
Lève `RuntimeError` si le serveur ne répond pas 200 (message technique, sans PII).
"""
def client(image_path: str, prompt: str) -> str:
body = build_chat_body(image_path, prompt, model=model, max_tokens=max_tokens, max_w=max_w)
poster = post_fn
if poster is None:
import requests
poster = requests.post
r = poster(url, json=body, headers={}, timeout=timeout)
if r.status_code != 200:
raise RuntimeError(f"vLLM {r.status_code}: {str(getattr(r, 'text', ''))[:300]}")
return r.json()["choices"][0]["message"]["content"]
return client

View File

@@ -8,6 +8,7 @@ from .t2a_decision import (
)
from .ocr_extractor import (
extract_digits_tesseract_from_image,
extract_grid_from_image,
extract_table_from_image,
extract_text_from_image,
)
@@ -19,5 +20,6 @@ __all__ = [
"build_dpi_enriched",
"extract_text_from_image",
"extract_table_from_image",
"extract_grid_from_image",
"extract_digits_tesseract_from_image",
]

View File

@@ -243,3 +243,107 @@ def extract_table_from_image(
except Exception as e:
logger.warning("extract_table échoué sur %s : %s", image_path, e)
return []
def _cluster_1d(centers: List[float], tol: float) -> List[Tuple[float, int]]:
"""Regroupe des positions 1D par proximité (centres triés, gap > tol = nouveau cluster).
Retourne, pour chaque centre d'entrée (ordre d'origine), un couple
(centre_du_cluster, index_du_cluster), les clusters étant indexés dans
l'ordre croissant. Permet de mapper lignes (y) et colonnes (x).
"""
order = sorted(range(len(centers)), key=lambda i: centers[i])
cluster_of = [0] * len(centers)
cluster_centers: List[List[float]] = []
prev = None
idx = -1
for i in order:
c = centers[i]
if prev is None or (c - prev) > tol:
idx += 1
cluster_centers.append([])
cluster_centers[idx].append(c)
cluster_of[i] = idx
prev = c
means = [sum(g) / len(g) for g in cluster_centers]
return [(means[cluster_of[i]], cluster_of[i]) for i in range(len(centers))]
def extract_grid_from_image(
image_path: str,
region: Optional[Tuple[int, int, int, int]] = None,
row_tol: float = 12.0,
col_tol: float = 25.0,
) -> List[List[dict]]:
"""Extrait un tableau STRUCTURÉ (lignes ET colonnes) via OCR EasyOCR.
Contrairement à `extract_table_from_image` (liste plate triée par y, x jeté),
on conserve la coordonnée x pour reconstruire une grille. Clustering :
lignes par proximité du centre y, colonnes par proximité du centre x.
Args:
image_path: chemin du PNG sur disque.
region: (x, y, w, h) pour cropper avant OCR. None = image entière.
row_tol: écart vertical max (px) entre 2 tokens d'une même ligne.
col_tol: écart horizontal max (px) entre 2 tokens d'une même colonne.
Returns:
Grille `List[List[cell]]`, lignes top→bottom, colonnes left→right.
`cell = {"text", "bbox", "confidence", "row", "col"}`.
En cas d'erreur ou d'absence de tokens, retourne [].
"""
path = Path(image_path)
if not path.exists():
logger.warning("extract_grid: fichier introuvable %s", image_path)
return []
try:
from PIL import Image
import numpy as np
img = Image.open(path)
if region:
x, y, w, h = region
img = img.crop((x, y, x + w, y + h))
reader = _get_reader()
results = reader.readtext(np.array(img), detail=1, paragraph=False)
toks = []
for bbox, text, conf in results:
t = str(text).strip()
if not t:
continue
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
toks.append({
"text": t,
"bbox": bbox,
"confidence": conf,
"xc": sum(xs) / len(xs),
"yc": sum(ys) / len(ys),
})
if not toks:
return []
rows_cl = _cluster_1d([tk["yc"] for tk in toks], row_tol)
cols_cl = _cluster_1d([tk["xc"] for tk in toks], col_tol)
for tk, (_yc, r), (_xc, c) in zip(toks, rows_cl, cols_cl):
tk["row"], tk["col"] = r, c
n_rows = max(tk["row"] for tk in toks) + 1
grid: List[List[dict]] = [[] for _ in range(n_rows)]
for tk in toks:
grid[tk["row"]].append({
"text": tk["text"],
"bbox": tk["bbox"],
"confidence": tk["confidence"],
"row": tk["row"],
"col": tk["col"],
})
for row in grid:
row.sort(key=lambda cell: cell["col"])
return grid
except Exception as e:
logger.warning("extract_grid échoué sur %s : %s", image_path, e)
return []

View File

@@ -1250,12 +1250,16 @@ class Workflow:
}
if self.chain_config:
result["chain_config"] = self.chain_config.to_dict() if hasattr(self.chain_config, 'to_dict') else self.chain_config
# machine_id : attribut d'instance posé au runtime (pas un champ dataclass)
machine_id = getattr(self, "_machine_id", None)
if machine_id:
result["machine_id"] = machine_id
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Workflow':
"""Désérialiser depuis JSON"""
return cls(
wf = cls(
workflow_id=data["workflow_id"],
name=data.get("name", data["workflow_id"]),
description=data.get("description", ""),
@@ -1277,7 +1281,13 @@ class Workflow:
references=data.get("references", []),
chain_config=data.get("chain_config")
)
# Reposer machine_id (attribut d'instance) : priorité au champ explicite,
# sinon depuis metadata['machine_id'] (rétrocompat des workflows déjà sur disque)
machine_id = data.get("machine_id") or (wf.metadata or {}).get("machine_id")
if machine_id:
wf._machine_id = machine_id
return wf
def to_json(self) -> str:
"""Sérialiser en JSON string"""
return json.dumps(self.to_dict(), indent=2)

119
core/navigation/__init__.py Normal file
View File

@@ -0,0 +1,119 @@
"""Navigation brique — login visuel, recherche dossiers, vérification écran.
Modules :
- visual_verifier : verify_before / verify_after chaque action (vision = validateur, OCR-ancré)
- grounding : résolution visuelle d'éléments UI (OCR-anchor first, VLM fallback, coords cache)
- visual_login : login form resolution + verification (DPI urgences default config)
- action_resolver : pont navigation → runtime (coords normalisés, OCR/VLM adapters)
Pattern d'injection : VlmClient + OcrClient + OcrDetailedClient injectables
"""
from .visual_verifier import verify_screen_match, ScreenMatchResult
from .action_resolver import navigate_login, NavigateResult
__all__ = [
"verify_screen_match",
"ScreenMatchResult",
"navigate_login",
"NavigateResult",
"_handle_navigate_action",
]
# Handler pour replay_engine — importé par api_stream.py
def _handle_navigate_action(
action: dict,
replay_state: dict,
session_id: str,
) -> bool:
"""Handler serveur pour action navigate (branchement replay_engine).
Thin wrapper : résout coords du login form et les stocke dans
replay_state["variables"] pour les actions type/click suivantes.
N'échoue jamais le replay — toute erreur → log + needs_review.
"""
import logging
logger = logging.getLogger("navigation._handle_navigate_action")
params = action.get("parameters") or {}
navigate_action = params.get("action", "login")
# Noms des variables output (configurable)
login_var = (params.get("login_coords_var") or "navigate_login_coords").strip()
password_var = (params.get("password_coords_var") or "navigate_password_coords").strip()
submit_var = (params.get("submit_coords_var") or "navigate_submit_coords").strip()
variables = replay_state.setdefault("variables", {})
try:
screenshot_path = ""
# Résoudre screenshot depuis replay_state
if "last_screenshot_path" in replay_state:
screenshot_path = replay_state["last_screenshot_path"]
elif "last_heartbeat" in replay_state:
hb = replay_state["last_heartbeat"]
screenshot_path = hb.get("screenshot_path", "") if isinstance(hb, dict) else ""
if not screenshot_path:
logger.warning("navigate: no screenshot for session %s", session_id)
variables[login_var] = {"error": "no_screenshot"}
return False
# Dimensions écran (fallback 1920×1080)
screen_width = replay_state.get("screen_width", 1920)
screen_height = replay_state.get("screen_height", 1080)
# OCR/VLM clients — lazy import pour éviter circular dependency
from core.llm import extract_grid_from_image
from core.extraction.vlm_client import make_vllm_client
from core.navigation.action_resolver import make_ocr_detailed_from_grid
ocr_detailed = make_ocr_detailed_from_grid(extract_grid_from_image)
vlm_client = make_vllm_client()
# Config login
from core.navigation.visual_login import LoginFormConfig, dpi_urgences_login_config
config = dpi_urgences_login_config()
if "login_field" in params:
config = LoginFormConfig(
login_field=params.get("login_field", config.login_field),
password_field=params.get("password_field", config.password_field),
submit_button=params.get("submit_button", config.submit_button),
success_elements=params.get("success_elements", config.success_elements),
context=params.get("context", config.context),
)
# Orchestration navigate
from core.navigation.action_resolver import navigate_login
result = navigate_login(
screenshot_path, config=config,
ocr_client=ocr_detailed, vlm_client=vlm_client,
screen_width=screen_width, screen_height=screen_height,
)
# Stocker coords dans variables (format dict pour substitution)
if result.login_coords:
variables[login_var] = result.login_coords.to_dict()
if result.password_coords:
variables[password_var] = result.password_coords.to_dict()
if result.submit_coords:
variables[submit_var] = result.submit_coords.to_dict()
variables["navigate_result"] = {
"all_resolved": result.all_resolved,
"method": result.login_coords.method if result.login_coords else "",
"error": result.error,
}
if not result.all_resolved:
logger.warning("navigate: incomplete — %s", result.error)
return False
logger.info("navigate: login form resolved OK (method=%s)", result.login_coords.method if result.login_coords else "?")
return True
except Exception as e:
logger.warning("navigate: exception (%s) — needs_review", e)
variables["navigate_result"] = {"all_resolved": False, "error": str(e)}
return False

View File

@@ -0,0 +1,205 @@
"""Action resolver — pont entre modules navigation et runtime replay.
Orchestre verify → ground → store coords pour le handler replay_engine.
Convertit coords pixels → normalisé (x_pct/y_pct) pour le client Agent V1.
Architecture :
- handler replay_engine = thin wrapper (appelle action_resolver)
- action_resolver = bridge (adapte OCR/VLM runtime → interfaces navigation)
- modules navigation = pure functions (ne connaissent pas le runtime)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
from core.navigation.grounding import (
BBox,
CoordsCache,
GroundedElement,
OcrDetailedClient,
OcrTokenInfo,
ground_element,
)
from core.navigation.visual_login import (
LoginFormConfig,
LoginResolution,
dpi_urgences_login_config,
resolve_login_form,
verify_login_visible,
verify_login_success,
)
from core.navigation.visual_verifier import (
OcrClient,
ScreenMatchResult,
VlmClient,
)
logger = logging.getLogger(__name__)
# ── Dataclasses ──────────────────────────────────────────────────────
@dataclass
class NavigateCoords:
"""Normalized coords for a grounded element — format Agent V1 client."""
x_pct: float # center x normalized [0-1]
y_pct: float # center y normalized [0-1]
bbox_pct: Optional[Tuple[float, float, float, float]] = None # (x1, y1, x2, y2) normalized
method: str = "" # grounding method used
def to_dict(self) -> Dict[str, Any]:
d = {"x_pct": self.x_pct, "y_pct": self.y_pct, "method": self.method}
if self.bbox_pct:
d["bbox_pct"] = list(self.bbox_pct)
return d
@dataclass
class NavigateResult:
"""Result of a navigate action — coords for each resolved field."""
login_coords: Optional[NavigateCoords] = None
password_coords: Optional[NavigateCoords] = None
submit_coords: Optional[NavigateCoords] = None
all_resolved: bool = False
pre_verify: Optional[ScreenMatchResult] = None
post_verify: Optional[ScreenMatchResult] = None # set later by verify_after
error: str = ""
# ── Coordinate conversion ────────────────────────────────────────────
def grounded_to_coords(
element: GroundedElement,
screen_width: int,
screen_height: int,
) -> NavigateCoords:
"""Convert GroundedElement (pixels) to NavigateCoords (normalized pct)."""
x_pct = element.center[0] / screen_width if screen_width else 0
y_pct = element.center[1] / screen_height if screen_height else 0
x1_pct = element.bbox[0] / screen_width if screen_width else 0
y1_pct = element.bbox[1] / screen_height if screen_height else 0
x2_pct = element.bbox[2] / screen_width if screen_width else 0
y2_pct = element.bbox[3] / screen_height if screen_height else 0
return NavigateCoords(
x_pct=x_pct,
y_pct=y_pct,
bbox_pct=(x1_pct, y1_pct, x2_pct, y2_pct),
method=element.method,
)
# ── OCR adapter ──────────────────────────────────────────────────────
def make_ocr_detailed_from_grid(
grid_fn: Callable[[str], List[List[Dict[str, Any]]]],
) -> OcrDetailedClient:
"""Adapt extract_grid_from_image → OcrDetailedClient (List[OcrTokenInfo]).
Converts the grid format (list of rows of cells with bbox) into
flat OcrTokenInfo list with normalized LTRB bbox.
"""
from core.extraction.role_mapper import tokens_from_grid
def client(image_path: str) -> List[OcrTokenInfo]:
grid = grid_fn(image_path)
ocr_tokens = tokens_from_grid(grid)
return [
OcrTokenInfo(
text=t.text,
bbox=t.bbox,
confidence=t.confidence,
)
for t in ocr_tokens
]
return client
def make_ocr_simple_from_detailed(
ocr_detailed: OcrDetailedClient,
) -> OcrClient:
"""Derive text-only OcrClient from OcrDetailedClient."""
def client(image_path: str) -> List[str]:
return [t.text for t in ocr_detailed(image_path)]
return client
# ── Navigate login orchestration ─────────────────────────────────────
def navigate_login(
screenshot_path: str,
config: Optional[LoginFormConfig] = None,
ocr_client: Optional[OcrDetailedClient] = None,
vlm_client: Optional[VlmClient] = None,
screen_width: int = 1920,
screen_height: int = 1080,
coords_cache: Optional[CoordsCache] = None,
skip_pre_verify: bool = False,
) -> NavigateResult:
"""Orchestrate login navigation: verify → ground → convert coords.
Returns NavigateResult with normalized coords for each field.
The handler stores these in replay_state variables for subsequent
type/click actions.
"""
if config is None:
config = dpi_urgences_login_config()
if ocr_client is None or vlm_client is None:
return NavigateResult(
all_resolved=False,
error="ocr_client and vlm_client required",
)
ocr_simple = make_ocr_simple_from_detailed(ocr_client)
# Step 1: Pre-verification (optional)
pre_verify = None
if not skip_pre_verify:
pre_verify = verify_login_visible(
screenshot_path, config, ocr_simple, vlm_client,
)
if not pre_verify.match:
logger.warning("navigate_login: pre-verify failed — %s", pre_verify.describe())
return NavigateResult(
all_resolved=False,
pre_verify=pre_verify,
error=f"pre-verify failed: {pre_verify.describe()}",
)
# Step 2: Ground all fields
resolution = resolve_login_form(
screenshot_path, config, ocr_client, vlm_client,
screen_width=screen_width, screen_height=screen_height,
coords_cache=coords_cache,
)
if not resolution.all_resolved:
logger.warning("navigate_login: incomplete resolution — %s", resolution.describe())
return NavigateResult(
all_resolved=False,
pre_verify=pre_verify,
error=f"incomplete resolution: {resolution.describe()}",
)
# Step 3: Convert to normalized coords
login_coords = grounded_to_coords(resolution.login_field, screen_width, screen_height) if resolution.login_field else None
password_coords = grounded_to_coords(resolution.password_field, screen_width, screen_height) if resolution.password_field else None
submit_coords = grounded_to_coords(resolution.submit_button, screen_width, screen_height) if resolution.submit_button else None
return NavigateResult(
login_coords=login_coords,
password_coords=password_coords,
submit_coords=submit_coords,
all_resolved=True,
pre_verify=pre_verify,
)

View File

@@ -0,0 +1,375 @@
"""Grounding — résolution visuelle d'éléments UI → coords (bbox + center).
Architecture OCR-ancrée (alignée avec visual_verifier) :
- STRATÉGIE 1 : OCR-anchor — si le texte cible est trouvé par OCR,
utiliser le bbox du token OCR (déterministe, zero hallucination).
- STRATÉGIE 2 : VLM grounder — si OCR ne trouve pas le texte,
le VLM localise l'élément visuellement (fallback, risque contrôlé).
- CACHE coords : mémorise les coords résolues, validées par vision avant usage.
Si cached coords fail → re-résolution visuelle.
Coords = cache local validé par vue (Dom/Claude recadrage 01/07).
Vision = source de vérité, coords = shortcut validé.
BBox format interne : LTRB (x1, y1, x2, y2) pixels absolus —
cohérent avec SomElement, OcrToken, DetectedUIElement.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from core.navigation.visual_verifier import (
fuzzy_match,
normalize_text,
OcrClient,
VlmClient,
)
logger = logging.getLogger(__name__)
# BBox format: LTRB pixels (x1, y1, x2, y2)
BBox = Tuple[int, int, int, int]
# ── Dataclasses ──────────────────────────────────────────────────────
@dataclass
class OcrTokenInfo:
"""OCR token with bounding box — for grounding (richer than text-only)."""
text: str
bbox: Optional[BBox] = None # (x1, y1, x2, y2) LTRB pixels
confidence: float = 1.0
# Type alias — injectable OCR client returning tokens with bbox
# More detailed than visual_verifier's OcrClient (which returns List[str])
OcrDetailedClient = Callable[[str], List[OcrTokenInfo]]
@dataclass
class GroundedElement:
"""A UI element grounded on screen with coordinates."""
role: str
text: str
bbox: BBox # (x1, y1, x2, y2) LTRB pixels
center: Tuple[int, int] # (cx, cy) — click target
confidence: float
method: str # "ocr_anchor" or "vlm_grounder" or "cache"
source_ocr_text: str = "" # actual OCR text that matched (for fuzzy)
@dataclass
class CoordsCacheEntry:
"""Cached coordinates for a UI element."""
element_key: str # "role:text"
bbox: BBox
center: Tuple[int, int]
method: str # how it was originally resolved
validation_count: int = 0
class CoordsCache:
"""In-memory cache of grounded coordinates.
Entries are validated by vision before use (verify_after).
If cached coords fail verification → invalidate + re-resolve.
"""
def __init__(self) -> None:
self._entries: Dict[str, CoordsCacheEntry] = {}
def get(self, element_key: str) -> Optional[CoordsCacheEntry]:
return self._entries.get(element_key)
def put(
self,
element_key: str,
bbox: BBox,
center: Tuple[int, int],
method: str,
) -> None:
entry = self._entries.get(element_key)
if entry:
entry.bbox = bbox
entry.center = center
entry.method = method
entry.validation_count += 1
else:
self._entries[element_key] = CoordsCacheEntry(
element_key=element_key,
bbox=bbox,
center=center,
method=method,
validation_count=1,
)
def invalidate(self, element_key: str) -> None:
self._entries.pop(element_key, None)
def clear(self) -> None:
self._entries.clear()
def keys(self) -> List[str]:
return list(self._entries.keys())
# ── Helper functions ─────────────────────────────────────────────────
def bbox_center(bbox: BBox) -> Tuple[int, int]:
"""Compute center point from LTRB bbox."""
x1, y1, x2, y2 = bbox
return ((x1 + x2) // 2, (y1 + y2) // 2)
def make_element_key(role: str, text: str) -> str:
"""Create a stable cache key from role + text."""
return f"{role}:{normalize_text(text)}"
# ── OCR-anchored grounding (deterministic) ───────────────────────────
def ocr_anchor_ground(
ocr_tokens: List[OcrTokenInfo],
target: Dict[str, Any],
fuzzy_threshold: float = 0.8,
) -> Optional[GroundedElement]:
"""Ground an element using OCR tokens with bbox (deterministic).
Finds the target text in OCR tokens via fuzzy match.
Returns GroundedElement with bbox from the matching OCR token.
"""
target_text = target.get("text", "")
target_role = target.get("role", "?")
if not target_text:
return None
for token in ocr_tokens:
if fuzzy_match(target_text, token.text, threshold=fuzzy_threshold):
if token.bbox is None:
continue # token found but no bbox → can't ground
return GroundedElement(
role=target_role,
text=target_text,
bbox=token.bbox,
center=bbox_center(token.bbox),
confidence=token.confidence,
method="ocr_anchor",
source_ocr_text=token.text,
)
return None
# ── VLM grounder (fallback) ─────────────────────────────────────────
def build_grounder_prompt(
target: Dict[str, Any],
context: str = "",
) -> str:
"""Build VLM prompt for locating a UI element on screen.
Asks for bounding box in normalized coordinates [0-1].
"""
role = target.get("role", "?")
text = target.get("text", "")
extra = target.get("extra", "")
prompt = (
"You are a UI element locator. Find the specified element on this "
"screenshot and return its bounding box.\n"
)
if context:
prompt += f"Context: {context}\n"
prompt += f"Target element: {role} with text \"{text}\""
if extra:
prompt += f" ({extra})"
prompt += (
"\n\nRespond in JSON format:\n"
"{\"found\": true/false, "
"\"bbox\": [x1_norm, y1_norm, x2_norm, y2_norm], "
"\"confidence\": 0.0-1.0, "
"\"description\": \"...\"}\n"
"bbox coordinates are normalized [0.0-1.0] relative to image dimensions "
"(x1=left, y1=top, x2=right, y2=bottom). "
"Only return found=true if you can clearly locate the element."
)
return prompt
def parse_grounder_response(
vlm_text: str,
screen_width: int,
screen_height: int,
target: Dict[str, Any],
) -> Optional[GroundedElement]:
"""Parse VLM grounder response into GroundedElement.
Converts normalized bbox [0-1] to absolute pixels.
"""
try:
data = json.loads(vlm_text)
except json.JSONDecodeError:
json_match = re.search(r"\{[\s\S]*\}", vlm_text)
if json_match:
try:
data = json.loads(json_match.group())
except json.JSONDecodeError:
logger.warning("grounding: VLM response not parseable as JSON")
return None
else:
return None
if not data.get("found", False):
return None
bbox_norm = data.get("bbox", [])
if not isinstance(bbox_norm, list) or len(bbox_norm) != 4:
logger.warning("grounding: invalid bbox format from VLM")
return None
# Convert normalized [0-1] to absolute pixels
try:
x1 = int(float(bbox_norm[0]) * screen_width)
y1 = int(float(bbox_norm[1]) * screen_height)
x2 = int(float(bbox_norm[2]) * screen_width)
y2 = int(float(bbox_norm[3]) * screen_height)
except (ValueError, TypeError):
logger.warning("grounding: bbox values not numeric")
return None
# Clamp to screen bounds
x1 = max(0, min(x1, screen_width))
y1 = max(0, min(y1, screen_height))
x2 = max(x1, min(x2, screen_width))
y2 = max(y1, min(y2, screen_height))
confidence = data.get("confidence", 0.5)
if isinstance(confidence, str):
try:
confidence = float(confidence)
except ValueError:
confidence = 0.5
bbox_abs: BBox = (x1, y1, x2, y2)
return GroundedElement(
role=target.get("role", "?"),
text=target.get("text", ""),
bbox=bbox_abs,
center=bbox_center(bbox_abs),
confidence=confidence,
method="vlm_grounder",
)
# ── Core grounding function (composition) ───────────────────────────
def ground_element(
screenshot_path: str,
target: Dict[str, Any],
ocr_client: OcrDetailedClient,
vlm_client: VlmClient,
screen_width: int = 1920,
screen_height: int = 1080,
coords_cache: Optional[CoordsCache] = None,
context: str = "",
fuzzy_threshold: float = 0.8,
) -> Optional[GroundedElement]:
"""Ground a UI element on screen — OCR-anchor first, VLM fallback.
Resolution strategy:
1. Cache: if cached coords exist → return cached (validated separately)
2. OCR-anchor: deterministic, zero hallucination
3. VLM grounder: fallback when OCR can't find the text
Args:
screenshot_path: path to screenshot image
target: {"role": "bouton", "text": "Connexion"} — element to find
ocr_client: injectable OCR client returning List[OcrTokenInfo]
vlm_client: injectable VLM client (image_path, prompt) -> text
screen_width/height: screen dimensions for pixel conversion
coords_cache: optional CoordsCache for memoization
context: optional context (e.g. "page login DPI")
fuzzy_threshold: fuzzy match threshold for OCR anchoring
Returns:
GroundedElement with bbox + center, or None if not found
"""
target_text = target.get("text", "")
target_role = target.get("role", "?")
element_key = make_element_key(target_role, target_text)
# Step 0: Check cache
if coords_cache:
cached = coords_cache.get(element_key)
if cached:
cached.validation_count += 1
logger.info("grounding: using cached coords for %s", element_key)
return GroundedElement(
role=target_role,
text=target_text,
bbox=cached.bbox,
center=cached.center,
confidence=1.0, # cached = previously validated
method="cache",
)
# Step 1: OCR-anchor (deterministic)
try:
ocr_tokens = ocr_client(screenshot_path)
except Exception as e:
logger.warning("grounding: OCR call failed (%s)", e)
ocr_tokens = []
ocr_result = ocr_anchor_ground(ocr_tokens, target, fuzzy_threshold)
if ocr_result:
if coords_cache:
coords_cache.put(element_key, ocr_result.bbox, ocr_result.center, "ocr_anchor")
logger.info(
"grounding: OCR-anchor found '%s' (matched OCR='%s', conf=%.2f)",
target_text, ocr_result.source_ocr_text, ocr_result.confidence,
)
return ocr_result
# Step 2: VLM grounder (fallback)
if not target_text:
logger.warning("grounding: no text for target, VLM grounder needs text")
return None
prompt = build_grounder_prompt(target, context)
try:
vlm_text = vlm_client(screenshot_path, prompt)
except Exception as e:
logger.warning("grounding: VLM grounder call failed (%s)", e)
return None
vlm_result = parse_grounder_response(vlm_text, screen_width, screen_height, target)
if vlm_result:
if coords_cache:
coords_cache.put(element_key, vlm_result.bbox, vlm_result.center, "vlm_grounder")
logger.info(
"grounding: VLM grounder found '%s' (conf=%.2f)",
target_text, vlm_result.confidence,
)
return vlm_result
logger.warning("grounding: element '%s' not found by OCR or VLM", target_text)
return None

View File

@@ -0,0 +1,227 @@
"""Visual login — résolution + vérification du formulaire de login par grounding.
Architecture (alignée visual_verifier + grounding) :
- verify_before : formulaire login visible (champs + bouton présents)
- resolve_login_form : ground chaque champ (login, password, bouton) → coords
- verify_after : dashboard/accueil visible (post-login)
- Chaque étape encadrée par vision (DETTE-023 couvert)
Coords = cache local validé par vue (Dom/Claude recadrage).
Le runtime exécute les actions (type/click) — ce module résout + valide.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from core.navigation.grounding import (
BBox,
CoordsCache,
GroundedElement,
OcrDetailedClient,
OcrTokenInfo,
ground_element,
)
from core.navigation.visual_verifier import (
OcrClient,
ScreenMatchResult,
VlmClient,
verify_before,
verify_after,
)
logger = logging.getLogger(__name__)
# ── Dataclasses ──────────────────────────────────────────────────────
@dataclass
class LoginFormConfig:
"""Configuration for a login form — what to look for."""
login_field: Dict[str, Any] # {"role": "champ", "text": "Login"}
password_field: Dict[str, Any] # {"role": "champ", "text": "Mot de passe"}
submit_button: Dict[str, Any] # {"role": "bouton", "text": "Connexion"}
success_elements: List[Dict[str, Any]] = field(default_factory=list)
context: str = "" # e.g. "DPI urgences"
@dataclass
class LoginResolution:
"""Result of login form resolution — grounded coords for each field."""
login_field: Optional[GroundedElement] = None
password_field: Optional[GroundedElement] = None
submit_button: Optional[GroundedElement] = None
all_resolved: bool = False
method: str = "" # "ocr_anchor", "vlm_grounder", "mixed", "cache"
def describe(self) -> str:
parts = []
if self.login_field:
parts.append(f"login@{self.login_field.center} ({self.login_field.method})")
else:
parts.append("login: NOT FOUND")
if self.password_field:
parts.append(f"password@{self.password_field.center} ({self.password_field.method})")
else:
parts.append("password: NOT FOUND")
if self.submit_button:
parts.append(f"button@{self.submit_button.center} ({self.submit_button.method})")
else:
parts.append("button: NOT FOUND")
status = "OK" if self.all_resolved else "INCOMPLETE"
return f"Login resolution [{status}]: " + ", ".join(parts)
# ── Default configs ──────────────────────────────────────────────────
def dpi_urgences_login_config() -> LoginFormConfig:
"""Default config for DPI urgences login form."""
return LoginFormConfig(
login_field={"role": "champ", "text": "Login", "extra": "champ identifiant"},
password_field={"role": "champ", "text": "Mot de passe", "extra": "champ password"},
submit_button={"role": "bouton", "text": "Connexion", "extra": "bouton submit"},
success_elements=[
{"role": "page", "text": "Accueil"},
{"role": "page", "text": "Dashboard"},
],
context="DPI urgences — page login",
)
# ── Helper ───────────────────────────────────────────────────────────
def _ocr_detailed_to_simple(ocr_detailed: OcrDetailedClient) -> OcrClient:
"""Convert OcrDetailedClient (text+bbox) to OcrClient (text-only) for verification."""
def client(image_path: str) -> List[str]:
return [t.text for t in ocr_detailed(image_path)]
return client
# ── Core functions ───────────────────────────────────────────────────
def verify_login_visible(
screenshot_path: str,
config: LoginFormConfig,
ocr_client: OcrClient,
vlm_client: VlmClient,
) -> ScreenMatchResult:
"""Verify login form is visible on screen (pre-condition).
Checks that login field, password field, and submit button are present.
Uses OCR-anchored verification (deterministic presence, VLM role).
"""
expected = [
config.login_field,
config.password_field,
config.submit_button,
]
return verify_before(
screenshot_path, expected, ocr_client, vlm_client,
context=config.context,
)
def verify_login_success(
screenshot_path: str,
config: LoginFormConfig,
ocr_client: OcrClient,
vlm_client: VlmClient,
) -> ScreenMatchResult:
"""Verify dashboard/accueil visible after login (post-condition).
Higher threshold (verify_after = 0.8) — false positive = Léa proceeds wrong.
"""
if not config.success_elements:
# No success criteria defined → can't verify
return ScreenMatchResult(
match=False,
confidence=0.0,
reason="no success_elements defined in config",
)
return verify_after(
screenshot_path, config.success_elements, ocr_client, vlm_client,
context=f"POST-LOGIN: {config.context}",
)
def resolve_login_form(
screenshot_path: str,
config: LoginFormConfig,
ocr_client: OcrDetailedClient,
vlm_client: VlmClient,
screen_width: int = 1920,
screen_height: int = 1080,
coords_cache: Optional[CoordsCache] = None,
) -> LoginResolution:
"""Ground all login form elements → coords for runtime action.
Resolution strategy per element:
1. Cache hit → return cached coords (validated separately)
2. OCR-anchor → deterministic bbox from OCR token
3. VLM grounder → fallback visual grounding
Returns LoginResolution with grounded coords for each field.
Runtime uses these coords to type/click.
"""
login_el = ground_element(
screenshot_path, config.login_field,
ocr_client=ocr_client, vlm_client=vlm_client,
screen_width=screen_width, screen_height=screen_height,
coords_cache=coords_cache, context=config.context,
)
password_el = ground_element(
screenshot_path, config.password_field,
ocr_client=ocr_client, vlm_client=vlm_client,
screen_width=screen_width, screen_height=screen_height,
coords_cache=coords_cache, context=config.context,
)
button_el = ground_element(
screenshot_path, config.submit_button,
ocr_client=ocr_client, vlm_client=vlm_client,
screen_width=screen_width, screen_height=screen_height,
coords_cache=coords_cache, context=config.context,
)
all_resolved = login_el is not None and password_el is not None and button_el is not None
# Determine overall method
methods = []
if login_el:
methods.append(login_el.method)
if password_el:
methods.append(password_el.method)
if button_el:
methods.append(button_el.method)
unique_methods = set(methods)
if len(unique_methods) == 1:
method = unique_methods.pop()
elif len(unique_methods) > 1:
method = "mixed"
else:
method = ""
resolution = LoginResolution(
login_field=login_el,
password_field=password_el,
submit_button=button_el,
all_resolved=all_resolved,
method=method,
)
if all_resolved:
logger.info("resolve_login_form: %s", resolution.describe())
else:
logger.warning("resolve_login_form: incomplete — %s", resolution.describe())
return resolution

View File

@@ -0,0 +1,408 @@
"""Visual verifier — verify_before / verify_after avec ancrage OCR.
Architecture OCR-ancrée (challenge Claude 01/07, gate-vert 30/06) :
- PRESENCE = tokens OCR (déterministe, pas d'hallucination possible)
- RÔLE = VLM confirmation (semantic, ancré sur tokens OCR trouvés)
- VLM ne décide JAMAIS de la présence d'un élément
- Faux positif impossible par construction ; faux négatif = retry acceptable
Pattern d'injection : OcrClient + VlmClient injectables (tests sans réseau).
"""
from __future__ import annotations
import json
import logging
import re
import unicodedata
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
# Type aliases — injectable callables for offline testing
VlmClient = Callable[[str, str], str] # (image_path, prompt) -> text
OcrClient = Callable[[str], List[str]] # (image_path) -> list of OCR text strings
@dataclass
class ScreenMatchResult:
"""Result of a screen verification check."""
match: bool
confidence: float = 0.0
reason: str = ""
observed_elements: List[Dict[str, Any]] = field(default_factory=list)
expected_elements: List[Dict[str, Any]] = field(default_factory=list)
mismatches: List[str] = field(default_factory=list)
def describe(self) -> str:
if self.match:
return f"Screen match OK (conf={self.confidence:.2f})"
parts = [f"Screen mismatch (conf={self.confidence:.2f})"]
if self.mismatches:
parts.append("missing: " + ", ".join(self.mismatches))
if self.reason:
parts.append(self.reason)
return " | ".join(parts)
# ── Text normalization (pure functions) ────────────────────────────────
def normalize_text(text: str) -> str:
"""Normalize text for fuzzy matching: lowercase, strip accents, collapse whitespace."""
text = text.lower().strip()
# Strip accents: é→e, è→e, ê→e, à→a, etc.
text = unicodedata.normalize("NFKD", text)
text = "".join(c for c in text if not unicodedata.combining(c))
# Collapse whitespace
text = re.sub(r"\s+", " ", text)
return text
def fuzzy_match(expected: str, observed: str, threshold: float = 0.8) -> bool:
"""Check if observed text fuzzy-matches expected text.
Three strategies (any wins):
1. Exact match after normalization
2. Substring containment (either direction)
3. SequenceMatcher ratio >= threshold
"""
norm_expected = normalize_text(expected)
norm_observed = normalize_text(observed)
if norm_expected == norm_observed:
return True
if norm_expected in norm_observed or norm_observed in norm_expected:
return True
ratio = SequenceMatcher(None, norm_expected, norm_observed).ratio()
return ratio >= threshold
# ── OCR presence check (deterministic, no VLM) ──────────────────────
@dataclass
class OcrPresenceResult:
"""Result of OCR-based presence check."""
found_texts: Dict[str, str] = field(default_factory=dict)
missing: List[str] = field(default_factory=list)
all_found: bool = False
@property
def presence_ratio(self) -> float:
if not self.found_texts:
return 1.0
found_count = sum(1 for v in self.found_texts.values() if v != "")
return found_count / len(self.found_texts)
def ocr_presence_check(
ocr_tokens: List[str],
expected_elements: List[Dict[str, Any]],
fuzzy_threshold: float = 0.8,
) -> OcrPresenceResult:
"""Check presence of expected texts against OCR tokens (deterministic).
Pure function — no VLM call, zero hallucination risk.
"""
found_texts: Dict[str, str] = {}
missing: List[str] = []
for el in expected_elements:
expected_text = el.get("text", "")
if not expected_text:
found_texts[""] = ""
continue
matched_ocr = ""
for token in ocr_tokens:
if fuzzy_match(expected_text, token, threshold=fuzzy_threshold):
matched_ocr = token
break
if matched_ocr:
found_texts[expected_text] = matched_ocr
else:
found_texts[expected_text] = ""
missing.append(f"{el.get('role', '?')}: {expected_text}")
all_found = len(missing) == 0
return OcrPresenceResult(
found_texts=found_texts,
missing=missing,
all_found=all_found,
)
# ── VLM role confirmation (semantic, anchored on found OCR texts) ────
def build_role_confirm_prompt(
found_elements: List[Dict[str, Any]],
expected_elements: List[Dict[str, Any]],
context: str = "",
) -> str:
"""Build VLM prompt for role confirmation of OCR-found elements.
VLM receives found texts and confirms their ROLE only — never presence.
"""
found_lines = []
for i, el in enumerate(found_elements):
matched_ocr = el.get("matched_ocr", "")
expected_role = el.get("expected_role", "?")
line = f"{i+1}. Text \"{matched_ocr}\" — expected role: {expected_role}"
found_lines.append(line)
found_block = "\n".join(found_lines)
prompt = (
"You are a screen role validator. OCR has confirmed these texts are "
"present on the screen. Your job is ONLY to confirm their ROLE — "
"do NOT re-declare whether they are present.\n"
)
if context:
prompt += f"Context: {context}\n"
prompt += (
f"Found texts with expected roles:\n{found_block}\n\n"
"Respond in JSON format:\n"
"{\"confirmed\": [{\"index\": 1, \"role_confirmed\": true/false, "
"\"actual_role\": \"...\", \"confidence\": 0.0-1.0}], "
"\"overall_confidence\": 0.0-1.0}\n"
"Only confirm role_confirmed=true if the text clearly plays the "
"expected role (e.g., a button, not just a label with the same text)."
)
return prompt
def parse_role_confirm_response(vlm_text: str) -> Dict[str, Any]:
"""Parse VLM role confirmation JSON response."""
try:
data = json.loads(vlm_text)
except json.JSONDecodeError:
json_match = re.search(r"\{[\s\S]*\}", vlm_text)
if json_match:
try:
data = json.loads(json_match.group())
except json.JSONDecodeError:
logger.warning("role_confirm: VLM response not parseable as JSON")
return {"confirmed": [], "overall_confidence": 0.0}
else:
return {"confirmed": [], "overall_confidence": 0.0}
confirmed = data.get("confirmed", [])
overall_conf = data.get("overall_confidence", 0.0)
if isinstance(overall_conf, str):
try:
overall_conf = float(overall_conf)
except ValueError:
overall_conf = 0.0
return {
"confirmed": confirmed,
"overall_confidence": float(overall_conf),
}
# ── Core verification (OCR-anchored composition) ────────────────────
def verify_screen_match(
screenshot_path: str,
expected_elements: List[Dict[str, Any]],
ocr_client: OcrClient,
vlm_client: VlmClient,
context: str = "",
min_confidence: float = 0.7,
) -> ScreenMatchResult:
"""Verify screen state with OCR-anchored presence + VLM role confirmation.
Step 1: OCR screenshot → tokens → deterministic presence check
Step 2: VLM confirms role of found elements (not presence!)
Eliminates VLM self-report hallucination for presence checks.
"""
if not expected_elements:
return ScreenMatchResult(
match=True,
confidence=1.0,
reason="no expected elements to verify",
)
# Step 1: OCR presence check (deterministic)
try:
ocr_tokens = ocr_client(screenshot_path)
except Exception as e:
logger.warning("verify_screen_match: OCR call failed (%s)", e)
return ScreenMatchResult(
match=False,
confidence=0.0,
reason=f"OCR error: {e}",
expected_elements=expected_elements,
)
presence = ocr_presence_check(ocr_tokens, expected_elements)
if not presence.all_found:
observed = []
for el in expected_elements:
text = el.get("text", "")
matched = presence.found_texts.get(text, "")
observed.append({
"role": el.get("role", "?"),
"expected_text": text,
"matched_ocr": matched,
"found": matched != "",
})
return ScreenMatchResult(
match=False,
confidence=presence.presence_ratio,
reason="OCR presence check: some texts not found",
observed_elements=observed,
expected_elements=expected_elements,
mismatches=presence.missing,
)
# Step 2: VLM role confirmation (only for found elements)
found_elements = []
for el in expected_elements:
text = el.get("text", "")
matched_ocr = presence.found_texts.get(text, "")
if text and matched_ocr:
found_elements.append({
"text": text,
"expected_role": el.get("role", "?"),
"matched_ocr": matched_ocr,
})
if not found_elements:
# All elements had no text → presence trivially OK
return ScreenMatchResult(
match=True,
confidence=1.0,
reason="no text-based elements to verify",
expected_elements=expected_elements,
)
prompt = build_role_confirm_prompt(found_elements, expected_elements, context)
try:
vlm_text = vlm_client(screenshot_path, prompt)
except Exception as e:
logger.warning("verify_screen_match: VLM role confirm failed (%s)", e)
observed = []
for el in expected_elements:
text = el.get("text", "")
observed.append({
"role": el.get("role", "?"),
"expected_text": text,
"matched_ocr": presence.found_texts.get(text, ""),
"found": True,
"role_confirmed": False,
"role_confidence": 0.0,
})
return ScreenMatchResult(
match=True,
confidence=0.5,
reason=f"OCR presence OK, VLM role confirm failed: {e}",
observed_elements=observed,
expected_elements=expected_elements,
)
parsed = parse_role_confirm_response(vlm_text)
overall_conf = parsed.get("overall_confidence", 0.0)
confirmed = parsed.get("confirmed", [])
observed = []
role_mismatches = []
for i, el in enumerate(expected_elements):
text = el.get("text", "")
expected_role = el.get("role", "?")
matched_ocr = presence.found_texts.get(text, "")
role_entry = None
for c in confirmed:
if c.get("index") == i + 1:
role_entry = c
break
role_confirmed = False
actual_role = ""
role_confidence = 0.0
if role_entry:
role_confirmed = role_entry.get("role_confirmed", False)
actual_role = role_entry.get("actual_role", "")
role_confidence = role_entry.get("confidence", 0.0)
if isinstance(role_confidence, str):
try:
role_confidence = float(role_confidence)
except ValueError:
role_confidence = 0.0
observed.append({
"role": expected_role,
"expected_text": text,
"matched_ocr": matched_ocr,
"found": True,
"role_confirmed": role_confirmed,
"actual_role": actual_role,
"role_confidence": role_confidence,
})
if not role_confirmed or role_confidence < min_confidence:
role_mismatches.append(
f"{expected_role}: {text} (actual={actual_role}, conf={role_confidence:.2f})"
)
is_match = len(role_mismatches) == 0 and overall_conf >= min_confidence
return ScreenMatchResult(
match=is_match,
confidence=overall_conf,
reason=f"OCR presence: {presence.presence_ratio:.0%}, VLM role: {overall_conf:.2f}",
observed_elements=observed,
expected_elements=expected_elements,
mismatches=presence.missing + role_mismatches,
)
def verify_before(
screenshot_path: str,
expected_elements: List[Dict[str, Any]],
ocr_client: OcrClient,
vlm_client: VlmClient,
context: str = "",
) -> ScreenMatchResult:
"""Verify screen state BEFORE an action (OCR-anchored).
Checks pre-conditions: expected texts present + roles correct.
min_confidence=0.7 — some tolerance for pre-action verification.
"""
return verify_screen_match(
screenshot_path, expected_elements, ocr_client, vlm_client,
context=f"PRE-ACTION: {context}", min_confidence=0.7,
)
def verify_after(
screenshot_path: str,
expected_elements: List[Dict[str, Any]],
ocr_client: OcrClient,
vlm_client: VlmClient,
context: str = "",
) -> ScreenMatchResult:
"""Verify screen state AFTER an action (OCR-anchored).
Checks post-conditions with higher threshold (0.8).
False positive = Léa proceeds on wrong assumption → stricter gate.
"""
return verify_screen_match(
screenshot_path, expected_elements, ocr_client, vlm_client,
context=f"POST-ACTION: {context}", min_confidence=0.8,
)

253
deploy/build_package_full.sh Executable file
View File

@@ -0,0 +1,253 @@
#!/bin/bash
# ============================================================
# build_package_full.sh — Construit le ZIP Lea COMPLET autoportant
# ------------------------------------------------------------
#
# Produit : deploy/build/Lea_full_v<version>.zip
#
# Ce ZIP est destine a etre servi par le dashboard Fleet
# (web_dashboard/app.py -> /api/fleet/download/<machine_id>).
# Contrairement a deploy/Lea_v1.0.0.zip (sources seules, suppose
# Python systeme), ce ZIP est 100% autonome :
#
# - Code source Lea A JOUR (working tree courant du repo,
# via build_package.sh : agent_v0/agent_v1, lea_ui, run_agent_v1)
# - Runtime Python 3.12 embedded complet (python-embed/)
# avec toutes les dependances pre-installees (mss, pynput,
# pystray, plyer, requests, PIL, pywin32, socketio...)
# - Lea.bat pointant directement sur python-embed\pythonw.exe
# (version embedded de configure_embed.ps1 : ni venv, ni pip,
# ni reseau, ni Python systeme)
# - python312._pth patche (import site active)
# - Lea/config.txt placeholder (CONFIGURE_ME) que le dashboard
# remplace a la volee par la config de l'agent
# - PAS de install.bat (plus aucune etape d'installation Python)
#
# Experience utilisateur cible (non-IT) :
# dezipper -> double-clic Lea.bat -> Lea demarre dans le systray.
# Aucune installation de Python, aucun UAC.
#
# Usage :
# ./deploy/build_package_full.sh # Build complet
# ./deploy/build_package_full.sh --clean # Nettoyer avant
#
# Pre-requis :
# - bash, rsync, zip
# - deploy/installer/python-3.12-embed/ (runtime embedded, ~80 Mo,
# non versionne — restaure depuis lea_python_embed_working.tgz si absent)
# ============================================================
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # racine repo
INSTALLER_DIR="$SCRIPT_DIR/installer"
STAGING_DIR="$SCRIPT_DIR/build/installer_staging"
BUILD_DIR="$SCRIPT_DIR/build"
ASSEMBLY_DIR="$BUILD_DIR/Lea_full_assembly" # arborescence Lea/ temporaire
# Version lue depuis la source courante.
# NB : la ligne peut etre soit AGENT_VERSION = "1.0.1" soit
# AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1").
# La regex de build_package.sh/build_installer.sh ne gere QUE la 1ere forme
# (et retombe sur 1.0.0 pour la 2e). Ici on prend le DERNIER litteral entre
# guillemets de la ligne AGENT_VERSION (= la valeur par defaut effective),
# pour nommer le ZIP de maniere stable quelle que soit la forme.
VERSION=$(grep -m1 'AGENT_VERSION' "$PROJECT_ROOT/agent_v0/agent_v1/config.py" \
| grep -oP '"[^"]+"' | tr -d '"' | tail -1)
VERSION="${VERSION:-1.0.0}"
OUTPUT_ZIP="$BUILD_DIR/Lea_full_v${VERSION}.zip"
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} Build ZIP Lea COMPLET autoportant v${VERSION}${NC}"
echo -e "${GREEN}============================================================${NC}"
echo ""
CLEAN=0
for arg in "$@"; do
case "$arg" in
--clean) CLEAN=1 ;;
*) echo "Argument inconnu : $arg" ;;
esac
done
# ---------------------------------------------------------------
# 1. Regenerer le staging depuis la SOURCE COURANTE du repo
# build_installer.sh --stage-only appelle build_package.sh
# (qui copie agent_v0/agent_v1, lea_ui, run_agent_v1.py courants)
# puis ajoute python-3.12-embed/ + helpers, et exclut install.bat.
#
# --clean est TOUJOURS force : sans lui, build_installer.sh reutilise
# un deploy/build/Lea/ deja present (cache du build precedent) et ne
# re-execute PAS build_package.sh -> la source embarquee serait perimee.
# On veut au contraire garantir le working tree COURANT du repo.
# ---------------------------------------------------------------
echo "[1/6] Regeneration du staging depuis la source courante (--clean force)..."
bash "$INSTALLER_DIR/build_installer.sh" --stage-only --clean
if [[ ! -d "$STAGING_DIR" ]]; then
echo -e "${RED} ERREUR : staging $STAGING_DIR absent apres build_installer.sh${NC}"
exit 1
fi
echo " Staging pret : $STAGING_DIR"
echo ""
# ---------------------------------------------------------------
# 2. Assembler l'arborescence Lea/ (prefixe attendu par le dashboard
# qui remplace exactement 'Lea/config.txt').
# ---------------------------------------------------------------
echo "[2/6] Assemblage de l'arborescence Lea/..."
rm -rf "$ASSEMBLY_DIR"
mkdir -p "$ASSEMBLY_DIR/Lea"
# Copier le staging, en renommant python-3.12-embed -> python-embed
# (chemin attendu par le Lea.bat embedded : %~dp0python-embed\pythonw.exe)
rsync -a \
--exclude='python-3.12-embed' \
--exclude='install.bat' \
--exclude='config.txt' \
"$STAGING_DIR/" \
"$ASSEMBLY_DIR/Lea/"
rsync -a "$STAGING_DIR/python-3.12-embed/" "$ASSEMBLY_DIR/Lea/python-embed/"
echo " Source + python-embed/ assembles"
echo ""
# ---------------------------------------------------------------
# 3. Lea.bat embedded : extraire le bloc canonique de configure_embed.ps1
# (le here-string $NewLeaBat). C'est la SEULE source de verite du
# Lea.bat embedded ; on ne le duplique pas dans ce script.
# ---------------------------------------------------------------
echo "[3/6] Generation de Lea.bat (runtime embedded)..."
LEA_BAT_OUT="$ASSEMBLY_DIR/Lea/Lea.bat"
python3 - "$INSTALLER_DIR/configure_embed.ps1" "$LEA_BAT_OUT" <<'PYEOF'
import sys, re
ps1_path, out_path = sys.argv[1], sys.argv[2]
text = open(ps1_path, encoding="utf-8").read()
# Extrait le here-string PowerShell : $NewLeaBat = @" ... "@
m = re.search(r'\$NewLeaBat\s*=\s*@"\r?\n(.*?)\r?\n"@', text, re.DOTALL)
if not m:
sys.exit("ERREUR : bloc $NewLeaBat introuvable dans configure_embed.ps1")
content = m.group(1)
# CRLF pour un .bat Windows
content = content.replace("\r\n", "\n").replace("\n", "\r\n")
if not content.endswith("\r\n"):
content += "\r\n"
open(out_path, "wb").write(content.encode("ascii"))
print(f" Lea.bat genere depuis configure_embed.ps1 ({len(content)} octets)")
PYEOF
# Installateur 1-clic non-IT (raccourci Bureau + Demarrage automatique,
# per-user, sans admin). Asset statique CRLF/ASCII copie tel quel dans Lea/.
INSTALLER_BAT_SRC="$INSTALLER_DIR/Installer-Lea.bat"
if [[ ! -f "$INSTALLER_BAT_SRC" ]]; then
echo -e "${RED} ERREUR : $INSTALLER_BAT_SRC introuvable${NC}"
exit 1
fi
cp "$INSTALLER_BAT_SRC" "$ASSEMBLY_DIR/Lea/Installer-Lea.bat"
echo " Installer-Lea.bat (installation 1-clic) ajoute"
# Notice utilisateur dediee a l'install autonome (remplace la LISEZMOI legacy
# du staging, qui decrit l'ancien flux install.bat + Python systeme).
LISEZMOI_SRC="$INSTALLER_DIR/LISEZMOI-autonome.txt"
if [[ -f "$LISEZMOI_SRC" ]]; then
cp "$LISEZMOI_SRC" "$ASSEMBLY_DIR/Lea/LISEZMOI.txt"
echo " LISEZMOI.txt (version install autonome) pose"
fi
echo ""
# ---------------------------------------------------------------
# 4. Patcher python312._pth (import site active) — idempotent.
# Necessaire pour que l'embed charge site-packages.
# ---------------------------------------------------------------
echo "[4/6] Patch python312._pth (import site)..."
PTH_FILE=$(find "$ASSEMBLY_DIR/Lea/python-embed" -name "python*._pth" | head -1)
if [[ -z "$PTH_FILE" ]]; then
echo -e "${RED} ERREUR : python*._pth introuvable dans python-embed/${NC}"
exit 1
fi
# Decommente '#import site' s'il est commente ; sinon laisse tel quel.
sed -i 's/^#import site/import site/' "$PTH_FILE"
if ! grep -q '^import site' "$PTH_FILE"; then
printf 'import site\r\n' >> "$PTH_FILE"
fi
echo " $(basename "$PTH_FILE") : import site actif"
echo ""
# ---------------------------------------------------------------
# 5. config.txt placeholder (CONFIGURE_ME) — cible de l'injection
# dashboard (app.py remplace 'Lea/config.txt').
# ---------------------------------------------------------------
echo "[5/6] Pose du config.txt placeholder..."
cp "$INSTALLER_DIR/../lea_package/config.txt" "$ASSEMBLY_DIR/Lea/config.txt"
if ! grep -q 'CONFIGURE_ME' "$ASSEMBLY_DIR/Lea/config.txt"; then
echo -e "${YELLOW} AVERTISSEMENT : config.txt ne contient pas CONFIGURE_ME (placeholder inattendu)${NC}"
fi
echo " Lea/config.txt (placeholder) pose"
echo ""
# ---------------------------------------------------------------
# 6. Validation de completude AVANT zip (un ZIP incomplet = install
# cassee chez le client non-IT).
# ---------------------------------------------------------------
echo "[6/6] Validation + creation du ZIP..."
REQUIRED=(
"Lea/run_agent_v1.py"
"Lea/agent_v1/config.py"
"Lea/agent_v1/main.py"
"Lea/lea_ui/server_client.py"
"Lea/Lea.bat"
"Lea/Installer-Lea.bat"
"Lea/config.txt"
"Lea/python-embed/python.exe"
"Lea/python-embed/pythonw.exe"
"Lea/python-embed/Lib/site-packages/mss"
"Lea/python-embed/Lib/site-packages/win32"
"Lea/python-embed/Lib/site-packages/socketio"
"Lea/python-embed/Lib/site-packages/httpx"
"Lea/python-embed/Lib/site-packages/httpcore"
"Lea/python-embed/Lib/site-packages/h11"
"Lea/python-embed/Lib/site-packages/anyio"
"Lea/python-embed/Lib/site-packages/typing_extensions.py"
MISSING=()
for f in "${REQUIRED[@]}"; do
[[ -e "$ASSEMBLY_DIR/$f" ]] || MISSING+=("$f")
done
# install.bat NE DOIT PAS etre present
if [[ -e "$ASSEMBLY_DIR/Lea/install.bat" ]]; then
echo -e "${RED} ERREUR : install.bat present dans l'assemblage (doit etre absent).${NC}"
exit 1
fi
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo -e "${RED} ERREUR : assemblage incomplet. Manquants :${NC}"
printf ' - %s\n' "${MISSING[@]}"
exit 1
fi
echo " Completude verifiee (${#REQUIRED[@]} elements, install.bat absent)"
# Verif source A JOUR : le config.py embarque doit etre identique au repo
if ! diff -q "$PROJECT_ROOT/agent_v0/agent_v1/config.py" "$ASSEMBLY_DIR/Lea/agent_v1/config.py" >/dev/null; then
echo -e "${RED} ERREUR : agent_v1/config.py embarque DIFFERE de la source repo !${NC}"
echo " Le ZIP n'embarque pas la source a jour — build interrompu."
exit 1
fi
echo " Source a jour confirmee (agent_v1/config.py == repo)"
rm -f "$OUTPUT_ZIP"
( cd "$ASSEMBLY_DIR" && zip -q -r -X "$OUTPUT_ZIP" Lea )
ZIP_SIZE=$(du -h "$OUTPUT_ZIP" | cut -f1)
echo ""
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} ZIP complet produit !${NC}"
echo -e "${GREEN}============================================================${NC}"
echo ""
echo " Fichier : $OUTPUT_ZIP"
echo " Taille : $ZIP_SIZE"
echo ""
echo " Servi par le dashboard via web_dashboard/app.py (_LEA_ZIP_TEMPLATE)."
echo " L'utilisateur : dezippe -> double-clic Lea.bat (aucun Python systeme requis)."
echo ""

60
deploy/dgx/vm_launch.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/bin/bash
# Persistent VM launch — starts swtpm first, waits for socket, then QEMU
# VNC only, no SPICE (POC configuration)
VMROOT=/home/aivanov/quickemu-win11-arm-lea
SWTPM_SOCK="$VMROOT/windows-11-arm-lea.swtpm-sock"
/usr/bin/swtpm socket \
--ctrl type=unixio,path="$SWTPM_SOCK" \
--terminate \
--tpmstate dir="$VMROOT" \
--tpm2 &
# Wait for swtpm socket (up to 10s)
for _i in $(seq 1 100); do
if [ -S "$SWTPM_SOCK" ]; then break; fi
sleep 0.1
done
if [ ! -S "$SWTPM_SOCK" ]; then
echo "ERROR: swtpm socket not ready after 10s"
exit 1
fi
exec /usr/bin/qemu-system-aarch64 \
-name windows-11-arm-lea \
-machine virt,highmem=on,pflash0=rom,pflash1=efivars,accel=kvm \
-global kvm-pit.lost_tick_policy=discard \
-cpu host \
-smp cores=8,threads=1,sockets=1 \
-m 8G \
-device virtio-balloon \
-pidfile "$VMROOT/windows-11-arm-lea.pid" \
-rtc base=utc,clock=host \
-device ramfb \
-vga none \
-device virtio-gpu-pci,id=video0,xres=1280,yres=800 \
-display none \
-vnc 127.0.0.1:2,password=on \
-device virtio-serial-pci \
-chardev socket,id=agent0,path="$VMROOT/windows-11-arm-lea-agent.sock",server=on,wait=off \
-device virtserialport,chardev=agent0,name=org.qemu.guest_agent.0 \
-device virtio-rng-pci,rng=rng0 \
-object rng-random,id=rng0,filename=/dev/urandom \
-device qemu-xhci,id=input \
-device usb-kbd,bus=input.0 \
-k fr \
-device usb-tablet,bus=input.0 \
-device virtio-net-pci,netdev=nic \
-netdev user,hostname=windows-11-arm-lea,hostfwd=tcp::22220-:22,id=nic \
-blockdev node-name=rom,driver=file,filename=/usr/share/AAVMF/AAVMF_CODE.no-secboot.fd,read-only=true \
-blockdev node-name=efivars,driver=file,filename="$VMROOT/OVMF_VARS.fd" \
-device virtio-scsi-pci,id=scsi0 \
-device scsi-hd,drive=SystemDisk,bus=scsi0.0,bootindex=2 \
-drive id=SystemDisk,if=none,format=qcow2,file="$VMROOT/disk.qcow2",discard=unmap,detect-zeroes=unmap,cache=writeback,aio=threads \
-chardev socket,id=chrtpm,path="$SWTPM_SOCK" \
-tpmdev emulator,id=tpm0,chardev=chrtpm \
-device tpm-tis-device,tpmdev=tpm0 \
-monitor unix:"$VMROOT/windows-11-arm-lea-monitor.socket",server,nowait \
-serial unix:"$VMROOT/windows-11-arm-lea-serial.socket",server,nowait \
2>"$VMROOT/qemu.log"

50
deploy/dgx/vm_stop.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Persistent VM stop — ACPI poweroff via QEMU monitor, then SIGTERM, then SIGKILL
# Kill swtpm after QEMU exits. Cleanup PID/sockets.
VMROOT=/home/aivanov/quickemu-win11-arm-lea
MONITOR_SOCKET="$VMROOT/windows-11-arm-lea-monitor.socket"
PIDFILE="$VMROOT/windows-11-arm-lea.pid"
# Step 1: Send ACPI poweroff via QEMU monitor
if [ -S "$MONITOR_SOCKET" ]; then
echo "system_powerdown" | socat - UNIX-CONNECT:"$MONITOR_SOCKET" - > /dev/null 2>&1
echo "ACPI poweroff sent, waiting 30s..."
for i in $(seq 1 30); do
if [ ! -f "$PIDFILE" ] || ! ps -p "$(cat "$PIDFILE" 2>/dev/null)" > /dev/null 2>&1; then
echo "QEMU exited gracefully"
exit 0
fi
sleep 1
done
fi
# Step 2: SIGTERM (10s more)
QEMU_PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -n "$QEMU_PID" ] && ps -p "$QEMU_PID" > /dev/null 2>&1; then
echo "Still running, sending SIGTERM..."
kill "$QEMU_PID" 2>/dev/null
for i in $(seq 1 10); do
if ! ps -p "$QEMU_PID" > /dev/null 2>&1; then
echo "QEMU exited after SIGTERM"
break
fi
sleep 1
done
fi
# Step 3: SIGKILL
QEMU_PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -n "$QEMU_PID" ] && ps -p "$QEMU_PID" > /dev/null 2>&1; then
echo "Still running, SIGKILL..."
kill -9 "$QEMU_PID" 2>/dev/null
sleep 2
fi
# Step 4: Kill swtpm
pkill -f "swtpm.*windows-11-arm-lea" 2>/dev/null
sleep 2
# Step 5: Cleanup
rm -f "$PIDFILE" "$VMROOT"/*.sock "$VMROOT"/*.socket 2>/dev/null
echo "VM stop complete"

View File

@@ -0,0 +1,152 @@
@echo off
chcp 65001 >nul 2>&1
title Lea - Installation 1-clic
setlocal EnableDelayedExpansion
:: ============================================================
:: Installer-Lea.bat - Installation 1-clic per-user (sans admin)
:: ------------------------------------------------------------
:: - Copie le paquet Lea (y compris python-embed) vers
:: %LOCALAPPDATA%\Lea (emplacement stable per-user).
:: - Cree un raccourci sur le Bureau.
:: - Cree un raccourci dans le dossier Demarrage (lancement
:: automatique a chaque ouverture de session Windows).
:: - Lance Lea une premiere fois (pythonw, sans console).
::
:: Aucun droit administrateur requis. Aucun service Windows
:: (Lea est une application systray, doit tourner dans la
:: session utilisateur).
:: ============================================================
echo.
echo ============================================================
echo Lea - Installation
echo ============================================================
echo.
:: --- Emplacement source (dossier de ce script) -------------
set "SRC=%~dp0"
:: Retirer l'antislash final eventuel
if "%SRC:~-1%"=="\" set "SRC=%SRC:~0,-1%"
:: --- Emplacement cible per-user ----------------------------
set "DEST=%LOCALAPPDATA%\Lea"
echo Installation vers : %DEST%
echo (copie du runtime embarque, cela prend quelques secondes)
echo.
:: --- Verification du runtime embarque dans la source -------
if not exist "%SRC%\python-embed\pythonw.exe" (
echo ERREUR : python-embed\pythonw.exe introuvable dans le paquet.
echo Le paquet semble incomplet. Re-telechargez Lea depuis le tableau de bord.
echo.
pause
exit /b 1
)
:: --- Si Lea tourne deja depuis la cible, l'arreter ----------
if exist "%DEST%\lea_agent.lock" (
for /f "usebackq tokens=* delims=" %%i in ("%DEST%\lea_agent.lock") do (
taskkill /F /PID %%i >nul 2>&1
)
del /f /q "%DEST%\lea_agent.lock" >nul 2>&1
timeout /t 1 >nul
)
:: --- Copie du paquet vers la cible -------------------------
:: robocopy : robuste pour la grosse arborescence python-embed.
:: /E sous-dossiers (vides inclus), /NFL /NDL /NJH /NJS /NP silencieux.
:: Codes de sortie robocopy < 8 = succes ; >= 8 = echec.
if not exist "%DEST%" mkdir "%DEST%" >nul 2>&1
robocopy "%SRC%" "%DEST%" /E /NFL /NDL /NJH /NJS /NP >nul
if %ERRORLEVEL% GEQ 8 (
echo robocopy a echoue, tentative avec xcopy...
xcopy "%SRC%\*" "%DEST%\" /E /I /H /Y >nul
if errorlevel 1 (
echo.
echo ERREUR : la copie vers %DEST% a echoue.
echo Verifiez l'espace disque et les droits sur votre profil.
echo.
pause
exit /b 1
)
)
echo Copie terminee - OK
echo.
:: --- Ne pas laisser l'installeur se relancer en boucle -----
:: (on supprime la copie de l'installeur dans la cible : inutile une fois installe)
del /f /q "%DEST%\Installer-Lea.bat" >nul 2>&1
:: --- Detection d'une icone optionnelle ---------------------
:: Cherche un .ico dans le paquet installe (best-effort).
set "ICON="
for /f "delims=" %%f in ('dir /b /s "%DEST%\*.ico" 2^>nul') do (
if not defined ICON set "ICON=%%f"
)
:: --- Cibles des raccourcis ---------------------------------
set "TARGET=%DEST%\python-embed\pythonw.exe"
set "ARGS=run_agent_v1.py"
set "WORKDIR=%DEST%"
set "DESKTOP_LNK=%USERPROFILE%\Desktop\Lea.lnk"
set "STARTUP_LNK=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Lea.lnk"
:: --- Creation des raccourcis via PowerShell (WScript.Shell) -
echo Creation des raccourcis (Bureau + Demarrage automatique)...
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$ws = New-Object -ComObject WScript.Shell;" ^
"foreach ($p in @('%DESKTOP_LNK%','%STARTUP_LNK%')) {" ^
" $dir = Split-Path $p -Parent;" ^
" if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }" ^
" $s = $ws.CreateShortcut($p);" ^
" $s.TargetPath = '%TARGET%';" ^
" $s.Arguments = '%ARGS%';" ^
" $s.WorkingDirectory = '%WORKDIR%';" ^
" $s.Description = 'Lea - Assistante IA';" ^
" if ('%ICON%' -ne '' -and (Test-Path '%ICON%')) { $s.IconLocation = '%ICON%' }" ^
" $s.Save();" ^
"}"
if errorlevel 1 (
echo ATTENTION : la creation des raccourcis a partiellement echoue.
echo Vous pourrez tout de meme lancer Lea via %TARGET%.
) else (
echo Raccourcis crees - OK
)
echo.
:: --- Premier lancement de Lea (sans console) ---------------
echo Demarrage de Lea...
pushd "%DEST%"
start "" /b "%TARGET%" %ARGS%
popd
:: --- Verification rapide (via le lock PID) -----------------
timeout /t 3 >nul
set "LEA_ALIVE=0"
if exist "%DEST%\lea_agent.lock" (
for /f "usebackq tokens=* delims=" %%i in ("%DEST%\lea_agent.lock") do (
tasklist /FI "PID eq %%i" /NH 2>nul | findstr /I "pythonw" >nul && set "LEA_ALIVE=1"
)
)
echo.
echo ============================================================
if "%LEA_ALIVE%"=="1" (
echo Lea est installee et demarree !
) else (
echo Lea est installee.
)
echo ============================================================
echo.
echo - Lea apparait en bas a droite, dans la barre des taches
echo (petite icone ronde, a cote de l'horloge).
echo - Lea demarrera AUTOMATIQUEMENT a chaque ouverture de session.
echo - Un raccourci "Lea" a ete ajoute sur votre Bureau.
echo.
echo Vous pouvez fermer cette fenetre.
echo.
pause
endlocal
exit /b 0

View File

@@ -53,7 +53,7 @@ AIVANOV ne pourra etre tenu responsable d'un usage non conforme.
7. CONTACT
----------
Pour toute question ou demande d'acces/rectification/suppression
de donnees : dpo@aivanov.com
de donnees : dpo@aivanov.eu
============================================================
En cliquant sur "J'accepte", vous confirmez avoir pris connaissance

View File

@@ -0,0 +1,105 @@
============================================================
Lea - Votre assistante intelligente
============================================================
Bienvenue ! Lea est une assistante qui apprend vos taches
repetitives sur l'ordinateur pour pouvoir vous aider.
Cette version est 100% autonome : aucun Python a installer,
aucun droit administrateur necessaire.
INSTALLATION (une seule fois)
-----------------------------
1. Si Lea est dans un fichier ZIP, faites un clic droit
dessus puis "Extraire tout..." (ne lancez pas Lea
directement depuis le ZIP).
2. Ouvrez le dossier extrait et double-cliquez sur
"Installer-Lea.bat".
3. Patientez quelques secondes (copie du programme).
A la fin, le message "Lea est installee et demarree"
s'affiche.
C'est tout. Lea est installee dans votre profil utilisateur
et :
- un raccourci "Lea" est ajoute sur votre Bureau ;
- Lea demarrera AUTOMATIQUEMENT a chaque fois que vous
ouvrez votre session Windows.
Vous pouvez ensuite supprimer le dossier extrait et le ZIP :
Lea continue de fonctionner (elle a ete copiee a part).
LANCER LEA MANUELLEMENT
-----------------------
Si besoin, double-cliquez sur le raccourci "Lea" du Bureau.
Lea apparait en bas a droite de votre ecran, dans la barre
des taches (petite icone ronde, a cote de l'horloge).
Clic droit sur l'icone pour ouvrir le menu :
- "Apprenez-moi une tache" : Lea observe ce que vous faites
et memorise les etapes. Travaillez normalement, Lea
apprend en vous regardant.
- "C'est termine" : Arrete l'enregistrement quand vous
avez fini la tache. Si vous oubliez, Lea s'arrete
automatiquement apres 1 heure.
- "Discuter avec Lea" : Ouvre une fenetre de discussion
pour poser des questions.
- "ARRET D'URGENCE" : Arrete immediatement tout ce que
Lea est en train de faire.
- "Quitter Lea" : Ferme le programme.
INFORMATIONS IMPORTANTES
------------------------
Quand Lea enregistre vos actions, elle capture votre ecran,
vos clics et vos frappes clavier.
- Lea vous previent AVANT chaque enregistrement
- Les donnees sensibles (mots de passe, informations
medicales) sont automatiquement floutees
- L'enregistrement s'arrete automatiquement apres 1 heure
- Vous pouvez arreter a tout moment via le menu
Lea est un systeme base sur l'intelligence artificielle
(Article 50, Reglement europeen sur l'IA).
CONFIGURATION
-------------
Si vous devez modifier l'adresse du serveur, ouvrez le fichier
"config.txt" (dans le dossier d'installation de Lea) avec le
Bloc-notes et changez les valeurs.
Ne modifiez rien d'autre sans l'accord de votre administrateur.
EN CAS DE PROBLEME
-------------------
- Lea ne demarre pas : double-cliquez a nouveau sur le
raccourci "Lea" du Bureau, ou relancez "Installer-Lea.bat".
- Lea est deconnectee : Verifiez votre connexion
reseau. Le serveur est peut-etre en maintenance.
- Pour desinstaller : supprimez le dossier "Lea" dans
votre profil (dossier %LOCALAPPDATA%\Lea) ainsi que les
raccourcis "Lea" du Bureau et du Demarrage.
- En cas de doute, contactez votre administrateur.
============================================================

View File

@@ -23,7 +23,7 @@
; ============================================================
#define MyAppName "Lea"
#define MyAppVersion "1.0.0"
#define MyAppVersion "1.0.2"
#define MyAppPublisher "AIVANOV"
#define MyAppURL "https://lea.labs.laurinebazin.design"
#define MyAppExeName "Lea.bat"
@@ -89,24 +89,23 @@ Name: "french"; MessagesFile: "compiler:Languages\French.isl"
[Files]
; Package complet (code Python + .bat + requirements)
; Note : install.bat EST copie (execute par [Run] pour creer le venv Python)
; Note : install.bat est EXCLU du staging (runtime 100% embedded, plus de venv/pip)
; Note : config.txt n'est PAS copie depuis le staging (il est genere par [Code])
Source: "{#SourceDir}\*"; \
DestDir: "{app}"; \
Flags: ignoreversion recursesubdirs createallsubdirs; \
Excludes: "{#PythonEmbedDir}\*,config.txt,*.log,sessions\*,__pycache__\*"
; Python 3.12 embedded (optionnel, copie conditionnelle via check)
; Python 3.12 embedded (OBLIGATOIRE — runtime 100% autonome, aucune dependance Python systeme)
Source: "{#SourceDir}\{#PythonEmbedDir}\*"; \
DestDir: "{app}\python-embed"; \
Flags: ignoreversion recursesubdirs createallsubdirs skipifsourcedoesntexist; \
Components: pythonembed
Flags: ignoreversion recursesubdirs createallsubdirs
; Script de desinstallation custom (kill + export logs)
Source: "uninstall_lea.ps1"; DestDir: "{app}"; Flags: ignoreversion
; Script de configuration du runtime Python embedded (optionnel)
Source: "configure_embed.ps1"; DestDir: "{app}"; Flags: ignoreversion; Components: pythonembed
; Script de configuration du runtime Python embedded (toujours installe)
Source: "configure_embed.ps1"; DestDir: "{app}"; Flags: ignoreversion
; Licence CGU (affichee dans la page licence ET conservee dans {app})
Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion isreadme
@@ -115,37 +114,30 @@ Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion isreadme
Source: "config_template.txt"; DestDir: "{app}"; Flags: ignoreversion
[Components]
Name: "core"; Description: "Lea (obligatoire)"; Types: full compact custom; Flags: fixed
Name: "pythonembed"; Description: "Python 3.12 embedded (recommande si Python non installe sur le poste)"; Types: full
Name: "autostart"; Description: "Demarrer Lea automatiquement au demarrage de Windows"; Types: full
; Composant unique fixe : pas de choix utilisateur (runtime embedded toujours inclus).
; Inno masque la page Composants quand il n'y a aucun composant selectionnable.
Name: "core"; Description: "Lea"; Types: full compact custom; Flags: fixed
[Tasks]
Name: "autostart"; Description: "Demarrer Lea automatiquement au demarrage de Windows"; GroupDescription: "Options :"
Name: "desktopicon"; Description: "Creer un raccourci sur le bureau"; GroupDescription: "Raccourcis :"; Flags: unchecked
Name: "startmenuicon"; Description: "Creer un raccourci dans le menu Demarrer"; GroupDescription: "Raccourcis :"
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: startmenuicon
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon
; Raccourci autostart (shell:startup) — cree si composant autostart selectionne
; Raccourci autostart (shell:startup) — cree si tache autostart selectionnee
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; \
WorkingDir: "{app}"; Components: autostart
WorkingDir: "{app}"; Tasks: autostart
[Run]
; Apres copie : executer install.bat pour creer le venv et installer les dependances Python
; Skip si bundle embedded (dans ce cas, on utilise python-embed directement)
Filename: "{app}\install.bat"; \
WorkingDir: "{app}"; \
StatusMsg: "Installation des composants Python (1-2 minutes)..."; \
Flags: runhidden waituntilterminated; \
Components: not pythonembed
; Configuration Python embedded : creer un Lea.bat qui pointe sur python-embed
; Configuration du runtime embedded : reecrit Lea.bat pour pointer sur python-embed.
; TOUJOURS execute — runtime 100% autonome, aucune branche venv/pip/Python systeme.
Filename: "{cmd}"; \
Parameters: "/c copy /y ""{app}\Lea.bat"" ""{app}\Lea.bat.bak"" && powershell -NoProfile -ExecutionPolicy Bypass -File ""{app}\configure_embed.ps1"""; \
WorkingDir: "{app}"; \
StatusMsg: "Configuration du runtime Python embedded..."; \
Flags: runhidden waituntilterminated; \
Components: pythonembed
StatusMsg: "Configuration de Lea..."; \
Flags: runhidden waituntilterminated
; Lancer Lea a la fin de l'installation (optionnel)
Filename: "{app}\{#MyAppExeName}"; \
@@ -161,13 +153,20 @@ Filename: "powershell.exe"; \
[UninstallDelete]
Type: filesandordirs; Name: "{app}\.venv"
Type: filesandordirs; Name: "{app}\python-embed"
Type: filesandordirs; Name: "{app}\__pycache__"
Type: filesandordirs; Name: "{app}\agent_v1\__pycache__"
Type: filesandordirs; Name: "{app}\agent_v1\sessions"
Type: filesandordirs; Name: "{app}\agent_v1\logs"
Type: files; Name: "{app}\lea_agent.lock"
Type: files; Name: "{app}\config.txt"
Type: files; Name: "{app}\config.txt.bak.*"
Type: files; Name: "{app}\machine_id.txt"
Type: files; Name: "{app}\Lea.bat.bak"
Type: files; Name: "{app}\install.bat"
; Filet de securite : supprime tout residu genere au runtime (caches, *.pyc, logs)
; afin que le dossier applicatif soit entierement supprime (exigence desinstall propre).
Type: filesandordirs; Name: "{app}"
; ============================================================
; Code Pascal : pages custom + generation config.txt + helpers
@@ -176,13 +175,14 @@ Type: files; Name: "{app}\machine_id.txt"
const
SERVER_URL_DEFAULT = 'https://lea.labs.laurinebazin.design/api/v1';
SERVER_HOST_DEFAULT = 'lea.labs.laurinebazin.design';
DEFAULT_TOKEN = '86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab';
DEFAULT_TOKEN = 'o3_LHqV_7_Gc6OVPHndhsBbvG6HJ5PCgl8yIBhGUIz8';
var
EnrollmentPage: TInputQueryWizardPage;
TokenPage: TInputQueryWizardPage;
MachineIdValue: string;
ConfigFilePath: string;
ExistingMachineId: string;
// --------------------------------------------------------------------
// Helper : ajoute des guillemets autour d'une chaine
@@ -268,6 +268,72 @@ end;
// --------------------------------------------------------------------
procedure LoadConfigFromCommandLine(); forward;
// --------------------------------------------------------------------
// UPGRADE — trouve le dossier d'une install Lea existante (config.txt present)
// --------------------------------------------------------------------
function FindExistingInstallDir(): string;
var
Candidates: array[0..1] of string;
I: Integer;
begin
Result := '';
Candidates[0] := ExpandConstant('{localappdata}\Programs\Lea');
Candidates[1] := ExpandConstant('{autopf}\Lea');
for I := 0 to 1 do
begin
if FileExists(Candidates[I] + '\config.txt') then
begin
Result := Candidates[I];
Exit;
end;
end;
end;
// --------------------------------------------------------------------
// UPGRADE — lit le config.txt existant : pre-remplit le wizard avec la
// VRAIE conf du poste (serveur/token/user) et MEMORISE le machine_id pour
// le PRESERVER (ne pas regenerer une nouvelle identite fleet).
// --------------------------------------------------------------------
procedure LoadExistingConfig();
var
Dir, ConfPath: string;
Lines: TArrayOfString;
I, EqPos: Integer;
Line, Key, Value: string;
begin
ExistingMachineId := '';
Dir := FindExistingInstallDir();
if Dir = '' then Exit; // install neuve -> comportement par defaut
ConfPath := Dir + '\config.txt';
if LoadStringsFromFile(ConfPath, Lines) then
begin
for I := 0 to GetArrayLength(Lines) - 1 do
begin
Line := Trim(Lines[I]);
if (Length(Line) = 0) or (Line[1] = '#') then Continue;
EqPos := Pos('=', Line);
if EqPos = 0 then Continue;
Key := Trim(Copy(Line, 1, EqPos - 1));
Value := Trim(Copy(Line, EqPos + 1, Length(Line)));
if Key = 'RPA_SERVER_URL' then TokenPage.Values[0] := Value
else if Key = 'RPA_API_TOKEN' then TokenPage.Values[1] := Value
else if Key = 'RPA_USER_NAME' then EnrollmentPage.Values[0] := Value
else if Key = 'RPA_USER_EMAIL' then EnrollmentPage.Values[1] := Value
else if Key = 'RPA_USER_ID' then EnrollmentPage.Values[2] := Value
else if Key = 'RPA_MACHINE_ID' then ExistingMachineId := Value;
end;
end;
// Fallback : machine_id.txt si absent du config.txt
if (ExistingMachineId = '') and FileExists(Dir + '\machine_id.txt') then
begin
if LoadStringsFromFile(Dir + '\machine_id.txt', Lines) and (GetArrayLength(Lines) > 0) then
ExistingMachineId := Trim(Lines[0]);
end;
end;
// --------------------------------------------------------------------
// Initialisation : cree les pages custom d'enrollment
// --------------------------------------------------------------------
@@ -302,7 +368,11 @@ begin
TokenPage.Values[0] := SERVER_URL_DEFAULT;
TokenPage.Values[1] := DEFAULT_TOKEN;
// Si un fichier /CONFIG= est passe en ligne de commande, pre-remplir
// UPGRADE : si une install existe, pre-remplir avec SA config (pas les
// defauts) et memoriser son machine_id pour le preserver.
LoadExistingConfig();
// Si un fichier /CONFIG= est passe en ligne de commande, pre-remplir (prioritaire)
LoadConfigFromCommandLine();
end;
@@ -509,6 +579,54 @@ begin
DeleteFile(PsFile);
end;
// --------------------------------------------------------------------
// UPGRADE — AVANT la copie des fichiers : tuer une Lea en cours (via le
// PID du lock) pour liberer les DLL de python-embed. Evite une install
// partielle / "reboot required". Ne tue QUE le PID du lock (jamais tous
// les pythonw du poste).
// --------------------------------------------------------------------
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
AppDir, LockPath, BackupDir, SessionsDir: string;
Lines: TArrayOfString;
ResultCode: Integer;
begin
Result := '';
AppDir := ExpandConstant('{app}');
// 1) Tuer une Lea en cours (via le PID du lock) pour liberer les DLL
// python-embed. Ne tue QUE ce PID, jamais tous les pythonw du poste.
LockPath := AppDir + '\lea_agent.lock';
if FileExists(LockPath) then
begin
if LoadStringsFromFile(LockPath, Lines) and (GetArrayLength(Lines) > 0) then
Exec('taskkill.exe', '/F /PID ' + Trim(Lines[0]), '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
DeleteFile(LockPath);
Sleep(1500);
end;
// UPGRADE uniquement (install existante detectee via config.txt).
if FileExists(AppDir + '\config.txt') then
begin
// 2) BACKUP (rollback) : copie code+config vers <app>_backup, HORS
// python-embed / sessions / logs (leger, rapide). Filet si la nouvelle
// version deconne : Julien restaure ce dossier.
BackupDir := AppDir + '_backup';
Exec(ExpandConstant('{cmd}'),
'/c rmdir /s /q "' + BackupDir + '" 2>nul & robocopy "' + AppDir + '" "' + BackupDir +
'" /E /XD python-embed sessions logs __pycache__ /XF *.pyc /R:1 /W:1 /NFL /NDL /NJH /NJS /NP >nul 2>&1',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
// 3) PURGE des captures accumulees (donnees d'apprentissage internes, non
// exploitables cote clinique) : libere le disque. Le fix capture JPEG
// evite que la saturation reprenne. Les logs (compliance 180j) restent.
SessionsDir := AppDir + '\agent_v1\sessions';
if DirExists(SessionsDir) then
Exec(ExpandConstant('{cmd}'),
'/c rmdir /s /q "' + SessionsDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
end;
// --------------------------------------------------------------------
// Hook : actions apres copie des fichiers (ssPostInstall)
// --------------------------------------------------------------------
@@ -516,8 +634,11 @@ procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
begin
// Genere le machine_id AVANT la copie des fichiers
MachineIdValue := GenerateMachineId();
// UPGRADE : preserver l'identite existante ; sinon en generer une neuve.
if ExistingMachineId <> '' then
MachineIdValue := ExistingMachineId
else
MachineIdValue := GenerateMachineId();
end;
if CurStep = ssPostInstall then

View File

@@ -81,16 +81,29 @@ cd deploy/installer
wget https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip
mkdir python-3.12-embed
unzip python-3.12.8-embed-amd64.zip -d python-3.12-embed/
# IMPORTANT : l'embed doit contenir TOUTES les dependances HORS LIGNE.
# Le runtime client ne fait AUCUN pip/reseau (POC clinique). On installe donc
# les dependances une fois dans l'embed, puis on le commit/reutilise tel quel :
python312._pth # decommenter 'import site'
python -m pip install --target python-3.12-embed/Lib/site-packages \
-r ../lea_package/requirements_agent.txt
# => doit inclure httpx (+ httpcore, h11) pour l'orchestrateur Lea (POST /api/learn/start).
```
Le staging copie automatiquement ce dossier si present. Le composant
"pythonembed" devient alors selectionnable dans l'installeur.
Le script `configure_embed.ps1` :
Le script `configure_embed.ps1` (execute a l'installation, sur le poste) :
1. Patche `python312._pth` pour activer `import site`
2. Installe `pip` via `get-pip.py`
3. Installe `requirements_agent.txt`
4. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe`
2. VERIFIE que les dependances sont deja embarquees (offline, aucun pip/reseau) —
`socketio, tkinter, mss, pynput, pystray, plyer, requests, httpx, PIL, win32api` ;
si une dependance manque, l'installation echoue explicitement.
3. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe`
> Note : `build_installer.sh` et `build_package_full.sh` valident aussi la presence
> des paquets (dont `httpx`, `httpcore`, `h11`) dans `Lib/site-packages/` avant de
> produire le paquet — un embed incomplet interrompt le build cote Linux.
## Installation silencieuse (deploiement de masse)

View File

@@ -103,6 +103,12 @@ rsync -a \
--exclude='.venv' \
--exclude='sessions/' \
--exclude='logs/' \
--exclude='test_lea_*' \
--exclude='_test_paused_toast.py' \
--exclude='tools/test_*' \
--exclude='install.bat' \
--exclude='*.bak' \
--exclude='config.txt.bak*' \
"$BASE_BUILD_DIR/" \
"$STAGING_DIR/"
@@ -128,15 +134,40 @@ echo ""
# 5. Python embedded (optionnel)
# ---------------------------------------------------------------
PYTHON_EMBED_SRC="${PYTHON_EMBED_DIR:-$SCRIPT_DIR/python-3.12-embed}"
if [[ -d "$PYTHON_EMBED_SRC" ]]; then
echo "[4/5] Copie de Python 3.12 embedded..."
rsync -a "$PYTHON_EMBED_SRC/" "$STAGING_DIR/python-3.12-embed/"
echo " Python embedded inclus"
else
echo -e "${YELLOW}[4/5] Python 3.12 embedded non trouve dans $PYTHON_EMBED_SRC${NC}"
echo " L'installeur sera produit SANS bundle Python."
echo " Pour bundler Python : voir README.md section 'Python embedded'"
if [[ ! -d "$PYTHON_EMBED_SRC" ]]; then
echo -e "${RED}[4/5] ERREUR : Python 3.12 embedded introuvable dans $PYTHON_EMBED_SRC${NC}"
echo " L'embed est OBLIGATOIRE (runtime 100% autonome, aucune dependance Python systeme)."
echo " Build interrompu."
exit 1
fi
echo "[4/5] Copie de Python 3.12 embedded..."
rsync -a "$PYTHON_EMBED_SRC/" "$STAGING_DIR/python-3.12-embed/"
# Validation de la completude de l'embed : un embed incomplet = install cassee chez le client.
# La liste doit rester alignee avec configure_embed.ps1 (verification runtime des imports).
EMBED="$STAGING_DIR/python-3.12-embed"
REQUIRED_EMBED=(
"python.exe" "pythonw.exe" "python312._pth"
"_tkinter.pyd" "tcl86t.dll" "tk86t.dll" "zlib1.dll"
"Lib/site-packages/socketio" "Lib/site-packages/tkinter"
"Lib/site-packages/mss" "Lib/site-packages/pynput"
"Lib/site-packages/pystray" "Lib/site-packages/plyer"
"Lib/site-packages/requests" "Lib/site-packages/PIL"
"Lib/site-packages/win32"
"Lib/site-packages/httpx" "Lib/site-packages/httpcore" "Lib/site-packages/h11"
"Lib/site-packages/anyio" "Lib/site-packages/typing_extensions.py"
)
MISSING_EMBED=()
for f in "${REQUIRED_EMBED[@]}"; do
[[ -e "$EMBED/$f" ]] || MISSING_EMBED+=("$f")
done
if [[ ${#MISSING_EMBED[@]} -gt 0 ]]; then
echo -e "${RED} ERREUR : embed incomplet. Elements manquants :${NC}"
printf ' - %s\n' "${MISSING_EMBED[@]}"
echo " Build interrompu (le runtime doit etre complet et autonome)."
exit 1
fi
echo " Python embedded complet inclus (${#REQUIRED_EMBED[@]} elements verifies)"
echo ""
# ---------------------------------------------------------------

View File

@@ -25,3 +25,5 @@ USER_ID=
# Connexion serveur (remplacer les valeurs CONFIGURE_ME avant utilisation)
SERVER_URL=CONFIGURE_ME
API_TOKEN=CONFIGURE_ME
AGENT_VERSION=1.0.2

View File

@@ -40,25 +40,24 @@ if ($PthFile) {
}
# ---------------------------------------------------------------
# 2. Installer pip (bootstrap via get-pip.py)
# 2-3. Verification des dependances embarquees (runtime 100% autonome)
# L'embed DOIT contenir toutes les dependances runtime.
# AUCUN pip, AUCUN reseau : si une dependance manque -> echec explicite.
# ---------------------------------------------------------------
$GetPip = Join-Path $env:TEMP "get-pip.py"
Write-Host " Telechargement de get-pip.py..."
Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPip -UseBasicParsing
Write-Host " Installation de pip..."
& $PythonExe $GetPip --no-warn-script-location
Remove-Item $GetPip -Force
# ---------------------------------------------------------------
# 3. Installer les dependances
# ---------------------------------------------------------------
$Requirements = Join-Path $AppDir "requirements_agent.txt"
if (Test-Path $Requirements) {
Write-Host " Installation des dependances Python..."
& $PythonExe -m pip install --no-warn-script-location -r $Requirements
$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','httpx','PIL','win32api')
$Missing = @()
foreach ($m in $RequiredModules) {
& $PythonExe -c "import $m" 2>$null
if ($LASTEXITCODE -ne 0) { $Missing += $m }
}
if ($Missing.Count -gt 0) {
Write-Host " ERREUR : runtime Lea incomplet. Modules manquants : $($Missing -join ', ')"
Write-Host " L'embed doit etre livre complet (aucune installation reseau en POC)."
exit 1
}
Write-Host " Dependances embarquees verifiees ($($RequiredModules.Count) modules) - offline OK."
# ---------------------------------------------------------------
# 4. Reecrire Lea.bat pour utiliser python-embed
# ---------------------------------------------------------------
@@ -77,6 +76,29 @@ if exist "lea_agent.lock" (
timeout /t 2 >nul
)
:: MAJ SILENCIEUSE swap atomique + rollback (renames uniquement)
if exist "PENDING_BOOT" (
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
if exist "agent_v1_prev" (
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
move "agent_v1_prev" "agent_v1" >nul 2>&1
)
del /f /q "PENDING_BOOT" >nul 2>&1
) else if exist "UPDATE_READY" (
if exist "agent_v1_new" (
echo [MAJ] Application de la mise a jour...
if exist "agent_v1" (
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
move "agent_v1" "agent_v1_prev" >nul 2>&1
)
move "agent_v1_new" "agent_v1" >nul 2>&1
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
) else (
del /f /q "UPDATE_READY" >nul 2>&1
)
)
if exist "config.txt" (
for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do (
if not "%%a"=="" if not "%%b"=="" set "%%a=%%b"

View File

@@ -20,6 +20,35 @@ if exist "lea_agent.lock" (
timeout /t 2 >nul
)
:: ---------------------------------------------------------------
:: MAJ SILENCIEUSE — swap atomique + rollback (hors-process)
:: L'ancienne instance est fermee ci-dessus : agent_v1\ est libre.
:: Renames uniquement (quasi-atomiques), jamais d'ecrasement fichier par fichier.
:: ---------------------------------------------------------------
if exist "PENDING_BOOT" (
:: Le boot precedent n'a JAMAIS confirme (crash) -> ROLLBACK version precedente
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
if exist "agent_v1_prev" (
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
move "agent_v1_prev" "agent_v1" >nul 2>&1
)
del /f /q "PENDING_BOOT" >nul 2>&1
) else if exist "UPDATE_READY" (
:: Une MAJ est armee (agent_v1_new pret) -> SWAP
if exist "agent_v1_new" (
echo [MAJ] Application de la mise a jour...
if exist "agent_v1" (
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
move "agent_v1" "agent_v1_prev" >nul 2>&1
)
move "agent_v1_new" "agent_v1" >nul 2>&1
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
) else (
del /f /q "UPDATE_READY" >nul 2>&1
)
)
:: ---------------------------------------------------------------
:: Verifier que l'installation a ete faite
:: ---------------------------------------------------------------

View File

@@ -36,5 +36,15 @@ RPA_MACHINE_ID=CONFIGURE_ME
RPA_USER_LABEL=CONFIGURE_ME
# --- Parametres avances (ne pas modifier sauf indication) ---
RPA_AGENT_VERSION=1.0.2
RPA_BLUR_SENSITIVE=false
RPA_LOG_RETENTION_DAYS=180
# --- MAJ silencieuse (DETTE-022 v2) — DESACTIVEE par defaut ---
# Deploiement CANARY : on active d'ABORD ce flag sur le SEUL poste pilote
# (Emilie), on verifie, puis on elargit. Le poste interroge le serveur et
# telecharge la MAJ en staging ; le remplacement reel des fichiers reste manuel
# / supervise (reserve revision humaine). Decommenter pour activer ce poste :
# RPA_AUTO_UPDATE_ENABLED=true
# Intervalle d'interrogation serveur en secondes (defaut 3600 = 1h) :
# RPA_AUTO_UPDATE_INTERVAL_S=3600

View File

@@ -5,9 +5,17 @@ mss>=9.0.1 # Capture d'ecran haute performance
pynput>=1.7.7 # Clavier/Souris
Pillow>=10.0.0 # Traitement image (crops, compression)
requests>=2.31.0 # Communication serveur
httpx>=0.27 # Client HTTP orchestrateur Lea (POST /api/learn/start) - brique conversationnelle
psutil>=5.9.0 # Monitoring CPU/RAM
pystray>=0.19.5 # Icone systray
plyer>=2.1.0 # Notifications toast natives
# FeedbackBus / bulles d'action Lea (client socketio temps reel vers agent-chat :5004)
# Jeu valide en runtime sur la VM (chat + bulles fonctionnels)
python-socketio>=5.10.0 # client SocketIO (FeedbackBus)
python-engineio>=4.8.0 # transport engine.io
websocket-client>=1.9.0 # transport websocket client
simple-websocket>=1.1.0 # fallback websocket
# Windows specifique
pywin32>=306 ; sys_platform == 'win32'

View File

@@ -14,6 +14,8 @@ EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
Environment="PYTHONUNBUFFERED=1"
Environment="ENVIRONMENT=production"
Environment="RPA_SERVICE_NAME=rpa-vision-v3-api"
# Keep the upload API internal to the DGX; other LAN-facing services keep the shared bind host.
Environment="RPA_BIND_HOST=127.0.0.1"
# Service grounding persistant — socket + répertoire d'images partagés via /run/rpa/.
# Si le service rpa-grounding n'est pas démarré, le client retombe automatiquement
# sur le subprocess one-shot (cf. ui_tars_grounder.py).

View File

@@ -0,0 +1,44 @@
@echo off
chcp 65001 >nul
title Connexion VM Lea (via DGX)
REM ============================================================
REM Connexion Bureau a distance a la VM Windows (Lea) du DGX.
REM Ouvre un tunnel SSH, lance le RDP (presse-papier actif),
REM puis referme le tunnel quand la session RDP est fermee.
REM ============================================================
REM --- Parametres (ajuste si besoin) ---
set "DGX_USER=aivanov"
set "DGX_HOST=192.168.1.45"
REM En deplacement (WireGuard, plus tard) : mettre DGX_HOST=10.10.0.1
set "LOCAL_PORT=13389"
set "RDP_FILE=%~dp0VM-Lea.rdp"
echo.
echo [1/3] Ouverture du tunnel SSH vers %DGX_USER%@%DGX_HOST% ...
echo (si un mot de passe est demande, saisis-le dans la fenetre "Tunnel")
start "Tunnel-DGX-VMLea" ssh -o StrictHostKeyChecking=accept-new -o ExitOnForwardFailure=yes -N -L %LOCAL_PORT%:127.0.0.1:3390 %DGX_USER%@%DGX_HOST%
echo [2/3] Attente de l'etablissement du tunnel (max ~30s)...
set /a tries=0
:wait
timeout /t 1 /nobreak >nul
powershell -NoProfile -Command "try{(New-Object Net.Sockets.TcpClient).Connect('127.0.0.1',%LOCAL_PORT%);exit 0}catch{exit 1}" >nul 2>&1
if not errorlevel 1 goto ready
set /a tries+=1
if %tries% lss 30 goto wait
echo ! Tunnel non etabli. Verifie l'acces SSH au DGX (mot de passe / reseau).
pause
goto cleanup
:ready
echo [3/3] Connexion Bureau a distance (localhost:%LOCAL_PORT%) ...
mstsc "%RDP_FILE%"
:cleanup
echo.
echo Fermeture du tunnel SSH...
taskkill /FI "WINDOWTITLE eq Tunnel-DGX-VMLea*" /T /F >nul 2>&1
echo Termine.
timeout /t 2 /nobreak >nul

View File

@@ -0,0 +1,35 @@
CONNEXION BUREAU A DISTANCE - VM Lea (DGX)
==========================================
CONTENU
- Connexion-VM-Lea.cmd : le lanceur (double-clic)
- VM-Lea.rdp : le profil de connexion RDP (presse-papier active)
INSTALLATION (sur ton laptop Windows)
1. Copie les DEUX fichiers dans le MEME dossier (ex: le Bureau).
2. (Optionnel) clic droit sur Connexion-VM-Lea.cmd > Envoyer vers > Bureau
(creer un raccourci), pour un acces rapide.
UTILISATION
- Double-clic sur "Connexion-VM-Lea.cmd".
- Une fenetre "Tunnel" s'ouvre : si un mot de passe SSH est demande,
saisis le mot de passe du compte aivanov du DGX.
- Le Bureau a distance s'ouvre ensuite : saisis ton identifiant + mot de
passe WINDOWS de la VM.
- Copier-coller (texte ET fichiers) fonctionne dans les deux sens.
- Ferme la fenetre RDP pour finir : le tunnel se referme automatiquement.
PRE-REQUIS
- Etre sur le reseau du labo (meme WiFi) pour joindre 192.168.1.45.
- OpenSSH client (inclus dans Windows 10/11).
- Le Bureau a distance doit etre active dans la VM (deja fait).
EN DEPLACEMENT (plus tard)
- Quand WireGuard sera en place, edite Connexion-VM-Lea.cmd et remplace
DGX_HOST=192.168.1.45 par DGX_HOST=10.10.0.1
- Tout le reste est identique. L'adresse RDP reste localhost:13389.
CONFORT (optionnel, recommande)
- Pour ne plus saisir le mot de passe SSH a chaque fois : on signe la cle
SSH de ton laptop avec la CA (acces par certificat). Demande-le moi et
envoie-moi la cle publique de ton laptop.

View File

@@ -0,0 +1,18 @@
full address:s:localhost:13389
prompt for credentials:i:1
redirectclipboard:i:1
redirectdrives:i:1
drivestoredirect:s:*
redirectprinters:i:0
redirectsmartcards:i:0
audiomode:i:2
authentication level:i:0
negotiate security layer:i:1
enablecredsspsupport:i:1
screen mode id:i:2
dynamic resolution:i:1
desktopwidth:i:1280
desktopheight:i:800
session bpp:i:32
compression:i:1
username:s:

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# RDP vers la VM Windows (Lea) du DGX, depuis ce serveur Linux (.40).
# Ouvre un tunnel SSH (par certificat) puis lance xfreerdp.
# Presse-papier + dossier $HOME partage. Tunnel ferme a la sortie.
#
# Usage:
# ./connexion-vm-lea.sh # labo (DGX = 192.168.1.45)
# ./connexion-vm-lea.sh 10.10.0.1 # en deplacement (via WireGuard)
# ./connexion-vm-lea.sh 192.168.1.45 /u:MonUserWindows
set -euo pipefail
DGX_HOST="${1:-192.168.1.45}"
[ $# -gt 0 ] && shift || true
LOCAL_PORT=13389
CTL="$(mktemp -u /tmp/rdp-vmlea-ctl.XXXXXX)"
cleanup(){ ssh -S "$CTL" -O exit "aivanov@${DGX_HOST}" >/dev/null 2>&1 || true; }
trap cleanup EXIT INT TERM
echo "[1/3] Tunnel SSH (cert) vers aivanov@${DGX_HOST} ..."
ssh -o ExitOnForwardFailure=yes -fN -M -S "$CTL" -L "${LOCAL_PORT}:127.0.0.1:3390" "aivanov@${DGX_HOST}"
echo "[2/3] Attente du tunnel ..."
for _i in $(seq 1 40); do
ss -tlnp 2>/dev/null | grep -q "127.0.0.1:${LOCAL_PORT} " && break
sleep 0.25
done
echo "[3/3] Connexion RDP (localhost:${LOCAL_PORT}) — presse-papier + dossier $HOME ..."
xfreerdp /v:localhost:${LOCAL_PORT} /cert:ignore /clipboard /dynamic-resolution /drive:home,"$HOME" "$@" || true
echo "Session RDP terminee, fermeture du tunnel."

View File

@@ -0,0 +1,59 @@
# Unblock NoMachine on Windows 11 — run as Administrator
# Adds firewall rules for port 4000 (TCP+UDP) and verifies NoMachine service
Write-Host "=== Unblock NoMachine ===" -ForegroundColor Cyan
# 1. Add firewall inbound rules for NoMachine (port 4000 TCP + UDP)
$ruleName = "NoMachine Server (Port 4000)"
$existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Firewall rule '$ruleName' already exists — enabling it" -ForegroundColor Yellow
Enable-NetFirewallRule -DisplayName $ruleName
} else {
Write-Host "Creating firewall rule '$ruleName' for port 4000 TCP+UDP" -ForegroundColor Green
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort 4000 -Action Allow -Profile Any -Enabled True -Description "Allow NoMachine remote desktop connections"
New-NetFirewallRule -DisplayName "$ruleName (UDP)" -Direction Inbound -Protocol UDP -LocalPort 4000 -Action Allow -Profile Any -Enabled True -Description "Allow NoMachine UDP discovery"
}
# 2. Check NoMachine service is running
$svc = Get-Service -Name "nxsrv" -ErrorAction SilentlyContinue
if (-not $svc) {
$svc = Get-Service -Name "NoMachine Server" -ErrorAction SilentlyContinue
if (-not $svc) {
$svc = Get-Service | Where-Object { $_.DisplayName -like "*NoMachine*" -and $_.DisplayName -like "*Server*" } | Select-Object -First 1
}
}
if ($svc) {
Write-Host "NoMachine service: $($svc.Name) — Status: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
if ($svc.Status -ne 'Running') {
Write-Host "Starting NoMachine service..." -ForegroundColor Yellow
Start-Service -Name $svc.Name -ErrorAction SilentlyContinue
$svc = Get-Service -Name $svc.Name
Write-Host "After start: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
}
} else {
Write-Host "WARNING: NoMachine server service not found!" -ForegroundColor Red
}
# 3. Verify port 4000 is listening
Write-Host ""
Write-Host "Checking port 4000..." -ForegroundColor Cyan
$port4000 = Get-NetTCPConnection -LocalPort 4000 -ErrorAction SilentlyContinue
if ($port4000) {
Write-Host "Port 4000 is LISTENING on $($port4000.LocalAddress):$($port4000.LocalPort) — State: $($port4000.State)" -ForegroundColor Green
} else {
Write-Host "WARNING: Port 4000 NOT listening — NoMachine server may not be active" -ForegroundColor Red
Write-Host "Try: restart NoMachine from the Start Menu or Services app" -ForegroundColor Yellow
}
# 4. Show this machine's IP for remote connection
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notlike '*Loopback*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -match '192\.168' } | Select-Object -First 1).IPAddress
if ($ip) {
Write-Host ""
Write-Host "Laptop IP on LAN: $ip" -ForegroundColor Green
Write-Host "From workstation: connect NoMachine to $ip" -ForegroundColor Green
}
Write-Host ""
Write-Host "=== Done ===" -ForegroundColor Cyan

View File

@@ -0,0 +1,106 @@
# Audit Code Mort — Classification A/B/C — 2026-07-02
**Auteur**: Qwen (vérifié par grep/glob/commandes réelles)
**Date**: 2026-07-02
**Méthode**: Parallel agent exploration + grep verification + graphify cross-check
---
## Méthodologie
- **A (WIRED/ACTIF)** : Code importé et appelé dans le runtime de production
- **B (ORPHAN/PROJECTION)** : Code avec lazy import ou projection future, pas appelé actuellement mais structuré pour activation
- **C (MORT/CONFIRMÉ)** : Code zero imports, zero callers, zero runtime activation — candidat suppression
**Règle**: C-MORT nécessite GO Dom avant suppression. B-ORPHELIN conserve. A-WIRED documenté.
---
## C-MORT Confirmé (8 items, ~843 lignes)
| # | Fichier/Zone | Lignes | Preuve C-MORT | Risque suppression |
|---|-------------|--------|---------------|-------------------|
| C1 | `agent_v0/deploy_windows.py` | ~244 | Comment "OBSOLETE avril 2026" + zero imports | LOW — standalone script |
| C2 | `core/config.py`: 7 deprecated config classes | ~160 | Zero prod imports, mirrorent SystemConfig | LOW — mais vérifier .env references |
| C3 | `core/detection/owl_detector.py`: 4 methods | ~90 | Zero callers dans prod | LOW — vérifier examples/ |
| C4 | `core/detection/ollama_client.py`: 5 old methods | ~150 | Remplacés par classify_element_complete() | LOW — vérifier examples/ |
| C5 | `ollama_client.py:check_ollama_available()` standalone | ~15 | 8/9 callers in examples/, 1 in VWB (duplicat D2) | LOW — VWB a sa propre copie |
| C6 | `agent_chat/app.py`: 2 Flask 410 endpoints | ~14 | Endpoints déprecated, retour 410 Gone | LOW — API contract check |
| C7 | `core/grounding/smart_resize.py` (77 lines) | 77 | Zero prod callers, DETTE-007 triple impl | LOW — 2 autres impls existent |
| C8 | PP-OCRv5 (paddleocr+paddlepaddle venv) | ~deps | 0 .py imports across entire project | LOW — venv deps uninstall |
**Total C-MORT**: ~843 lignes code + venv deps
---
## B-ORPHELIN (5 items, ~537 lignes)
| # | Fichier/Zone | Lignes | Preuve B | Action |
|---|-------------|--------|----------|--------|
| B1 | VWB ui_detection_service OmniParser path | ~70 | HARD-DISABILÉ `_omniparser_available = False # DÉSACTIVÉ` | Conserver, documenter activation condition |
| B2 | `fusion_engine.py:_fuse_concat_projection()` | ~15 | Stub, prévu pour future fusion modes | Conserver, marque PROJECTION |
| B3 | `omniparser_adapter.py` | ~429 | BRANCHABLE DORMANT, try/except import | Conserver, documenter activation condition |
| B4 | `CorrectionStatus.DEPRECATED` enum value | ~3 | Enum value, pas supprimable sans break | Conserver, marque DEPRECATED |
| B5 | `catalog_routes_v2_vlm.py:check_ollama_available()` | ~20 | Duplicat de ollama_client.py (D2) | DÉCISION Dom : unifier ou garder 2 impls |
---
## Duplicats Identifiés (4)
| # | Item | Impl 1 | Impl 2 | Statut |
|---|------|--------|--------|--------|
| D1 | smart_resize | smart_resize.py (C7) | ui_detection_service.py resize | C-MORT vs WIRED |
| D2 | check_ollama_available | ollama_client.py (C5) | catalog_routes_v2_vlm.py (B5) | C-MORT vs B-ORPHELIN |
| D3 | ground_element | seeclick_adapter.py (B→provenance?) | ollama_client.py old method | B vs C4 |
| D4 | 7 deprecated config classes | core/config.py (C2) | SystemConfig (WIRED) | C-MORT vs A-WIRED |
---
## Classification Updates (C→A upgrades confirmés)
| Item | Prior Status | Current Status | Preuve upgrade |
|------|-------------|---------------|---------------|
| autonomous_planner.py | C | **A** | Migrated to agent_chat/, wired by app.py |
| seeclick_adapter.py | C | **B** | Lazy re-export, `_seeclick_available` never consulted mais impl ground_element indépendante |
| grounding/server.py | C | **A** | HTTP service port 8200, standalone Flask |
| get_grounding_profile() | C | **A** | Wired via ollama_client.py:303-304 lazy import |
---
## OmniParser — Classification 7 Zones
| # | Zone | Statut | Activation | Fallback |
|---|------|---------|-----------|----------|
| 1 | SoM engine (som_engine.py) | **A-WIRED** | YOLO weights direct | docTR OCR |
| 2 | resolve_engine (_get_omniparser) | **B-DORMANT** | Lazy Optional[bool] | None → skipped |
| 3 | phase25_analyzer (_OmniParserSafeWrapper) | **B-DORMANT** | Lazy import + healthcheck | docTR-only |
| 4 | api_stream healthcheck | **A-WIRED** | Always 200 omniparser_available:bool | degraded:true |
| 5 | omniparser_adapter.py | **B-DORMANT** | Import phase25 & resolve | empty list |
| 6 | VWB ui_detection_service.py | **B-HARD-DISABILÉ** | `_omniparser_available = False # DÉSACTIVÉ` | ui-detr-1 only |
| 7 | VWB catalog_routes_v2_vlm.py | **B-DORMANT** | try/except, flips True si installé | VLM fallback |
---
## QG-Gated Lots (proposé, nécessite GO Dom)
### Lot 1 — C-MORT Low Risk (suppression directe après GO Dom)
- C1 deploy_windows.py
- C7 smart_resize.py
- C6 agent_chat 410 endpoints
- C8 PP-OCRv5 venv deps uninstall
### Lot 2 — C-MORT Medium Risk (vérification examples/ avant suppression)
- C2 7 deprecated config classes (vérifier .env)
- C3 owl_detector 4 methods (vérifier examples/)
- C4 ollama_client 5 old methods (vérifier examples/)
- C5 check_ollama_available standalone (vérifier VWB duplicat)
### Lot 3 — Duplicats Unification (décision Dom)
- D1 smart_resize: unifier ou garder 2 impls
- D2 check_ollama_available: unifier VWB vs core
- D3 ground_element: unifier seeclick vs ollama
- D4 config classes: supprimer deprecated vs garder compat
---
**Prochaine étape**: Dom review → GO/NOGO par lot → exécution séquentielle avec tests verification après chaque lot.

View File

@@ -0,0 +1,174 @@
# Audit — Ce qui manque pour une appli 100% fonctionnelle
- **Date** : 2026-06-10
- **Demandeur** : Dom
- **Auteur** : Claude (audit read-only par 4 sous-agents d'exploration + contre-vérifications manuelles)
- **Périmètre** : agent client Windows (Léa), chaîne d'apprentissage, capacité de replay, maturité produit/fleet
- **Statut findings** : les fichier:ligne proviennent d'agents d'exploration. Les 3 contradictions majeures ont été re-vérifiées à la main (voir annexe). **Avant d'engager du code sur un item, revalider le point au cas par cas** (méthode habituelle).
- **Avis croisé Qwen** : reçu 2026-06-10 23:30 (`inbox_claude/2026-06-10_2330_qwen-to-claude_AVIS-GAPS-APPLI-100PCT.md`) — intégré en **Addendum (§7)**.
---
## 1. Diagnostic central
L'appli aujourd'hui, honnêtement qualifiée : **un record-and-replay supervisé robuste, avec une couche sémantique réelle au grounding, mais dont la boucle d'apprentissage n'est pas fermée et dont les filets de sécurité sont écrits mais débranchés.**
Trois promesses produit non tenues dans le code qui tourne :
1. **La boucle d'apprentissage est ouverte** — Shadow observe et construit des workflows, mais ils n'arrivent jamais dans VWB (import jamais déclenché).
2. **L'exécution ne se vérifie pas elle-même** — verify, healing, recovery : tout existe, tout est désactivé ou non branché.
3. **Pas de généralisation** — un workflow appris ne survit pas à un changement de poste/résolution ; FAISS est construit au training mais jamais consulté au replay.
Point structurel transverse : **deux chemins d'exécution aux capacités différentes** :
- `visual_workflow_builder/backend/api_v3/execute.py` (exécution locale VWB, Legacy + ORA)
- `agent_v0/server_v1/replay_engine.py` → agent Windows Léa (chemin POC)
Certains manques n'existent que sur l'un des deux. `t2a_decision` et le templating profond `{{var.field.sub}}` sont **implémentés sur le chemin Léa** (replay_engine.py:1922 et :2017) mais absents du chemin local. Cette asymétrie a piégé jusqu'aux agents d'audit eux-mêmes — c'est un coût de maintenance et un risque d'erreur permanent.
---
## 2. Axe agent client Windows (Léa) — ~80% fonctionnel, 1 bug critique
### Ce qui marche (vérifié wired)
- Capture complète : clics/double-clics, clavier+buffer texte, scroll, multi-écrans, DPI awareness, floutage sensible, dédup pHash.
- Résilience réseau : buffer SQLite persistant, retry 3×, backoff 1→30s, heartbeat 5s.
- Purge captures après ACK serveur (`PURGE_AFTER_ACK=1` défaut).
- Enroll Bearer token + machine_id ; détection dialogues système UAC/CredUI/SmartScreen **fail-closed** (pause supervisée).
- Rétention logs 180 j (conforme Règlement IA art. 12).
### Gaps
| # | Gap | Sévérité | Preuve | Type |
|---|-----|----------|--------|------|
| A1 | **Timeout HTTP client 5s** : étape serveur > 5s (extract_text, t2a) → client coupe, action déjà sortie de la queue → **perdue silencieusement**. Incident documenté 8 mai (4 actions perdues, pause step 18). | 🔴 BLOQUANT POC | `agent_v1/core/executor.py:1786` ; `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` | Bug |
| A2 | **Watchdog `_retry_pending` absent** côté serveur : actions perdues jamais republiées. | 🔴 BLOQUANT POC | `network/streamer.py:99-105` (constat) | Non implémenté |
| A3 | Écran verrouillé non détecté : agent capture noir, tape dans le vide. | 🟠 Important | — | Non implémenté |
| A4 | RecoveryEngine client : code complet, jamais appelé. | 🟡 Nice-to-have | `agent_v1/core/recovery.py` | Écrit non branché |
| A5 | Long-polling HTTP fragile par construction (vs SSE/WebSocket). | 🟡 Post-POC | `main.py:287-366` | Architecture |
Note : la suspicion « appel Ollama de commentaire d'action orphelin côté client » **ne se confirme pas** côté agent_v1 (`OLLAMA_HOST` défini dans config mais aucun appel client). Le commentaire d'action est côté serveur.
---
## 3. Axe chaîne d'apprentissage — marche jusqu'au dernier mètre, puis s'arrête
### Ce qui marche (vérifié wired)
- Chaîne Shadow complète : streaming → LiveSessionManager → `_worker_queue.txt``run_worker.py` → ScreenAnalyzer → CLIP → FAISS indexation → GraphBuilder DBSCAN → workflow JSON dans `data/training/workflows/{machine_id}/`.
- Apprentissage post-replay : ReplayLearner (JSONL `data/learning/replay_results/`) + TargetMemoryStore (SQLite `data/learning/target_memory.db`), consultés avant résolution, alimentés après succès/échec.
### Gaps
| # | Gap | Sévérité | Preuve | Type |
|---|-----|----------|--------|------|
| L1 | **Workflows Shadow jamais importés dans VWB** : `import_core_workflow()` existe (`visual_workflow_builder/backend/api/workflows.py:622`) mais aucun appel automatique post-finalize. La boucle d'apprentissage produit des fichiers que personne ne voit. | 🔴 BLOQUANT promesse produit | grep `import_core_workflow` depuis server_v1 = 0 hit | Écrit non branché |
| L2 | ShadowLearningHook jamais importé (état avril 2026 inchangé). | 🟠 Important | `core/grounding/shadow_learning_hook.py` | Écrit non branché |
| L3 | **FAISS construit mais jamais interrogé au replay** — la mémoire sémantique ne sert pas à la résolution. | 🟠 Important | `core/embedding/faiss_manager.py` | Écrit non branché |
| L4 | Pas de généralisation cross-résolution/cross-poste : workflows cloisonnés par machine_id, ancres dépendantes du poste source. | 🟠 Important | `core/models/workflow_graph.py` | Non implémenté |
| L5 | **Copilot : inexistant** (aucun moteur de suggestion). **Autonomous : AutonomousPlanner isolé du replay engine.** Le cycle Shadow→Copilot→Autonomous est aujourd'hui Shadow→(rien)→exécution déclenchée manuellement. | 🔴 BLOQUANT promesse produit | `agent_chat/autonomous_planner.py`, `agent_v0/server_v1/execution_plan_runner.py` | Squelettes |
| L6 | Recapture anchor VWB : pas de revalidation/régénération PNG post-modification (bug connu mai 2026). | 🟠 Important | `visual_workflow_builder/backend/services/anchor_image_service.py` | Non implémenté |
---
## 4. Axe replay/exécution — ça clique bien, mais ça ne sait pas si ça a marché
### Ce qui marche (vérifié wired)
- Cascade de résolution active : template matching → CLIP → OCR/UI-TARS → VLM.
- DialogHandler branché (détection popups pré-step).
- Pause supervisée avec choix utilisateur (skip/static/coords, timeout 120s).
- Chemin Léa : `t2a_decision` (replay_engine.py:1922, handlers :2045+), templating profond `{{var.field.sub}}` (`path.split('.')` :2017), extract_text.
### Gaps
| # | Gap | Sévérité | Preuve | Type |
|---|-----|----------|--------|------|
| R1 | **`verify_level='none'` en dur** : aucune vérification post-action que le clic a produit l'effet attendu (seul pHash d'attente d'écran). Contraire au principe « vérif avant + après chaque clic ». | 🟠 Important (🔴 avant clinique) | `execute.py:1545` | Branché désactivé |
| R2 | **VLM pre-check `if False:` en dur** : pas de validation que l'élément trouvé = l'élément attendu. | 🟠 Important | `core/execution/observe_reason_act.py:1707` | Branché désactivé |
| R3 | Healing engine implémenté, jamais appelé. | 🟠 Important | `core/healing/healing_engine.py:21-150` | Écrit non branché |
| R4 | **Aucune reprise après crash** : crash au step N → redémarrage à 0, pas de checkpoint. | 🔴 BLOQUANT clinique | `execute.py:1732` (thread daemon sans checkpoint) | Non implémenté |
| R5 | **OCR-DIRECT « centre de ligne »** : substring d'une ligne docTR → coordonnées du centre de la ligne entière. Sur une barre d'onglets, Imagerie/Notes/Synthèse ≈ mêmes coords. Latent et sournois. | 🟠 Important | `agent_v0/server_v1/resolve_engine.py:1447-1527` | Bug |
| R6 | Timeout VWB→serveur 30s vs étapes longues (t2a/Ollama lent) → 504. | 🟠 Important | `server_client.py:207` | Bug config |
| R7 | Reporting d'exécution pauvre : méthode de grounding utilisée et raison d'échec non tracées. | 🟠 Important | ExecutionStep DB | Incomplet |
| R8 | Popup détecté mais gestion échouée → continue (log seul), pas de pause. | 🟡 Nice-to-have | `execute.py:283` | Incomplet |
| R9 | 3 systèmes de grounding morts (code zombie) : `fast_detector`, `smart_matcher`, `template_matcher` standalone. | 🟡 Ménage | `core/grounding/` | Poids mort |
| R10 | TitleVerifier aveugle en VM (crop EasyOCR 45px illisible). | 🟡 Connu | `core/grounding/title_verifier.py:34-67` | Limitation |
---
## 5. Axe maturité produit / fleet — OK pour 5 TIM supervisés, pas au-delà
### Ce qui marche (vérifié)
- Fleet : enroll/uninstall/revoke SQLite (`agent_registry.py`), `_guard_agent_registry_access`, last_seen heartbeat.
- Sessions concurrentes thread-safe ; uploads images rate-limités sans sérialisation.
- Healthcheck multi-composants + timer systemd (API, dashboard, worker heartbeat, disque).
- Export ZIP workflows ; dashboard HTTP Basic fail-closed.
### Gaps
| # | Gap | Sévérité | Preuve | Type |
|---|-----|----------|--------|------|
| P1 | **DETTE-006/010 — grounding Qwen3-VL instable** (`smart_resize` non déterministe, config checkpoint factor 28 vs 32). LE risque technique du calendrier POC. | 🔴 BLOQUANT POC | `docs/MIGRATION_VLM_PLAN_2026-05-09.md`, DETTE_TECHNIQUE.md | En cours |
| P2 | **1 seul replay simultané** (verrou global `_replay_lock`). Acceptable POC séquentiel, bloquant au-delà. | 🟠 Important post-POC | `api_stream.py` | Limitation |
| P3 | Opérabilité non-dev : pas d'onglet « état systèmes », pas de monitoring GPU/Ollama, erreurs JSON brut. Acceptable seulement avec Dom en SSH derrière. | 🟠 Important | `web_dashboard/app.py` | Incomplet |
| P4 | Export ZIP sans **restore** en masse ; backup exporte les JSON, pas la DB (DETTE-015 : symlink tient pour le POC). | 🟠 Important | `core/system/backup_exporter.py:58-160` | Incomplet |
| P5 | Multi-users/RBAC : 1 user statique. Accepté POC (lié DETTE-016). | 🟡 Post-POC | `web_dashboard/app.py:67` | Accepté |
| P6 | Pas de rotation des logs serveurs (`logs/*.log`) — artifact_retention ne couvre que les données. | 🟡 Nice-to-have | `core/system/artifact_retention.py` | Incomplet |
| P7 | DETTE-013 : tests unit non exécutables sans `RPA_API_TOKEN` (sys.exit à l'import). | 🟠 Important dev | `agent_v0/server_v1/api_stream.py:135` | Bug |
| P8 | Ménage pré-POC (~9-10 j-h, `MENAGE_PRE_POC_2026-05-29.md`) non engagé ; ~21 modules core/ orphelins documentés. | 🟡 Planifié | docs/POC/ | Dette |
---
## 6. Priorisation proposée
### Horizon 1 — avant le M2 live (jours) : fiabiliser la chaîne qui existe
1. **A1** Timeout client 5s → 30s (1 constante) + **A2** watchdog `_retry_pending` serveur — le duo qui a tué la session du 8 mai.
2. **P1** Trancher DETTE-006/010 (calibration grounding Qwen3-VL sur DGX) avant le bench J+6.
3. **A3** Détection écran verrouillé.
### Horizon 2 — avant la clinique (semaines) : les filets de sécurité
4. **R1/R2** Réactiver verify (au moins post-condition légère) + pre-check.
5. **R5** Fix OCR centre-de-ligne (span du substring, pas centre de ligne).
6. **R4** Reprise sur crash (checkpoint step) + **R7** tracer la méthode de résolution.
7. **R6** Timeout VWB 30s → adapté aux étapes longues (ou polling asynchrone).
### Horizon 3 — la promesse produit (post-POC) : fermer la boucle
8. **L1** Pont auto Shadow→VWB (`import_core_workflow` post-finalize) — LA pièce qui transforme l'outil en produit apprenant.
9. **L3** FAISS consulté au replay + **L4** début de généralisation cross-poste.
10. **L5** Copilot (moteur de suggestion) puis branchement AutonomousPlanner.
11. Unifier les deux chemins d'exécution (execute.py local vs replay_engine.py Léa).
12. **P2** Replays parallèles, **P3** opérabilité TIM, **P4** restore, RBAC.
---
## 7. Addendum — Avis croisé Qwen (historien/QG, 2026-06-10 23:30)
### Convergences avec l'audit code
- **DETTE-006/010 = les deux vrais risques démo** (« si le grounding dérive, les clics ratent. Démo morte. ») — aligné avec P1/Horizon 1.
- **Monitoring/alerting productif absent** (P3) : « si un worker crashe à 3h du matin sur un TIM, personne ne le saura ».
- Écarts doc vs réalité confirmés par son registre : ContinuousLearner/Shadow hook orphelins (L2), cascade YOLO et OmniParser neutralisées (DETTE-004), ~1900 lignes de code mort jamais câblé (autonomous_planner, seeclick…) — cohérent avec L5/R9.
### Apports nouveaux de Qwen (absents de l'audit code)
1. **Multi-TIM jamais testé > 1 agent simultané** : le Fleet existe, mais routage session, isolation mémoire et contention GPU sous charge réelle sont **inconnus**. Mon audit avait noté le verrou replay global (P2) ; Qwen élargit : c'est toute la concurrence multi-agents qui n'a aucune preuve d'exécution.
2. **Le pipeline complet record → replay → compétence n'a jamais tourné en conditions réelles** : M2 live n'a pas encore eu lieu. « Le premier vrai test sera devant le client » si on ne fait pas M2 avant.
3. **Incidents récurrents de son registre** : worker zombie 5 jours (résolu par watchdog N3), tunnel Ollama instable (stabilisé systemd), UI-TARS 500 non détecté (toujours 0 test dédié), OOM VRAM GB10 (fixé).
4. **DETTE-015 jugée fragile** : le symlink a déjà cassé une fois (P0-1) ; peut resurgir si cwd change.
### Point de tension à arbitrer en M2 (pas tranché)
Qwen affirme : « si le serveur redémarre, les agents Windows tombent — pas de reconnexion automatique ». Mon audit client a trouvé buffer SQLite persistant + retry + backoff + health-check 30s (`streamer.py`). Les deux peuvent être vrais : le **transport** se reconnecte, mais la **session/replay en cours** ne reprend probablement pas après un restart serveur. À vérifier explicitement pendant M2 (test : restart serveur en cours de session).
### Verdict Qwen
- **1 TIM en démo contrôlée : prêt** (sous réserve DETTE-006/010).
- **5 TIM réels en clinique : pas prêt** — le gap n'est pas dans le code métier (OCR, VLM, grounding) mais dans l'**infra multi-utilisateur** : sessions, isolation, monitoring, résilience.
Ce verdict est compatible avec la priorisation §6 et la renforce : l'Horizon 2 doit inclure un **test de charge multi-agents** (2-3 agents simultanés minimum) avant la clinique, en plus des filets de sécurité.
---
## Annexe — Contradictions inter-agents résolues (contre-vérifiées à la main)
| Affirmation agent d'audit | Verdict après vérification |
|---|---|
| « t2a_decision non implémenté » | **FAUX sur le chemin Léa** : implémenté `agent_v0/server_v1/replay_engine.py:1922` + handlers :2045+. Vrai uniquement pour le chemin local execute.py. |
| « `{{var.field.sub}}` ne marche pas » | **FAUX sur le chemin Léa** : `path.split('.')` replay_engine.py:2017. Vrai uniquement chemin local. |
| « Le chemin replay vers Léa est démis / Léa n'existe plus » | **FAUX** : pont `learned_workflow_bridge.py` côté VWB + polling `/replay/next` côté client actifs. Les deux chemins coexistent — c'est l'asymétrie connue. |
Leçon : tout audit ou modification doit d'abord identifier **sur quel chemin d'exécution** il porte.

View File

@@ -0,0 +1,170 @@
# Benchmark OCR PP-OCRv5 CPU — 02/07/2026
> **Label**: baseline CPU, non verdict GPU
> **Machine**: Ryzen 9 9950X 32 threads, 123GB RAM, RTX 5070 12GB VRAM, CUDA driver 580.159.03/13.0
> **Image**: `shot_0172_full.png` (2560×1600, 721K, RGB) — capture écran Windows Léa
> **PaddleOCR**: 3.4.0, paddlepaddle 3.3.1 CPU-only (non compilé CUDA)
---
## 1. Résultats synthèse
| Engine | Cold (s) | Warm (s) | Detections | Mem init (MB) | Mem peak (MB) | Statut |
|--------|----------|----------|------------|---------------|---------------|--------|
| **docTR CPU** | 0.776 | 0.717 | 139 | 263.2 | 263.2 | ✅ OK |
| **EasyOCR CPU** | 4.878 | 4.856 | 54 | 0.6 | 156.9 | ✅ OK |
| **PP-OCRv5 CPU** | — | — | — | — | — | ❌ BLOCKED |
---
## 2. PP-OCRv5 CPU — VERDICT: BLOCKED
### Crash récurrent
Toute inference PaddleOCR sur paddlepaddle 3.3.1 CPU-only crash systématiquement :
```
(Unimplemented) ConvertPirAttribute2RuntimeAttribute not support
[pir::ArrayAttribute<pir::DoubleAttribute>]
(at /paddle/paddle/fluid/framework/new_executor/instruction/onednn/onednn_instruction.cc:116)
```
### Root cause
Bug dans le **PIR new executor** de paddlepaddle 3.3.1 CPU-only : l'instruction OneDNN
tente de convertir un `ArrayAttribute<DoubleAttribute>` en runtime attribute, opération
non implémentée. Ce bug est :
- **NON model-spécifique** : v3_mobile_det, v4_mobile_det, v5_mobile_det crashent tous
- **NON version-spécifique** : PP-OCRv3, v4 (fr absent), v5 crashent tous
- **NON API-spécifique** : `ocr()` (deprecated) et `predict()` crashent identiquement
- **NON contournable** par flags : `FLAGS_use_mkldnn=0`, `FLAGS_use_pir_api=0` n'ont aucun effet
### 7 approches testées — TOUTES FAILED
| # | Approche | Résultat |
|---|----------|----------|
| 1 | `FLAGS_use_mkldnn=0` via `os.environ` | Same crash |
| 2 | `det='PP-OCRv5_mobile_det'` param | ValueError "Unknown argument: det" (PaddleOCR 3.4.0 rejette ce param) |
| 3 | `FLAGS_use_mkldnn=0` shell-level avant Python | Same crash |
| 4 | `text_detection_model_name='PP-OCRv5_mobile_det'` | mobile_det DL OK → inference crash (same OneDNN) |
| 5 | `ocr_version='PP-OCRv4', lang='fr'` | ValueError "No models available for language 'fr' and PP-OCRv4" |
| 6 | PP-OCRv3 + `ocr(img, cls=True)` legacy | DeprecationWarning → TypeError sur `cls` kwarg → predict() → same crash |
| 7 | `FLAGS_use_pir_api=0` shell + os level | Same crash |
### PaddleOCR 3.4.0 __init__ params inspectés
28 paramètres au total. **Pas** de `enable_mkldnn`, `use_pir`, ou `det`. Param de détection
remplacé par `text_detection_model_name`. API v3.4.0 : `use_angle_cls` deprecated
`use_textline_orientation=True`, `show_log` supprimé (ValueError si utilisé).
### Incompatibilité downgrade
paddlepaddle 2.6.2 existe mais **incompatible** avec PaddleOCR 3.4.0 (requires ≥3.x).
PaddleOCR 2.x serait compatible avec paddlepaddle 2.6.2 mais API/outils complètement
différents — non évalué dans ce bench.
### Conclusion
**PP-OCRv5 CPU = BLOCKED**. Bug upstream dans paddlepaddle CPU-only binary, aucune
workaround applicative possible. Seules alternatives :
1. **paddlepaddle GPU binary** (RTX 5070 + CUDA 13.0 compatible) → bench GPU séparé
2. **Fix upstream** paddlepaddle (PR PIR executor OneDNN)
3. **Downgrade PaddleOCR 2.x + paddlepaddle 2.6.2** (API legacy, non testé)
---
## 3. docTR CPU — Résultats détaillés
- **Cold latency**: 0.776s (incl. model loading)
- **Warm latency**: 0.717s
- **Detections**: 139 (mot-level, agressif — fragmente "Dites", "Sortie", "de", "veille")
- **Mémoire**: 263.2MB stable (init = peak)
- **Qualité**: haute sur mots courts, fragmente les phrases longues
- **Confiance**: variable (0.26→0.99), nombreux tokens <0.7
### Observations docTR
- Word-level detection = 139 items → beaucoup de fragments 1-2 lettres
- Bonne qualité sur labels UI ("Mode", "veille", "RPA", "VWB", "Python", "proxmox")
- Fragmente les phrases ("Sortie de veille de l'accès vocal ou appuyez..." → 12 mots isolés)
- IP correctement détecté : "192.168.1.40:3002" (conf 0.90)
- Faux positifs : "0", "E03", "E", "€" isolés avec conf <0.4
---
## 4. EasyOCR CPU — Résultats détaillés
- **Cold latency**: 4.878s (heavy model loading)
- **Warm latency**: 4.856s
- **Detections**: 54 (line-level, plus conservatif)
- **Mémoire**: 0.6MB init → 156.9MB peak
- **Qualité**: bonne sur lignes complètes, plus robuste sur phrases
### Observations EasyOCR
- Line-level detection = 54 items → phrases plus cohérentes
- Cold start très lent (5x docTR) mais warm identique
- Meilleur sur textes longs, moins de fragmentation
- Peak mémoire plus élevé que docTR (156.9 vs 263.2 MB init docTR)
---
## 5. Comparaison avec baselines Mai 2026
> Bench Mai 2026 — image `landing_wide.png`, critère 11 items de référence
| Engine | Score Mai (11 ref) | Score Juillet (detections) | Latency warm | Commentaire |
|--------|-------------------|---------------------------|--------------|-------------|
| Tesseract | **11/11** | — (non re-benché) | — | Référence May, non retesté |
| EasyOCR brut | 8/11 | 54 det (shot_0172) | 4.856s | Fragmente moins, score < Tesseract |
| EasyOCR preproc | 9/11 | — | — | +1 vs brut May |
| docTR CPU | 10/11 | 139 det (shot_0172) | 0.717s | **Meilleur rapport qualité/latence** |
| PP-OCRv5 CPU | non testé May | BLOCKED | — | Bug PIR/OneDNN, 0 inference possible |
### Hierarchie CPU confirmée
```
docTR CPU (0.7s, 10/11) > EasyOCR preproc (4.9s, 9/11) > EasyOCR brut (4.9s, 8/11) > PP-OCRv5 CPU (BLOCKED)
```
docTR reste le **meilleur moteur OCR CPU** pour Léa en termes de latence + qualité.
Tesseract reste le plus précis (11/11) mais sans bounding boxes exploitables.
---
## 6. Recommandations
1. **docTR = moteur OCR CPU de production** — latence <1s, qualité 10/11, word-level bboxes
2. **PP-OCRv5 GPU bench = action séparée** — requiere paddlepaddle GPU binary sur RTX 5070
3. **PaddleOCR 3.4.0 = ORPHAN** — 0 imports dans le projet, pas dans requirements.txt,
CPU-only install sans CUDA → retirer du venv si cleanup D2 (C-MORT)
4. **Ne pas dépendre de PaddleOCR** pour POC T1 — docTR suffisant
5. **Bug report upstream** — paddlepaddle PIR executor OneDNN, repro: any model + CPU binary
---
## 7. Annexes
### A. Script bench
`scripts/bench_ppocrv5_cpu.py` — compare PP-OCRv5, docTR, EasyOCR sur shot_0172_full.png.
PP-OCRv5 crash → résultats JSON avec error field.
### B. Résultats JSON
`scripts/bench_ppocrv5_results.json` — 4522 lignes, contient tous texts + bboxes pour
docTR (139 items) et EasyOCR (54 items). PP-OCRv5 = error only.
### C. Machine specs
- CPU: Ryzen 9 9950X, 32 threads
- RAM: 123 GB
- GPU: RTX 5070 12GB VRAM (non utilisé — bench CPU)
- CUDA driver: 580.159.03 / runtime 13.0
- OS: Linux (Ubuntu)
- paddlepaddle: 3.3.1 CPU-only (pip install)
- PaddleOCR: 3.4.0
- docTR: (version installée dans venv)
- EasyOCR: (version installée dans venv)

View File

@@ -0,0 +1,104 @@
# Cartographie — Chaîne d'apprentissage & mise en commun des connaissances (2026-06-16)
> Question Dom : *« Comment le savoir appris sur chaque poste est-il mutualisé dans un fonds commun unique partagé par tous les postes, et exploité vers l'autonomie ? »*
> Méthode : graphify (graphe code 58k nodes) + 3 agents Explore + vérif code directe. Cross-checks Codex (pipeline server_v1) & Qwen (fédération/FAISS) **en cours** — ce doc sera enrichi.
> ⚠️ Verdicts = état runtime constaté ce jour (`poc-dgx` @ `2b1743c20`), pas la doc d'intention.
## TL;DR (réponse directe)
Le « fonds commun → autonomie » est **partiellement construit, mais les maillons clés sont soit silotés par machine, soit dormants** :
1.**Ce qui EST déjà commun** : les **compétences YAML** (`core/competences/`) et les **embeddings** (CLIP→FAISS) — partagés serveur, tous postes.
2.**Ce qui est SILOTÉ par machine (codé en dur)** : le **stockage** workflows/sessions (`{machine_id}/`) et surtout le **cross-session learning** qui **refuse de matcher entre machines** (`if workflow_machine != machine_id: continue`). C'est l'anti-pattern direct vs la vision.
3. 🌙 **Ce qui est DORMANT** : la **fédération** (`core/federation` : LearningPack, GlobalFAISSIndex) — le vrai mécanisme de fonds commun — est **bien conçue, globale et anonymisée** (Qwen confirmé : zéro machine_id, clé `pack_source_hash`), mais **doublement inerte au runtime** : (a) alimentée **seulement** par l'endpoint import **manuel** (jamais auto-déclenché) ; (b) son **`search()` n'est JAMAIS appelé** (`faiss_global.py:199` défini, zéro consommateur actif) → **index write-only, jamais consulté** → contribue **zéro** au comportement/à l'autonomie aujourd'hui.
4. 🚧 **Ce qui manque pour l'autonomie** : la **couche graphe** (WorkflowGraph) **EST construite en live** (`finalize_session``GraphBuilder`, import lazy `stream_processor.py:3017-3022`, DBSCAN) — **correction d'un faux "orphelin"****mais le graphe est siloté par machine** (persisté sous `workflows/{machine_id}/`) et le merge cross-session est machine-filtré. La progression **Shadow→Copilot→Autonomous** reste **du design, pas du runtime** (Shadow observe+log).
> ⚠️ **Caveat méthodo** : ce code utilise massivement des **imports lazy dans les handlers/méthodes**. Les verdicts "orphelin" basés sur grep d'imports top-level sont **non fiables** (federation ET GraphBuilder étaient ainsi faussement classés orphelins, puis confirmés WIRED). Tout "orphelin" ci-dessous non vérifié par lazy-import est à recontrôler.
→ Aujourd'hui, par défaut, **chaque poste est un silo cognitif** : il n'apprend pas des autres, sauf pour les compétences YAML et les embeddings centralisés.
## Tableau de synthèse (WIRED ? · COMMUN/SILOTÉ)
### A. Capture → construction → stockage (agent Explore #1)
| Composant | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|---|---|---|---|
| TraceStreamer (tag machine_id) | `agent_v1/network/streamer.py:91` | ✅ (main.py:227) | tague machine_id sur chaque POST |
| register/stream/finalize | `server_v1/api_stream.py:1748/1801/2336` | ✅ endpoints | session taguée machine_id |
| `_persist_workflow` | `server_v1/stream_processor.py:4417` (appelé 3066) | ✅ | **SILOTÉ** : écrit `data/training/workflows/{machine_id}/` + tag `_machine_id` |
| Store disque sessions | `data/training/live_sessions/{machine_id}/` | ✅ | **SILOTÉ** (arbo 1:1 par machine) |
| `_run_cross_session_learning` / `_find_best_cross_session_match` | `stream_processor.py:3149 / 3273` | ✅ (via finalize) | **SILOTÉ CODÉ** : `if workflow_machine != machine_id: continue` (L3284-3286) → un poste n'apprend jamais d'un autre |
| Listing `list_workflows` | `api_stream.py:2799` + `stream_processor.py:4518` | ✅ | **BIMODAL** : `machine_id=None` → tous ; sinon filtré |
| Client `list_workflows()` | `lea_ui/server_client.py:228` | ✅ (smart_tray:802) | **COMMUN** : n'envoie PAS machine_id → reçoit tous |
| Dashboard list_sessions | `web_dashboard/app.py:2289` | ✅ | filtre disque par machine_id (optionnel) |
| Replay ciblage | `api_stream.py:3064` + `replay_engine.py:1559` | ✅ | machine_id = **ROUTE** l'exécution vers le bon poste (légitime) |
### B. Apprentissage / cognition / compétences (agent Explore #2)
| Module | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|---|---|---|---|
| `target_memory_store` (mémoire cibles, phase 1) | `core/learning/target_memory_store.py:77` | ✅ hot-path `resolve_engine.py:1869` | **SILOTÉ par machine** (`data/learning/target_memory.db` local, sauf `RPA_LEARNING_DIR` partagé) |
| `continuous_learner` | `core/learning/continuous_learner.py` | ✅ (`stream_processor.py:3145`) | **SILOTÉ par session** |
| `replay_learner` | `server_v1/replay_learner.py:90` | ✅ (`api_stream.py:2436`) | **SILOTÉ par session** |
| `learning_manager` (états workflow VWB) | `core/learning/learning_manager.py:37` | ✅ singleton VWB | **COMMUN** |
| **Compétences YAML** (catalog/replay/persist/verdicts/promotions) | `core/competences/*` | ✅ endpoints `api_stream` + dashboard | ✅ **COMMUN** (tous lisent `data/competences/`) — **c'est le vrai fonds commun qui marche** |
| `observe_reason_act` (ORALoop) | `core/execution/observe_reason_act.py:145` | ✅ (VWB `api_v3/execute.py`) | siloté par exécution |
| `feedback_processor`, `versioned_store`, `core/cognition/*`, `core/knowledge`, `core/coaching`, `core/healing`, `core/supervision` | — | ⚠️ **ORPHELINS** (tests seuls) | — |
### C. Graphe / embeddings / autonomie (agent Explore #3)
| Composant | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|---|---|---|---|
| ScreenState (perception) | `core/pipeline/workflow_pipeline.py` | ✅ serveur | COMMUN |
| StateEmbedding + Builder (fusion 512d) | `core/models/state_embedding.py:44` / `core/embedding/state_embedding_builder.py:25` | ✅ `stream_processor` startup | **COMMUN** (vecteurs `data/training/embeddings/*.npy` centralisés) |
| CLIP (OpenCLIP ViT-B-32) | `core/embedding/clip_embedder.py` | ✅ | COMMUN |
| FAISSManager (similarité) | `core/embedding/faiss_manager.py:40` | ✅ `stream_processor` | **COMMUN serveur** mais **index per-session en mémoire** (pas un index global persistant) |
| **WorkflowGraph builder (couche 4)** | `core/graph/graph_builder.py:148` | ✅ **WIRED** (import lazy `stream_processor.py:3017`, instancié `:3022` dans `finalize_session`, DBSCAN) — *corrigé : Agent #3 l'avait dit orphelin à tort* | **SILOTÉ** : graphe construit par session, persisté `workflows/{machine_id}/` ; merge cross-session machine-filtré |
| WorkflowNode/Edge (modèles couche 4) | `core/models/workflow_graph.py:384` | ✅ utilisés par GraphBuilder | siloté (idem) |
| **Shadow** observer | `core/workflow/shadow_observer.py:25` | ⚠️ partiel (`api_stream:2700` observe + LOG seulement) | pas d'apprentissage collectif |
| **Copilot / Autonomous** | `core/learning/learning_engine.py` | ❌ **design papier**, pas runtime | — |
| `audit_trail.execution_mode` (shadow/assisted/autonomous) | `server_v1/audit_trail.py:50` | ✅ enregistré | mais **pas exploité** pour décider |
### D. Fédération = LE fonds commun par design (vérif directe + Qwen en cours)
| Composant | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|---|---|---|---|
| `LearningPackExporter` | `core/federation/learning_pack.py` | 🌙 **endpoint manuel** `GET /api/v1/traces/stream/learning-pack/export` (`api_stream.py:6278`, import lazy L6292) | conçu commun |
| `LearningPack` + `GlobalFAISSIndex` | `core/federation/learning_pack.py:294` / `faiss_global.py:51` | 🌙 **endpoint manuel** `POST .../learning-pack/import` (`api_stream.py:6323`, L6334-6353 `GlobalFAISSIndex()` ré-instancié) | conçu commun |
| Déclenchement automatique | — | ❌ **AUCUN** : rien dans le flux capture→learn n'appelle export/import | → fonds commun **dormant** |
**Verdict fédération (Claude + Qwen)** :
-**Bien architecturé** (Qwen) : `LearningPack` anonymise (machine_id blacklisté `_SENSITIVE_METADATA_KEYS:64`, `source_hash` SHA-256), `GlobalFAISSIndex` est **global** (clé `pack_source_hash`+`workflow_skeleton_id`+`node_name`+`app_name`, **aucun machine_id**), persistant (`.faiss`+`.meta.json`, `save/load` L245/277). Export prend **tous** les workflows (`processor._workflows.values()` L6305) sans filtre machine.
-**Mais doublement inerte** : (a) **pas d'auto-déclenchement** (rien dans capture→learn n'appelle export/import) ; (b) **`search()` jamais appelé** — `_global_faiss_index` n'est référencé QU'aux lignes `api_stream.py:6351-6372` (instanciation + `add_pack` à l'import). Aucun chemin de résolution/apprentissage/replay ne **lit** l'index global. → **write-only, jamais consulté** : contribue 0 au runtime.
Conclusion : le fonds commun fédéré est **codé correctement mais débranché du runtime** — c'est exactement le maillon à activer pour la vision.
## Où `machine_id` cloisonne vs route
- **ROUTE (légitime)** : ciblage d'un replay/d'une session vers le bon poste (`replay_engine.py:1559`, `start_replay`).
- **CLOISONNE (à corriger vs vision)** : (1) dossiers de stockage `{machine_id}/` ; (2) **filtre dur du cross-session learning** (`stream_processor.py:3284`) ; (3) `target_memory.db` local par machine. + machine_id **instable** (nouvel ID à chaque relance) qui fragmente même au sein d'un poste.
## Gap vs vision « fonds commun → autonomie » (constats, pas décisions)
Pour réaliser la vision, il manque le câblage de :
1. **Dé-siloter le savoir workflows/sessions** : retirer le filtre machine_id du cross-session learning + stockage commun (ou index commun), en gardant machine_id pour le seul routing.
2. **Activer la fédération en continu** (auto-export/import ou store partagé) au lieu du manuel dormant — c'est l'endroit conçu pour ça.
3. **Câbler la couche graphe (4)** en live (aujourd'hui orpheline) pour un knowledge graph commun.
4. **Implémenter Shadow→Copilot→Autonomous** (aujourd'hui observe+log / design) consommant ce fonds commun.
5. **Stabiliser machine_id** (persisté) pour ne pas fragmenter.
## Ce qui marche DÉJÀ comme fonds commun (à capitaliser)
- **Compétences YAML** (`core/competences/`) : micro-workflows réutilisables, états supervisés, **lus par tous les postes**. C'est le modèle commun qui fonctionne → piste à étendre.
- **Embeddings centralisés** (`data/training/embeddings/`) : matière première commune déjà là.
## E. Pipeline server_v1 (cross-check Codex — intégré)
- Pipeline **WIRED** : capture → `_worker_queue.txt``run_worker.py``StreamProcessor.reprocess_session()` → workflow JSON → replay → apprentissage. `api_stream.py` importe+instancie `ReplayLearner`/`StreamProcessor`/`StreamWorker` (`:32/40/41`, `:562-563`, startup `:1626-1629`).
- **`ReplayLearner` = commun mais faiblement effectif** : `ActionOutcome` sans `machine_id`, stockage global `data/learning/replay_results/` ; MAIS `query_similar()` ne lit que le cache mémoire `_recent` (`:273-304`) et `build_replay_from_raw_events()` crée une **nouvelle instance** (`stream_processor.py:2379-2382`) → **l'historique JSONL global n'est pas exploité** après restart/hors instance globale.
- **Risque ambiguïté** : la queue worker ne porte que `session_id`, pas `machine_id` (`api_stream.py:734-760`) → résolution disque ambiguë si 2 machines ont le même `session_id`.
- `machine_id` **route** légitimement : `/replay`, `/replay/next` refusent les actions d'une autre machine (`:3978-3982`, `:4033-4054`), `_find_active_agent_session` filtre `machine_id`/`bg_<machine_id>` (`replay_engine.py:1559-1588`).
## Pistes de correction CONVERGENTES (constats Codex+Claude — NON décidées, mapping seulement)
1. **Cantonner `machine_id` au routing/fleet/replay-target/audit** — pas au stockage ni au matching du savoir.
2. **Dé-siloter les workflows appris** : sortir du stockage logique `workflows/{machine_id}/` (ou neutraliser ce marqueur en lecture/matching).
3. **Retirer/rendre optionnel le filtre machine** dans `_run_cross_session_learning()` / `_find_best_cross_session_match()` (`stream_processor.py:3193-3203`, `3283-3286`) → apprendre sur tous les workflows compatibles.
4. **Brancher le fonds commun fédéré** : (a) alimenter le `GlobalFAISSIndex` en continu (auto export/import ou store partagé) ; (b) **appeler son `search()`** dans le hot-path résolution/apprentissage (aujourd'hui jamais lu).
5. **Rendre `ReplayLearner` durable** : charger/interroger l'historique JSONL global, réutiliser l'instance globale (pas une neuve par session).
6. **Stabiliser `machine_id`** (persisté) pour ne pas fragmenter intra-poste.
7. **Étendre le modèle "compétences communes"** (`core/competences/`, déjà commun + supervisé) comme colonne vertébrale du fonds commun.
---
*Sources : graphify-out (58k nodes) ; agents Explore #1/#2/#3 (⚠ verdicts "orphelin" sur imports top-level corrigés par lazy-import) ; cross-checks **Codex** (server_v1, msg 16:43) & **Qwen** (federation/FAISS, msg 16:50) intégrés ; vérifs directes `api_stream.py:6271-6372`, `faiss_global.py:199`, `stream_processor.py:3017-3022`.*

View File

@@ -0,0 +1,186 @@
# CARTO CODE NON BRANCHÉ — carte de référence wiring (2026-07-02)
> **But** : carte « existing-first » de référence. AVANT tout chantier/bench/proposition,
> consulter ce doc pour savoir si une brique existe et si elle est **réellement branchée au
> runtime**. Recadrage Dom 02/07 : « vérifier ce qui existe et non branché, c'est le BABA ».
>
> **Méthode** : verdict prouvé par chaîne d'imports depuis un point d'entrée actif
> (fichier:ligne), imports lazy inclus, gates de config citées. Jamais de conclusion sur un
> grep seul. Sources fusionnées : agent Claude « intelligence » + carto Qwen (volet 1
> détection) + `AUDIT_CODE_MORT_2026-07-02.md` (Qwen) + vérifs ponctuelles Claude.
>
> **Légende** : **WIRED** (chaîne prouvée) · **GATED** (branché mais derrière flag, défaut
> cité) · **ORPHELIN** (0 appelant runtime, recherche exhaustive) · **INCERTAIN** (non tranché,
> raison donnée).
>
> **Points d'entrée actifs runtime** : `api_stream.py` (streaming 5005, `rpa-streaming`) ·
> `run_worker.py` (worker VLM 5099) · VWB `app.py` (5002) · `web_dashboard/app.py` (5001) ·
> `agent_chat/app.py` (5004) · `server/api_upload.py` (8000).
---
## 0. Résumé exécutif — les découvertes qui changent une décision
1. **Self-healing = façade morte, malgré doc « wired ».** Chaîne d'import réelle (VWB →
`core/healing`), routes REST répondent, MAIS déclenchement **impossible** : le code teste
`hasattr(healing_integration, 'enable_healing')` et cette méthode **n'existe nulle part**
(`execution_integration.py:421`). `handle_execution_failure` = 0 appelant d'exécution.
Preuve d'inertie : `logs/healing/recovery.log` = **0 octet, mtime déc. 2025**. Le pont
manquant tient à **une méthode**, pas un module. → `PLAN_MENAGE_CODE_MORT` le classait
« wired — NE PAS TOUCHER » : **doc fausse**.
2. **`core/navigation` (commit du matin `f9a053132`) = write-only.** Le handler résout le
login et écrit `navigate_login_coords` dans `replay_state["variables"]`, mais **aucun
consommateur** : le compilateur `_edge_to_normalized_actions` n'a pas de branche `navigate`
et produit des coords littérales, jamais de templates `{{navigate_login_coords.x_pct}}`.
Détail : `docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md`. Décision D1 en attente Dom.
3. **AutonomousPlanner : coût sans usage.** Instancié au boot d'`agent_chat` (charge LLM +
OWL detector via `autonomous_planner.py:36`), mais **aucune route n'appelle une méthode de
planification** — seuls des setters. Type même du « code écrit jamais invoqué ».
4. **PaddleOCR installé, jamais importé.** `paddleocr 3.4.0` + `paddlepaddle 3.3.1` (CPU)
présents dans `.venv`, **0 `import paddle` dans le code**, 0 requirements, 0 deploy. Piste
bench en cours (Qwen), pas un composant actif.
5. **YOLO cascade de résolution = mort.** `_resolve_by_yolo` défini
(`resolve_engine.py:458`) + importé (`api_stream.py:6114`) mais **jamais appelé** ; aucune
branche `yolo` dans la cascade compilée. ⚠ À NE PAS confondre avec le YOLO de `som_engine`
(OmniParser SoM), lui **WIRED**.
6. **`server/api_core.py`** : blueprint Flask complet (capture/detect/embed/faiss) **jamais
enregistré** — orphelin absent du plan ménage.
7. **Nos propres cartos avaient 4 erreurs** (cf. §4). Re-prouver était justifié.
---
## 1. Chaîne détection / grounding / résolution
| Module | Verdict | Preuve (fichier:ligne) | Remarque |
|--------|---------|------------------------|----------|
| `core/detection/som_engine.py` | **WIRED** | resolve_engine.py:1192 (replay) · stream_processor.py:643 (recording) · api_stream.py:1958 (temps réel) | 3 chemins indépendants, singleton thread-safe. Tire YOLO weights direct. |
| `core/detection/omniparser_adapter.py` | **B-DORMANT** (branché lazy, fallback vide) | phase25_analyzer.py:388 · resolve_engine.py:437 · désactivé côté VWB (`_omniparser_available=False`) | Import lazy try/except, singleton. 7 zones cartographiées (§ audit Qwen). |
| `core/detection/owl_detector.py` | **WIRED (via AutonomousPlanner) — mais planner inerte** | autonomous_planner.py:36 | Chargé au boot agent_chat pour rien (cf. §0.3). 4 méthodes internes C-MORT. |
| `core/detection/ollama_client.py` | **WIRED partiel** | `classify_element_complete()` actif ; 5 vieilles méthodes + `check_ollama_available()` standalone = C-MORT | Duplicat VWB (D2). |
| `_resolve_by_yolo` (resolve_engine.py:458) | **ORPHELIN** | importé api_stream.py:6114, **0 appel réel**, 0 branche cascade | ≠ YOLO de som_engine (wired). |
| `core/grounding/bbox_parser.py` | **WIRED** | resolve_engine.py:29 | |
| `core/grounding/smart_resize.py` | **ORPHELIN (C-MORT)** | 0 appelant prod, DETTE-007 triple impl (2 autres existent) | |
| `core/grounding/server.py` | **WIRED** | service HTTP Flask port 8200 standalone | Upgrade C→A (Qwen). |
| `visual_workflow_builder/.../api/ui_detection.py` | **WIRED** | VWB app.py:310 (blueprint) · fast_detector.py:117 | UI-DETR-1 du recording, modèle rfdetr RFDETRMedium, 5 endpoints `/api/ui-detection`. |
| `core/semantic/phase25_analyzer.py` | **WIRED** | api_stream.py:7690 (route `lea_competence_persist:7435`) | |
| `core/extraction/{field_extractor,vlm_client,role_mapper}` | **WIRED-transitif** | field_extractor ← input_handler.py:121/504/722 (lazy) · vlm_client+role_mapper ← core/navigation/__init__.py:69, action_resolver.py:109 | Le plan ménage 23/06 (« 4/5 morts ») précède navigation. |
| `core/llm/` (ocr_extractor, extract_grid) | **WIRED** | api_stream.py:1766 · replay_engine.py:2115-2403 · resolve_engine.py:2597 | |
| `core/navigation/` | **WIRED (boot) / write-only (fonctionnel)** | api_stream.py:440 top-level NON gardé · handler résout mais 0 consommateur coords | cf. §0.2. ⚠ import non gardé → si casse, 5005 ne boote pas (garde-fou test_navigate_wiring.py). |
| PaddleOCR (venv) | **ORPHELIN** | 0 import, 0 requirements, 0 deploy | cf. §0.4. |
---
## 2. Modules « intelligence »
| Module | Verdict | Preuve (fichier:ligne) | Remarque |
|--------|---------|------------------------|----------|
| `core/healing/` | **ORPHELIN de fait** (importé, indéclenchable) | chaîne VWB app.py:217 → api/self_healing.py → services/self_healing_integration.py, MAIS `enable_healing` inexistant (execution_integration.py:421) ; `handle_execution_failure` 0 appelant | cf. §0.1. `logs/healing/recovery.log` vide depuis déc. 2025. |
| `core/coaching/` | **WIRED** | VWB app.py:284-285 (blueprint) → api/coaching_sessions.py:17,22 · exec : execution_integration.py:869 · front WebSocket | REST blueprint peut-être non consommé par l'UI (front = socket.io). |
| `core/cognition/working_memory` | **WIRED-transitif** | observe_reason_act.py:30,506 · ORALoop ← VWB execute.py:1542,2075 | Les 4 autres sous-modules cognition = MORTS (tests only). |
| `core/learning/` (4/5) | **WIRED** | target_memory_store: resolve_engine.py:1865 + api_stream.py:5132 · continuous_learner: stream_processor.py:3147 · learning_manager: VWB learning_integration.py:36, api/workflows.py:696 · feedback_processor: execution_loop.py:317 | `versioned_store` ORPHELIN. `record_observation` = **0 appelant** (learning_manager.py:54). |
| `core/execution/` | **WIRED massif** | observe_reason_act ← execute.py:1542 · input_handler ← execute.py:69 · dag_executor+llm_actions ← dag_execute.py:33,40 · action_executor/target_resolver/error_handler/execution_loop ← agent_chat app.py:328-340 · +transitifs | Morts : spatial_index, target_memory, workflow_runner (⚠ encore exporté par `__init__.py:10`). |
| `core/auth/` | **GATED — défaut OFF** | api_stream.py:278-286 : import lazy SSI `RPA_AUTH_VAULT_PATH` **et** `RPA_AUTH_VAULT_PASSWORD` définis (absents par défaut). Seul lieu qui les définit : CI `.gitea/workflows/tests.yml:35` | Vault inactif en prod. TOTP dans la même chaîne gated. |
| `core/federation/` | **WIRED manuel, write-only** | routes actives non gated : GET learning-pack/export api_stream.py:6431 · POST import :6476 | `GlobalFAISSIndex.search()` = **0 appelant**. Aucun auto-déclenchement. |
| `core/gpu/` (2/6) | **WIRED** | device_policy ← resolve_engine.py:1750 (hot-path) · gpu_resource_manager ← agent_chat app.py:53,266 | clip_manager, ollama_manager, vram_monitor, preflight = morts. |
| `core/embedding/` | **WIRED (lazy)** | construction CLIP/FAISS ← stream_processor.py:2560 `_ensure_initialized` (appelé process_screenshot:2804 + finalize_session:2969) · lecture web_dashboard app.py:309+ | Se déclenche au 1er screenshot / finalisation, pas au boot. |
| `agent_chat/autonomous_planner` | **INSTANCIÉ mais INERTE** | import app.py:48, instancié :358, mais seuls appels = setters :362,367 ; 0 route de planification | cf. §0.3. Tire owl_detector pour rien. |
| `agent_chat/urgences_orchestrator` | **WIRED** | import lazy app.py:2740, routes `/api/urgences/*` | |
| `agent_chat/gesture_catalog` | **WIRED ×2** | agent_chat app.py:377,955 · **api_stream.py:269,3598** (hot-path replay `optimize_replay_actions`) | Pas seulement le chat. |
| `core/validation/` | **GATED — défaut OFF** | flag `RPA_VALIDATOR_V2_ENABLED` défaut OFF (api_stream.py:91), consommé report_action_result:4924 | |
**WIRED confirmés (survol)** : capture, models, competences, corrections, data, graph,
knowledge, monitoring, persistence, pipeline, system, workflow, visual, config.py,
anonymisation (PII), matching/training (transitifs).
**ORPHELINS confirmés** : variants, precision, supervision, interfaces (0 importeur non-test) ·
`core/evaluation/` (consommé seulement par `tools/lea_bench*.py`, outillage CLI) ·
`server/api_core.py` (blueprint jamais enregistré).
---
## 3. Zones GATED (flags + défaut) — activation supervisée
| Flag | Défaut | Effet si ON | Preuve |
|------|--------|-------------|--------|
| `RPA_AUTH_VAULT_PATH` + `RPA_AUTH_VAULT_PASSWORD` | absents | active `core/auth` (vault Fernet + TOTP) | api_stream.py:278-286 |
| `RPA_VALIDATOR_V2_ENABLED` | OFF | active validation V2 (report_action_result) | api_stream.py:91 |
| `RPA_R1_AUTO_IMPORT` | OFF | active import auto core→DB VWB (R1) | api_stream.py:~4480 (revue en cours) |
| `RPA_AUTO_UPDATE_ENABLED` | OFF | MAJ silencieuse client (DETTE-022) | agent_v1/config.py:103 |
| `RPA_GROUNDING_ENGINE=qwen3vl_vllm` | legacy Qwen2.5-VL | grounder Qwen3-VL (override DGX runtime) | resolve_engine.py:1001-1007 |
---
## 4. Divergences corrigées avec les docs existants
1. **`core/healing` : doc `PLAN_MENAGE` = « wired, NE PAS TOUCHER » → FAUX.** Indéclenchable
(`enable_healing` fantôme, log vide déc. 2025). Le pont est à une méthode près.
2. **`feedback_processor` : CARTO 16/06 = ORPHELIN → FAUX.** Instancié à chaque ExecutionLoop
(execution_loop.py:317).
3. **`core/cognition` : CARTO 16/06 = tout orphelin → FAUX pour working_memory** (vivant au
runtime VWB via observe_reason_act).
4. **`core/extraction` : plan ménage « 4/5 morts » → périmé.** vlm_client + role_mapper
branchés via `core/navigation` (postérieur au doc).
Upgrades C→A/B confirmés par Qwen : autonomous_planner (C→A, mais inerte cf. §0.3),
seeclick_adapter (C→B), grounding/server.py (C→A), get_grounding_profile (C→A).
---
## 5. Code mort candidat suppression → voir `AUDIT_CODE_MORT_2026-07-02.md`
Résumé : **8 C-MORT** (~843 lignes, ex. deploy_windows.py, smart_resize.py, 7 config classes
dépréciées, agent_chat 410 endpoints) · **5 B-ORPHELIN** (à conserver, projections) · **4
duplicats** (décision Dom). Suppression = GO Dom par lot, worktree isolé + tests après chaque
lot. ⚠ Prudence renforcée vu les 4 erreurs de doc du §4 : re-prouver chaque item avant
suppression.
---
## 6. Cascade de résolution UI (`resolve_engine.py`) — ordre RÉEL prouvé
Point d'entrée unique au replay : le client Léa (`executor.py:2847`, **`strict_mode=True` hardcodé**
:2870) → route `resolve_target` (`api_stream.py:6131`) → `_resolve_target_sync`
(`resolve_engine.py:1804`). `replay_engine.py` ne résout pas (il construit le target_spec).
**Ordre réel au replay (mode strict VLM-first, `resolve_engine.py:1957`)** :
```
0. Mémoire persistante (replay_memory.memory_lookup:1869) — hit → skip toute la cascade
0c. dialog_button → OCR seul (1920-1952)
── strict VLM-first (1957) ──
S0a. Grounding VLM (_resolve_by_grounding:2019) si by_text_source ∈ {ocr, vlm}
S0b. Template matching icônes (2057) sinon
S0.5 OCR direct (_resolve_by_ocr_text:2105) si by_text
S1. VLM Quick Find (_vlm_quick_find:2158)
S1.5 SoM + VLM (_resolve_by_som:2207)
S2. Template matching fallback (2238)
S3. STOP replay resolved=False (2283)
```
Note : grounding VLM (S0a) et VLM Quick Find (S1) sont **deux appels VLM distincts**.
**Statut resolvers** : `_resolve_by_grounding`, `_resolve_by_template_matching`,
`_resolve_by_ocr_text`, `_vlm_quick_find`, `_resolve_by_som`, `replay_memory` = **WIRED**
(preuves lignes ci-dessus). Grounder Qwen3-VL : bascule dans `_resolve_by_grounding:1006`
(flag `RPA_GROUNDING_ENGINE=qwen3vl_vllm`, sinon legacy Qwen2.5-VL) — change modèle/endpoint/
prompt/parser, pas le flux.
**3 branches MORTES dans la cascade** :
- `_resolve_by_yolo` (:458) — importé api_stream.py:6114, **0 appel réel**. ORPHELIN.
- **Vérification CLIP** (:1972-2008) — **dead gate** : lit `target_spec["clip_embedding"]`
qui n'est **jamais peuplé** dans tout `agent_v0/` → branche jamais exécutée.
- **V4 pré-compilé** (`_resolve_with_precompiled_order:1601`, ordre figé `["ocr","template",
"vlm"]`) — **WIRED mais dormant en replay normal** : alimenté uniquement par l'endpoint
`/replay/plan` (`execution_plan_runner.py:173`), jamais par le flux VWB→Léa.
**Verdict README « OCR→template→YOLO→VLM » = FAUX** : (1) YOLO mort, (2) l'ordre est
VLM-first, (3) la séquence `ocr,template,vlm` n'existe que dans le V4 dormant.
## 7. Zones restantes non re-vérifiées (honnêteté)
- `core/analytics/` : ~13 sous-modules orphelins non re-vérifiés un par un (conforme doc).
- Reste couvert : chaîne détection/grounding/résolution + intelligence = prouvés. **Carto
considérée complète sur le périmètre runtime actif.**

View File

@@ -0,0 +1,145 @@
# CHECKLIST DGX — Contrôle avant installation clinique
- `Auteur`: Qwen
- `Date`: 2026-06-19
- `Version`: v1 — à vérifier point par point avant déploiement site clinique
---
## 1. SERVICES — Tous démarrent au reboot
| # | Service | Port | Statut attendu | Check |
|---|---|---|---|---|
| 1.1 | rpa-streaming | 5005 | `health=200` | `curl http://127.0.0.1:5005/health` |
| 1.2 | rpa-vision-v3-dashboard | 5001 | `401 sans creds, 200 avec creds` | `curl -u lea:<password> http://127.0.0.1:5001/api/system/status` |
| 1.3 | rpa-vision-v3-vwb-backend | 5002 | `401 LAN, 200 loopback` | `curl http://127.0.0.1:5002/health` puis `curl http://192.168.x.x:5002/health` |
| 1.4 | rpa-agent-chat | 5004 | `200` | `curl http://127.0.0.1:5004/api/status` |
| 1.5 | rpa-vision-v3-api | 8000 | `fermé LAN` | `curl http://192.168.x.x:8000` → timeout/refused |
| 1.6 | rpa-vision-v3-vwb-frontend | 3002 | `200` | `curl http://127.0.0.1:3002` |
| 1.7 | rpa-vision-v3-stream-worker | 5099 | `running` | `systemctl status rpa-vision-v3-stream-worker` |
| 1.8 | rpa-vision-v3-worker | — | `running` | `systemctl status rpa-vision-v3-worker` |
| 1.9 | rpa-firewall | — | `active (exited)` | `systemctl status rpa-firewall` |
| 1.10 | Dashboard systemd | 5001 | **service system ACTIF** (pas fallback user) | ✅ **VALIDÉ reboot 20/06** — system service active, fallback user masked |
**Check reboot** : `systemctl list-units --type=service | grep rpa` → tous `active running` ou `active exited`
---
## 2. RÉSEAU — Ports sensibles fermés LAN
| # | Port | Risque | Statut attendu | Check |
|---|---|---|---|---|
| 2.1 | 5900 (VNC GNOME) | Remote desktop | **LAN fermé, loopback OK** | `nmap 192.168.x.x -p 5900` → filtered/closed |
| 2.2 | 5902 (VNC VM Windows) | Remote desktop VM | **LAN fermé, tunnel SSH only** | `nmap 192.168.x.x -p 5902` → filtered/closed |
| 2.3 | 3389 (RDP/xrdp) | Remote desktop | **LAN fermé** | `nmap 192.168.x.x -p 3389` → filtered/closed |
| 2.4 | 22220 (SSH VM Windows) | Shell VM | **LAN fermé** | `nmap 192.168.x.x -p 22220` → filtered/closed |
| 2.5 | 8000 (API upload) | API non protégé | **LAN fermé** | `nmap 192.168.x.x -p 8000` → filtered/closed |
| 2.6 | 11434 (Ollama) | Modèles IA | **LAN fermé** | `nmap 192.168.x.x -p 11434` → filtered/closed |
| 2.7 | 5002 (VWB backend) | Données workflows | **LAN : auth requise (401)** | `curl http://192.168.x.x:5002/api/workflows/` → 401 |
| 2.8 | 5004 (Agent chat) | Chat interface | **À arbitrer** — ouvert ou fermé ? | Décision Dom |
| 2.9 | 3002 (VWB frontend) | Interface web | **À arbitre** — ouvert ou fermé ? | Décision Dom |
---
## 3. SÉCURITÉ — Authentification + accès
| # | Item | Statut attendu | Check |
|---|---|---|---|
| 3.1 | Dashboard Basic Auth | `401 sans creds` | `curl http://192.168.x.x:5001/api/system/status` → 401 |
| 3.2 | VWB Basic Auth | `401 LAN, 200 loopback` | Vérifié ✅ (commit cf81ce4c7) |
| 3.3 | Streaming Bearer Auth | `401 sans token` | `curl http://127.0.0.1:5005/api/v1/...` → 401 |
| 3.4 | SSH clé uniquement | Pas de password login | `grep PasswordAuthentication /etc/ssh/sshd_config` → no |
| 3.5 | Firewall persistant reboot | Ports fermés après reboot | ✅ **VALIDÉ reboot 20/06** — ports sensibles filtrés, services ouverts OK |
| 3.6 | RPA_SIGNING_KEY défini | FAISS metadata valide | ⚠️ **À FIXER** — HMAC mismatch, Option A en attente |
---
## 4. VM WINDOWS — Autostart + stabilité
| # | Item | Statut attendu | Check |
|---|---|---|---|
| 4.1 | VM boot auto au reboot DGX | Service systemd user `aivanov` | ✅ **VALIDÉ reboot 20/06**`win11-arm-lea.service` auto-démarre, linger=yes |
| 4.2 | VM accessible VNC | Tunnel SSH `localhost:5902` | Vérifié ✅ |
| 4.3 | VM ne pas libvirt en parallèle | Pas de conflit disk.qcow2 owner | ⚠️ **À DOCUMENTER** — ne pas lancer libvirt VM |
| 4.4 | disk.qcow2 owner = aivanov | Pas libvirt-qemu | `ls -la disk.qcow2` → aivanov:aivanov |
| 4.5 | swtpm lancé par script | Pas manuel | Script standalone gère swtpm ✅ |
| 4.6 | Léa config.txt pointe DGX | Pas cloud URL | `cat config.txt` → DGX IP |
---
## 5. DONNÉES — Persistence + integrity
| # | Item | Risque | Statut attendu | Check |
|---|---|---|---|---|
| 5.1 | workflows.db | 24 workflows live | `curl -u lea:<pw> http://127.0.0.1:5001/api/workflows | jq '.total'` → 24 |
| 5.2 | FAISS index | 13666 vectors | `curl ... /api/knowledge-base/stats | jq '.vectors_indexed'` → 13666 |
| 5.3 | FAISS metadata HMAC | Test endpoint 200 | ⚠️ **À FIXER** — Option A (resigner) |
| 5.4 | Sessions training | Non trackées git → safe au reset | `ls data/training/sessions/` |
| 5.5 | Git aligné | HEAD = dernier commit P0 | `git log -1` → cf81ce4c7 |
| 5.6 | workflows.db préservé au git reset | Backup avant reset | ⚠️ **Procédure à respecter** |
---
## 6. STABILITÉ — Test reboot (✅ exécuté en réel le 2026-06-20)
| # | Item | Check | Résultat | Verdict |
|---|---|---|---|---|
| 6.1 | Reboot DGX | Coupure secteur 02:07 | 9 services reviennent | ✅ PASS |
| 6.2 | VM Windows auto-start | `win11-arm-lea.service` | VM auto-démarre | ✅ PASS |
| 6.3 | Firewall persisté | Ports après reboot | Sensibles filtrés, services ouverts | ✅ PASS |
| 6.4 | Dashboard systemd | Après reboot | System service actif, user fallback masked | ✅ PASS |
| 6.5 | Worker healthy | Après reboot | PID 2267 actif, last_cycle continu | ✅ PASS |
| 6.6 | **IP DHCP dérive** | `.45``.46` | IP statique `.45` appliquée (Dom) | ⚠️ **G1 — IP statique obligatoire clinique** |
| 6.7 | **OVMF corruption VM** | Coupure brutale | OVMF corrompu, récupération manuelle (Codex) | ⚠️ **G2 — auto-réparation OVMF à implémenter** |
| 6.8 | **Léa guest reconnecte** | config.txt | CONFIGURE_ME, pas DGX | ⚠️ **G4 — config.txt à renseigner** |
---
## 7. PRÉ-REQUIS DSI (envoyés à Nicolas PORQUET)
| # | Item | Statut | Check |
|---|---|---|---|
| 7.1 | Proxy HTTPS | À installer clinique | Architecture validée |
| 7.2 | Docker | À installer | — |
| 7.3 | VLAN isolation | À configurer | — |
| 7.4 | SSH clé uniquement | ✅ Configuré DGX | `PasswordAuthentication no` |
| 7.5 | 100% on-premise | ✅ Aucune cloud call | Vérifier config Léa |
| 7.6 | Pas de secrets exposés | ✅ .env.local permissions | `ls -la .env.local` → 600 |
---
## ⚠️ ITEMS À FIXER AVANT CLINIQUE
1. **Dashboard fallback user** → ✅ **FIXÉ 20/06** (mask persistant, system service actif)
2. **Auto-start VM** → ✅ **VALIDÉ 20/06** (reboot réel prouvé)
3. **FAISS Option A** → ✅ **FIXÉ 19/06** (metadata resigné, 13666 vectors, test success=true)
4. **Git DGX aligné** : DGX sur ec1fb81, cible cf81ce4c7 → aligner avec backup workflows.db
5. **Test reboot** → ✅ **exécuté en réel 20/06** (5 PASS, 3 gaps identifiés)
6. **G1 Dérive IP DHCP** : IP statique labo `.45` OK ; clinique = Ethernet `.178` obligatoire
7. **G2 Auto-réparation OVMF** : snapshot sain au boot + restauration auto si TianoCode loop → **À IMPLÉMENTER**
8. **G4 Léa reprise auto** : config.txt persistant DGX + token + auto-login → **À RENSEIGNER**
---
## Commandes smoke rapide (à lancer sur DGX)
```bash
# Services
systemctl list-units --type=service | grep rpa
# Health endpoints
curl -s http://127.0.0.1:5002/health
curl -s http://127.0.0.1:5005/health
curl -s -u lea:v_zhmqOpGYcR-t7xJFKZyW-LjpvBuOOKss0ZleyH4jQ http://127.0.0.1:5001/api/system/status | jq '{workflows_count,status}'
curl -s -H "Authorization: Bearer o3_LHqV_7_Gc6OVPHndhsBbvG6HJ5PCgl8yIBhGUIz8" http://127.0.0.1:5005/api/v1/traces/stream/processing/status | jq '{status,processing_ready}'
# Firewall LAN
nmap 192.168.1.45 -p 5900,5902,3389,22220,8000,11434
# VM
virsh -c qemu:///system list # doit être VIDE (standalone, pas libvirt)
ps aux | grep qemu-system-aarch64 | grep win11
# Git
cd ~/ai/rpa_vision_v3 && git log -1 --oneline
```

View File

@@ -0,0 +1,87 @@
# Décisions produit en attente — à trancher à tête reposée
- `Date`: 2026-06-23
- `Auteur`: Claude (questions) / Dom (réponses)
- `Usage`: Dom remplit la ligne **Réponse Dom** quand il a la tête au calme. Chaque décision débloque un ou plusieurs chantiers du plan `PLAN_ACTION_SUITE_2026-06-23.md`. Segmentation par **fonctionnalité (F#)**.
> Contexte : H3 (cœur produit) traité **en premier**, décisions d'abord puis exécution séquencée (validé Dom 23/06). On ne touche pas l'archi rejeu pendant la livraison clinique du jour.
---
## ✅ Déjà tranché (23/06)
| Réf | Décision | Réponse Dom |
|---|---|---|
| **PROC-1** | « H3 en premier » = trancher les décisions produit d'abord, exécution séquencée ensuite | ✅ OK |
| **F6-1** | Niveau de mutualisation du fonds appris | ✅ **Cross-clinique ET intra-clinique** → la fédération anonymisée (cross) **est dans le périmètre**, + lever le silo `machine_id` entre postes d'une même clinique |
| **F11-1** | Accès distant | ✅ **Multi-VPN par site** (WireGuard=nôtre, Stormshield=clinique, autres à venir) ; SSH cert + RDP = transport commun |
| **F9-1** | Source de vérité workflows | ✅ **DB SQLAlchemy = vérité, JSON = échange** ; métrique produit = rejouables validés ; migration JSON→DB séquencée post-clinique (23/06) |
| **F1-1** | Critère de fusion workflows | ✅ **Signature de trajectoire** (hash de la séquence d'actions/écrans) → débloque U-A create-or-update (23/06) |
| **F2-1 / F14-1** | Principe rejeu intelligent | ✅ **Oui, prérequis** — le rejeu consulte le fonds appris (TargetMemory + FAISS anchors), pas de coords figées ; cohérent F6 cross-clinique (23/06) |
---
## ⏳ À trancher
> ✅ **Tranchés 23/06** (voir §Déjà tranché) : Q-F9-1 (DB), Q-F1-1 (signature trajectoire), Q-F2-1 + Q-F14-1 (rejeu intelligent = prérequis). **Restent** : Q-F2-2 (point d'entrée Léa — Claude trace en read-only), et les items **parkés** : Q-F8-* (natif/sécu mis de côté momentanément), Q-F11-2 (VPN).
### F2 — Rejeu intelligent (le plus urgent)
**Q-F2-1 — Principe « rejeu intelligent ».** Valides-tu que le rejeu **doit consulter le fonds appris** (TargetMemoryStore + FAISS anchors) au lieu de rejouer des coordonnées figées ? *(Vérif runtime 23/06 : aujourd'hui le rejeu est « brut », il ne consulte rien.)* C'est la décision qui change l'archi du replay direct (chantiers R2/R3).
> ⚠️ **Quasi-entraînée par ta décision F6 = cross-clinique** : un pack fédéré anonymisé n'a ni coords ni templates → la re-résolution visuelle (anchors/FAISS) devient le seul moyen de rejouer ailleurs. Voir Q-F14-1.
> **Réponse Dom :** _____
**Q-F2-2 — Provider Léa au runtime.** Quel modèle/route sert la **résolution** pendant le rejeu ? (impacte l'import auto R1 + la cascade de résolution). Options connues : Qwen3-VL-4B/vLLM (grounder prod), gemma4 (cerveau/lecteur), autre.
> **Tracé Claude 23/06** (read-only) : point d'entrée runtime = **agent_chat (5004)** → `SemanticMatcher.find_workflow()` (`agent_chat/app.py:906`) sur `/api/chat`. Sélection sur **fichiers JSON** (`data/workflows/`, `data/training/workflows/`, `…/live_sessions/workflows/`), **pas la DB** → ⚠️ **gap avec Q-F9-1 (DB=vérité)** : la sélection runtime devra migrer sur la DB. **Pas de filtre machine_id** à la sélection. `api_stream` (5005) = présent mais pas le chemin chat actif. Modèle de résolution = Qwen3-VL-4B grounder + gemma4 (bench 13/06).
### F1 / F9 — Apprentissage & workflows
**Q-F1-1 — Critère de fusion des workflows** *(tu as dit « on verra à tête reposée »)*. Quand deux sessions apprises sont-elles « le même » workflow (→ create-or-update plutôt que doublon) ? Par signature d'écran de départ ? nom ? séquence d'actions ? application cible ?
> **Réponse Dom :** _____
**Q-F9-1 — Source de vérité workflows + métrique produit.****Ma reco ci-dessous (§Reco F9-1)**, à valider ou amender.
> **Réponse Dom :** _____
### F14 — Unification Léa ↔ VWB (anchors)
**Q-F14-1 — Re-exécutabilité des packs fédérés (décision induite).** Un pack cross-clinique anonymisé n'emporte **ni coordonnées ni templates** (PII) → re-résolution **visuelle** obligatoire au rejeu (anchors/FAISS). ⇒ acte-t-on que **le « rejeu intelligent » (Q-F2-1) devient un prérequis** (pas une option) dès lors que F6 = cross-clinique ?
> **Réponse Dom :** _____
*(Le sous-chantier U-B « propagation des anchors aux substeps compound + ré-import » ne demande aucune décision produit — c'est un fix prêt, à exécuter post-stabilisation sous GO. Idem U-D « asymétrie grounding UI-DETR-1 vs cascade » = à trancher plus tard, sujet ouvert post-démo.)*
### F8 — Exécution native agentique (zéro-shot)
*Constat 23/06 : briques présentes et wirées (boucle ORA `/execute/instruction`, planner NL→plan gemma4), mais mode « free » peu mûr, **sans sandbox ni validation humaine**, exécution directe sur l'host.*
**Q-F8-1 — Périmètre du mode natif.** Desktop/navigateur **généraliste** (« ouvre YouTube ») ou **borné aux apps métier** (Easily…) ? (impacte le risque et le choix moteur)
> **Réponse Dom :** _____
**Q-F8-2 — Surface d'exécution / sandbox (sécurité).** Acte-t-on que le mode free ne tourne **QUE dans un sandbox** (VM/Xvfb + kill-switch + pause humaine), **jamais l'host** — prérequis avant tout élargissement ? (= la décision CUA P1)
> **Réponse Dom :** _____
**Q-F8-3 — Moteur agentique.** Garder ORA + gemma4/Qwen3-VL (100 % local) ou évaluer un agent computer-use dédié (UI-TARS mode agent, Qwen3-VL agent…) ? → un mini-état-de-l'art web peut éclairer (les modèles évoluent vite).
> **Réponse Dom :** _____
**Q-F8-4 — Niveau d'autonomie.** Validation humaine step-by-step au début (Copilot) puis desserrage progressif (cohérent F7 Shadow→Copilot→Autonomous + safety) ?
> **Réponse Dom :** _____
### F11 — Accès distant multi-VPN
**Q-F11-2 — Abstraction « fiche site → accès ».** Quels **types de site/VPN** anticiper au-delà de Stormshield + WireGuard ? Faut-il coder dès maintenant une abstraction générique « fiche site → méthode d'accès », ou gérer les 2 cas connus d'abord et généraliser plus tard ?
> **Réponse Dom :** _____
---
## 💡 Reco Claude — Q-F9-1 (source de vérité workflows)
**Recommandation : la DB (SQLAlchemy/SQLite `workflows.db`) = source de vérité unique ; le JSON = format d'échange/export uniquement (packs fédération, portabilité, import VWB), PAS un 2ᵉ magasin.**
**Pourquoi la DB :**
- Les workflows sont **relationnels** (workflow ↔ steps ↔ substeps ↔ anchors ↔ session/machine) : intégrité référentielle, requêtes, versionnement.
- Le **create-or-update à la fusion** (Q-F1-1) = un upsert atomique trivial en SQL, pénible et risqué sur des fichiers JSON.
- **Concurrence** : 5 Léa qui apprennent/s'enrôlent en parallèle (test de charge) → JSON + chemins relatifs au cwd = races + fragilité du symlink (DETTE-015 déjà constatée).
- **F6 fédération** a justement besoin de DB-comme-vérité + JSON-comme-échange : un `LearningPack` = sérialisation d'une requête DB → pack JSON ; l'import = désérialisation → DB. JSON-comme-vérité **entre en conflit** avec ça.
**Nuance de timing :** la **migration** JSON→DB est un refactor persistant → **séquencée post-clinique** (après stabilisation + merge), comme le prévoit déjà `PLAN_MIGRATION_WORKFLOWS_STORE`. D'ici là le symlink reste, dette connue, **on n'y touche pas pendant la livraison**.
**Métrique produit (24/79/37) :** la seule défendable face à un client/DSI = **les workflows rejouables validés** (≈ les **24** VWB aujourd'hui, ce que Léa exécute vraiment de bout en bout). Les **79** (catalogue agent-chat) et **37** (KB/FAISS) = compteurs **internes**, à ne pas exposer comme « N workflows ». Une fois la DB source de vérité : métrique = `count(workflows WHERE statut = rejouable_validé)`.

View File

@@ -0,0 +1,44 @@
# Design — Anonymisation par tokens typés (= apprentissage des variables)
- `Date`: 2026-06-28
- `Auteur`: Claude (idée Dom)
- `Statut`: design actif
- `Origine`: PII patient en clair confirmée en production clinique (titres de fenêtre + **contenu médical des `text_input`**). Idée Dom : remplacer la PII par des **tokens typés** plutôt que flouter.
## Principe directeur (Dom 28/06)
**Léa apprend l'INTERFACE, pas la DONNÉE.** Ce qui compte pour l'apprentissage, c'est **où sont les champs, leur type, l'enchaînement****pas leur contenu**. Après apprentissage, Léa devra :
- **saisir des données** dans les bons champs (contenu fourni au runtime par un agent / une extraction),
- **lire les écrans pour extraire des données** que l'« agent » traitera.
→ Le contenu capturé (texte saisi, valeurs OCR, nom patient dans un titre) est une **variable**, pas une constante à mémoriser. On le remplace par un **token typé** : on **anonymise** ET on **apprend la carte des variables** d'un seul geste. Flouter détruit l'info ; tokeniser la préserve **structurellement** sans la valeur.
Conforme au principe d'anonymisation déjà acté (`feedback_anonymisation_stricte` : remplacer uniquement les identités, ne jamais réécrire le clinique) — ici on va plus loin : **le contenu d'un champ saisi n'est même pas nécessaire**, on garde le champ, pas la valeur.
## Modèle d'anonymisation par type de capture
| Capture | Ce qu'on garde (interface) | Ce qu'on retire (donnée) | Comment |
|---|---|---|---|
| **`text_input`** (texte tapé) | le **fait** qu'un champ texte a été saisi (+ éventuellement nb de caractères) | **tout le contenu** (diagnostics, notes médicales = données de santé) | **`[SAISIE]`** (option **b**, décision Dom). Pas de NER nécessaire — on ne stocke simplement pas le contenu. **Résout la fuite la plus grave.** |
| **`active_window_title`** | l'**app/écran** (« GXD5 Pacs », « Expert Santé », « Firefox ») = contexte d'apprentissage | l'**identité patient** (nom, IPP, âge) | tokens typés `[NOM_1]`/`[IPP_1]`/`[AGE_1]` via **couche 1 (regex+structurel)** + **couche 2 (NER)** pour les noms libres |
| **OCR / lecture d'écran** | les **zones/champs** d'où extraire | les **valeurs** extraites (PII) | token typé de la zone (« extraire un `[NOM]` ici »), valeur réinjectée au runtime, non persistée |
## Architecture (2 couches + intégration)
- **Couche 1 — regex + structurel (FAIT, `agent_v0/server_v1/pii_sanitizer.py`)** : tokens typés cohérents, **sans modèle**, déployable partout. Capte IPP/NIR/TEL/EMAIL/AGE + noms format clinique (`NOM (NAISSANCE) Prénom`, `[Nom Prénom]` PACS) + blacklist logiciels. Couvre 5/7 patients du jour, **tous les IPP**. 5 tests verts.
- **Couche 2 — NER CamemBERT-bio (à vendorer)** : moteur `CamembertNerManager` du projet `~/ai/anonymisation` (ONNX **CPU**, ~9-12 ms/titre, labels typés PER/IPP/AGE/DATE/HOPITAL…). Pour les **noms libres** que la couche 1 rate (« Prénom NOM — Firefox »). Modèle **421 Mo → côté DGX** (postes cliniques trop légers — contrainte Dom). Lazy, optionnel.
- **Intégration** : une fonction `sanitize_event(event, mapping)` au **point de persistance serveur** : `text_input``[SAISIE]` ; titres → `anonymize_text` (couche 1 + 2) ; cohérence par **mapping de session** (même entité → même token).
## Placement & déploiement
- **Côté serveur (DGX)** : les events y remontent déjà ; le client reste léger. (Cible privacy-by-design = supprimer le contenu **au plus près du poste** avant stream — évolution, quand le client pourra être modifié.)
- **Déploiement GATED** : ne pas redémarrer le serveur DGX pendant des sessions live.
- **Ne casse pas l'apprentissage** : `workflow_trajectory_signature` tokenise déjà la PII pour le discriminant ; les tokens typés **renforcent** la carte des variables.
## Décisions Dom
-**Option (b)** pour `text_input` : placeholder `[SAISIE]`, on ne garde pas le contenu (28/06).
-**Donnée déjà capturée** (9 patients, 6 IPP, contenu médical) : assainir a posteriori vs **purger** — avec Amina (reco Claude : purger les sessions du jour une fois le fix en place).
## Connexe
- **Config-remontée** des specs postes (CPU/RAM/GPU/OS) pour cibler + fournir des prérequis (`screen_metadata` en remonte déjà une partie).
- Réutilise : `~/ai/anonymisation` (`camembert_ner_manager.py`, gazetteers INSEE, blacklists, regex, PLACEHOLDERS).

View File

@@ -0,0 +1,193 @@
# DESIGN — MAJ silencieuse du client Léa + déploiement CANARY (DETTE-022 v2)
Date : 2026-07-01
Branche : `feat/push-log-dgx`
Statut : **premier draft fonctionnel — GATED OFF partout, aucun swap réel, revue supervisée Dom requise avant toute activation**
> ⚠️ RIEN N'A ÉTÉ DÉPLOYÉ. Aucun SSH poste, aucune action fleet. Ce document +
> le code de la branche sont un livrable de conception/implémentation pour revue.
---
## 1. Problème
Pousser des correctifs au client Léa sur ~19 postes cliniques live (Wallerstein)
**sans** patch manuel DSI et **sans** déranger les TIM en plein travail. Contrainte
absolue : une MAJ ratée peut **briquer toute la flotte**. Le mécanisme doit donc
être **conservateur** : canary lent + rollback béton plutôt que rapide et risqué.
## 2. État de départ (stub commit `813b33b47`) — ce qui existait déjà
Le noyau était plus avancé qu'un simple squelette. Déjà présent et **testé (vert)** :
| Brique | Fichier | Rôle |
|---|---|---|
| Décision serveur PURE | `agent_v0/server_v1/update_check.py` | `parse_version`/`is_newer` (semver correct : `1.0.2 < 1.0.10`), `decide_update()`, `build_download_url()` |
| Endpoint serveur gated | `agent_v0/server_v1/api_stream.py:7843+` | `GET /api/v1/agents/update/check`**503 si `RPA_AUTO_UPDATE_SERVER_ENABLED` OFF**, Bearer requis |
| Noyau client PUR | `agent_v0/agent_v1/network/updater.py` | `auto_update_enabled()` (flag `RPA_AUTO_UPDATE_ENABLED`, défaut OFF), `should_update()` (double garde anti-downgrade), `download_update()` (staging + SHA256, ne touche jamais les fichiers vivants) |
| **Stubs dangereux (no-op)** | `updater.py:246+` | `apply_update()` / `write_boot_ok_marker()`**réservés révision humaine** (swap fichiers, édition `Lea.bat`, restart) |
| Version agent | `agent_v0/agent_v1/config.py:30` | `AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1")` (amorcé `105ade959`) |
| Tests | `tests/unit/test_update_check_server.py`, `tests/unit/test_agent_v1_updater.py`, `tests/integration/test_update_check_endpoint.py` | R2/R3 verts |
### Ce qui MANQUAIT (comblé par ce draft)
1. **Aucune logique canary** : `decide_update` recevait `machine_id` mais l'ignorait pour choisir la version. La version cible était une seule var globale `RPA_AGENT_LATEST_VERSION` → une MAJ partait sur **toute** la flotte d'un coup. **C'est le trou de sécurité n°1.**
2. **Le noyau client n'était pas wiré** : `updater.py` n'était appelé nulle part. `main.py` ne l'importait pas. Aucun caller HTTP de `/agents/update/check`.
3. **Pas d'orchestrateur** reliant check → décide → download (staging) côté client.
## 3. Fleet / versioning existant (réutilisé, pas réinventé)
- Registre SQLite `enrolled_agents` (`agent_v0/server_v1/agent_registry.py:105`) : colonne `version` + `last_seen_at` par `machine_id`. Le dashboard Fleet (`web_dashboard/templates/index.html:2247`) affiche déjà la version par poste.
- **Limite connue** : `version` n'est écrite qu'à l'`enroll` (installateur), pas rafraîchie par le heartbeat runtime. Le serveur connaît donc la version *installée*, pas forcément la *version vive*. → **inventaire de version = amélioration future** (voir §8), non bloquante pour le canary (le canary est piloté par une allow-list de `machine_id`, pas par l'inventaire).
## 4. Design retenu (et pourquoi)
Aligné sur l'état de l'art self-update desktop 2025 (canary / blue-green / A-B swap + watchdog rollback + intégrité + version) — sources en fin de doc.
### 4.1 CANARY côté serveur — la keystone de sécurité (IMPLÉMENTÉ)
Nouveau module PUR `agent_v0/server_v1/update_policy.py`. Il résout la version cible
**PAR MACHINE** :
- poste dans l'allow-list canary → `canary_version` (la nouvelle) ;
- tous les autres postes → `stable_version` (le floor, inchangé).
Piloté 100 % par **variables d'environnement serveur** (aucun rebuild, aucune
DSI) :
```
RPA_AGENT_STABLE_VERSION # version servie à TOUTE la flotte (défaut 1.0.1)
RPA_AGENT_CANARY_VERSION # version servie AUX SEULS postes canary (optionnel)
RPA_AGENT_CANARY_MACHINES # allow-list CSV des machine_id canary
```
Garde-fous du résolveur (tous prudents par défaut) :
- machine_id absent / liste vide / pas de `canary_version`**stable** ;
- `canary_version` doit être **strictement plus récente** que `stable` (sinon on sert stable — jamais de recul) ;
- ne lève jamais ; version illisible → retombe sur stable via le comparateur semver tolérant.
Wiring : `_latest_agent_version(machine_id)` dans `api_stream.py` appelle
`resolve_target_version_from_env(machine_id)`. **Rétrocompat** : si l'ancienne
`RPA_AGENT_LATEST_VERSION` est positionnée, elle prime (pas de régression d'un
déploiement existant).
**Effet** : la 1.0.2 ne peut PAS fuiter hors de la liste canary. Blast radius =
la liste. On démarre la liste = `lea-4zbgwxty` (Émilie) seul.
**Promotion** = quand le canary est validé : `RPA_AGENT_STABLE_VERSION=<canary>`
+ vider `RPA_AGENT_CANARY_MACHINES` → toute la flotte suit.
**Rollback canary** = vider `RPA_AGENT_CANARY_MACHINES` / remettre l'ancienne
`RPA_AGENT_CANARY_VERSION` → le prochain check ne propose plus rien.
### 4.2 Orchestrateur client (IMPLÉMENTÉ, GATED, sans swap)
`updater.run_update_cycle(local_version, machine_id, staging_dir, checker?, downloader?)` :
1. **GATE** `auto_update_enabled()` (`RPA_AUTO_UPDATE_ENABLED`, défaut OFF) — si OFF, ne fait **strictement rien**, aucun appel réseau ;
2. `checker(...)` → réponse serveur (défaut = `_default_update_checker` : GET vers l'endpoint gated, Bearer, 503→None, jamais d'exception) ;
3. `should_update(...)` → plan (double garde semver anti-downgrade) ;
4. `download_update(...)` → ZIP en **staging** + vérif **SHA256** (fichiers vivants jamais touchés) ;
5. `apply_update(staged)` = **stub no-op** → résultat `applied: False`. **Le swap réel n'est PAS fait par du code d'agent.**
Statuts retournés (diagnostic/log) : `disabled | check_failed | up_to_date | download_failed | staged`. Best-effort total : aucune exception ne remonte (ne casse jamais Léa).
### 4.3 Wiring runtime (IMPLÉMENTÉ, GATED)
`main.py` : thread daemon `_auto_update_loop`, démarré **uniquement si**
`AUTO_UPDATE_ENABLED`, à côté des boucles permanentes existantes (même pattern
que le log shipper). Sécurité « **au bon moment** » : on ne stage PAS pendant un
enregistrement (`self.session_id`) ou un replay actif (`self._replay_active`) —
pas de perturbation du travail TIM. Intervalle `RPA_AUTO_UPDATE_INTERVAL_S`
(défaut **3600 s / 1 h** : une MAJ n'est jamais urgente).
### 4.4 Intégrité + version
- **Intégrité** : SHA256 vérifié dans `download_update` (déjà présent) ; mismatch → rejet + staging propre.
- **Version** : `AGENT_VERSION` envoyée à chaque check (`current_version`) ; le serveur choisit la cible par machine.
- **Signature (à ajouter, §8)** : SHA256 seul protège de la corruption, pas de l'usurpation. Recommandation : signer le manifeste (le SHA256 vient d'un canal authentifié — l'endpoint Bearer — donc chaîne acceptable pour le POC ; signature détachée = durcissement futur).
### 4.5 Swap atomique + rollback (SPEC — réservé révision humaine, PAS codé par agent)
Le swap réel reste dans les stubs `apply_update` / `write_boot_ok_marker` et
dans `Lea.bat`. **Un agent ne doit pas écrire de code qui écrase des binaires
vivants ni relance un process.** Spec cible pour la revue humaine :
- **A-B / staging** : le ZIP est extrait dans `Lea_next\`. Au **prochain démarrage**, `Lea.bat` (hors-process) : backup `Lea\``Lea_prev\`, swap `Lea_next\``Lea\`, lance la nouvelle version.
- **Watchdog rollback** : la nouvelle version doit écrire un marker `boot_ok_<version>` **après** ~60 s de heartbeat DGX sain + session OK. Si `Lea.bat` ne trouve pas le marker au démarrage suivant (crash au boot), il restaure `Lea_prev\` automatiquement. Cible « rollback latency » < 90 s (état de l'art).
- **Cas edge** (documenté dans les stubs) : DGX down ≠ Léa N+1 buguée — le health-check doit distinguer les deux pour éviter un faux rollback.
## 5. Fichiers touchés (cette branche)
**Ajouts**
- `agent_v0/server_v1/update_policy.py` — canary PUR (résolveur par machine + lecture env).
- `tests/unit/test_update_policy_canary.py` — TDD canary (résolveur + env).
**Modifs**
- `agent_v0/server_v1/api_stream.py``_latest_agent_version(machine_id)` canary-aware (rétrocompat legacy) + docstring endpoint.
- `agent_v0/agent_v1/network/updater.py``_default_update_checker()` + `run_update_cycle()` (orchestrateur gated, sans swap).
- `agent_v0/agent_v1/config.py``AUTO_UPDATE_INTERVAL_S`, `AUTO_UPDATE_STAGING_DIR`.
- `agent_v0/agent_v1/main.py` — thread `_auto_update_loop` gated + import config.
- `tests/unit/test_agent_v1_updater.py` — TDD `run_update_cycle` (gate off, up-to-date, staged, sha mismatch, checker raise).
- `tests/integration/test_update_check_endpoint.py` — TDD canary HTTP (poste canary vs hors-canary).
- `deploy/lea_package/config.txt` — flags client MAJ documentés (commentés, OFF).
**Intacts (réservés révision humaine)** : `updater.apply_update`, `updater.write_boot_ok_marker`, `Lea.bat`.
## 6. Matrice des flags (tout OFF par défaut)
| Flag | Côté | Défaut | Effet |
|---|---|---|---|
| `RPA_AUTO_UPDATE_SERVER_ENABLED` | serveur | OFF (503) | active l'endpoint de décision |
| `RPA_AGENT_STABLE_VERSION` | serveur | `1.0.1` | version floor de toute la flotte |
| `RPA_AGENT_CANARY_VERSION` | serveur | — | nouvelle version, postes canary seulement |
| `RPA_AGENT_CANARY_MACHINES` | serveur | — | allow-list CSV canary |
| `RPA_AGENT_LATEST_VERSION` (legacy) | serveur | — | si set, prime sur le canary (rétrocompat) |
| `RPA_AUTO_UPDATE_ENABLED` | client | OFF | active la boucle de check + staging |
| `RPA_AUTO_UPDATE_INTERVAL_S` | client | `3600` | intervalle de check |
## 7. Plan de déploiement CANARY (étapes + critères GO / ROLLBACK)
> Prérequis avant TOUTE étape : la mécanique de **swap réel** (§4.5) doit avoir
> été implémentée et revue par un humain. Tant qu'elle est en stub, ce plan ne
> fait que **stager** un ZIP (aucun poste ne change réellement de version) — ce
> qui est déjà utile pour valider la chaîne check/download/intégrité à vide.
**Étape 0 — Serveur seul (aucun poste touché)**
- Action : `RPA_AUTO_UPDATE_SERVER_ENABLED=true`, `RPA_AGENT_STABLE_VERSION=1.0.1`, PAS de canary encore.
- GO si : `GET /agents/update/check` répond 200 pour un `machine_id` quelconque avec `update_available:false`. Aucun poste n'a la MAJ activée côté client.
- ROLLBACK : repasser le flag serveur OFF.
**Étape 1 — Canary Émilie, staging seul**
- Action serveur : `RPA_AGENT_CANARY_VERSION=<nouvelle>`, `RPA_AGENT_CANARY_MACHINES=lea-4zbgwxty`.
- Action poste Émilie (config.txt) : `RPA_AUTO_UPDATE_ENABLED=true`.
- GO si : dans les logs d'Émilie (remontés par le push-log DGX), `[UPDATE] MAJ <v> téléchargée en staging (SHA256=True)`, ZIP présent dans le staging, `applied:False`, Léa continue de tourner normalement (session/replay non perturbés). Vérifier qu'AUCUN autre poste ne reçoit `update_available:true`.
- ROLLBACK : vider `RPA_AGENT_CANARY_MACHINES` (le check ne propose plus rien). Aucun impact : rien n'avait été appliqué.
**Étape 2 — Canary Émilie, swap réel (après implémentation humaine du §4.5)**
- GO si : après redémarrage, Émilie tourne la nouvelle version (`AGENT_VERSION` remontée), marker `boot_ok` écrit, heartbeat DGX sain > 24 h, zéro régression fonctionnelle (enregistrement + replay OK).
- ROLLBACK : automatique par watchdog `Lea.bat` si pas de `boot_ok` au boot ; manuel = restaurer `Lea_prev\` + vider la liste canary.
**Étape 3 — Élargissement progressif (rings)**
- Ajouter 2-3 postes à `RPA_AGENT_CANARY_MACHINES`, attendre 48 h par palier.
- GO/ROLLBACK : mêmes critères qu'étape 2, par palier.
**Étape 4 — Promotion générale**
- `RPA_AGENT_STABLE_VERSION=<nouvelle>` + vider `RPA_AGENT_CANARY_MACHINES`.
- Toute la flotte converge au rythme de son intervalle de check.
- ROLLBACK flotte : remettre `RPA_AGENT_STABLE_VERSION` à l'ancienne (les postes ne redescendent pas seuls — le swap-down reste une opération supervisée ; les nouveaux checks ne proposeront plus la MAJ).
## 8. Améliorations futures (hors périmètre de ce draft)
1. **Swap réel + watchdog rollback** (§4.5) — la brique manquante n°1, révision humaine.
2. **Inventaire de version vive** : rafraîchir `enrolled_agents.version` au heartbeat (le serveur saurait exactement quelle version tourne où — utile pour piloter le canary depuis le dashboard).
3. **Signature détachée** du manifeste (durcissement au-delà du SHA256 sur canal Bearer).
4. **Endpoint de download versionné** : aujourd'hui `/api/fleet/download/<machine_id>` (dashboard) sert l'installateur complet et **ignore `?type=&version=`** ; il faudra qu'il serve le vrai payload `code-only` incrémental attendu par le contrat d'URL.
5. **Auto-report du résultat de swap** (succès/rollback) au serveur pour un tableau de bord canary.
## 9. Sources (état de l'art self-update desktop / canary 2025)
- [Rollback Strategies for Enterprise: 2025 Best Practices — sparkco.ai](https://sparkco.ai/blog/rollback-strategies-for-enterprise-2025-best-practices)
- [Canary Deployment with Auto-Rollback for AI Agents — antigravitylab.net](https://antigravitylab.net/en/articles/agents/antigravity-ai-agent-canary-deployment-burn-rate-slo)
- [awesome-agentic-patterns — canary rollout & automatic rollback](https://github.com/nibzard/awesome-agentic-patterns/blob/main/patterns/canary-rollout-and-automatic-rollback-for-agent-policy-changes.md)
- [What is Canary Testing — aqua-cloud.io](https://aqua-cloud.io/canary-testing/)
- [Rollback Automation Best Practices for CI/CD — hokstadconsulting.com](https://hokstadconsulting.com/blog/rollback-automation-best-practices-for-ci-cd)

View File

@@ -0,0 +1,218 @@
# Design Note — NavigateCoords Consumption Gap (Write-Only)
**Auteur**: Qwen
**Date**: 2026-07-02
**Statut**: DESIGN NOTE — pas de câblage sans GO Dom
**Référence**: `tests/unit/test_coords_consumption_gap.py` (10 tests PASSING documenting the gap)
---
## Executive Summary
Le module navigation (`core/navigation`) produit des coords normalisés (`NavigateCoords`) via OCR/VLM, les stocke dans `replay_state["variables"]`, mais **aucun consommateur** dans le runtime n'utilise ces coords. Le résultat est un pattern **write-only** : coords générés mais jamais consommés par les actions suivantes (click/type).
Trois gaps structurels confirmés par code lecture :
---
## Gap A — Compiler Produces Literals, Not Templates
**Localisation**: `replay_engine.py:1832-1846` (`_edge_to_normalized_actions`)
**Problème**: Pour `mouse_click`, le compiler bake `x_pct` et `y_pct` comme **floats littéraux** depuis `by_position` :
```python
# replay_engine.py:1843-1846
normalized["type"] = "click"
normalized["x_pct"] = x_pct # float littéral (ex: 0.15)
normalized["y_pct"] = y_pct # float littéral (ex: 0.07)
```
Ces floats sont **hardcodés** dans le step definition. Il n'existe pas de mécanisme pour référencer les coords navigate via templates comme `{{navigate_login_coords.x_pct}}`.
**La substitution existante ne couvre pas ce cas** :
- `_substitute_variables()``${var}` → appliqué uniquement à `text_input.text`
- `_RUNTIME_VAR_PATTERN``{{var.field}}` → compilé regex, **jamais appliqué à `x_pct/y_pct`**
**Conséquence**: Un navigate step qui résolve coords login à (0.15, 0.07) ne peut PAS injecter ces coords dans un click step suivant, car le click step a ses propres `x_pct/y_pct` hardcodés.
---
## Gap B — Zero Consumers in Runtime
**Localisation**: `core/navigation/__init__.py:43-113` (`_handle_navigate_action`)
**Problème**: `_handle_navigate_action` stocke coords dans `replay_state["variables"]` :
```python
# core/navigation/__init__.py:100-105
if result.login_coords:
variables[login_var] = result.login_coords.to_dict()
# → {"x_pct": 0.15, "y_pct": 0.07, "method": "ocr_anchor"}
```
**Zéro consommateur** : aucun action handler (click, type, double_click, right_click) lit `variables["navigate_login_coords"]` pour résoudre ses propres coords. Chaque action utilise exclusivement `by_position` depuis son edge definition.
**Preuve par grep** : `navigate_login_coords|navigate_password_coords|navigate_submit_coords` apparaît uniquement dans :
- `core/navigation/__init__.py` (write)
- `tests/unit/test_*.py` (test verification)
- **0 occurrences** dans `replay_engine.py` action dispatch ou `api_stream.py` action handlers
---
## Gap C — Navigate Edge → Empty Actions List
**Localisation**: `replay_engine.py:1806-1955` (`_edge_to_normalized_actions`)
**Problème**: Le type `navigate` est dans `_ALLOWED_ACTION_TYPES` (ligne 44) et possède un handler câblé dans `api_stream.py` (ligne 4459-4463 via `_handle_navigate_action`). Mais `_edge_to_normalized_actions` **n'a pas de branche** pour `navigate` :
```python
# replay_engine.py:1954-1955 (else branch)
else:
logger.warning(f"Type d'action inconnu : {action_type}")
return []
```
**Conséquence** : Quand le BFS traverse un edge navigate, `_edge_to_normalized_actions(edge, params)` retourne `[]`. L'action navigate est **skippée** dans le path. Le handler existe dans `api_stream.py` mais est **inaccessible** car le normalized action dict n'est jamais produit.
**Paradoxe** : Le navigate handler est câblé et fonctionnel, mais le pipeline edge→action le bloque à l'entrée.
---
## Options de Résolution
### Option 1 — Compiler Injection (modifier `_edge_to_normalized_actions`)
**Approche**: Ajouter une branche `navigate` dans `_edge_to_normalized_actions` qui produit un normalized action dict. Modifier les actions click/type pour permettre des template refs `{{navigate_login_coords.x_pct}}` dans `x_pct/y_pct`, avec résolution runtime.
```python
# Option 1 — Branch navigate dans _edge_to_normalized_actions
elif action_type == "navigate":
normalized["type"] = "navigate"
normalized["parameters"] = {
"action": action_params.get("action", "login"),
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
}
return [normalized]
```
**+ Avantages** :
- Minimal change — 1 branche ajoutée + template resolution dans click/type
- Compatible avec handler existant (`_handle_navigate_action`)
- BFS path inclut navigate → handler appelé → coords stockés → consommés
** Risques** :
- Template resolution dans `x_pct/y_pct` nécessite modification de click/type dispatch
- Float vs string : `{{navigate_login_coords.x_pct}}` résout en `"0.15"` (string), pas `0.15` (float) — nécessite conversion
- Ordonnancement : navigate doit s'exécuter AVANT les actions click/type qui consomment ses coords — scheduling implication
### Option 2 — Declarative YAML Templates (step definitions avec coords_template)
**Approche**: Ajouter un champ `coords_template` dans les step YAML definitions. Au runtime, le template est résolu par substitution des variables navigate.
```yaml
# Option 2 — YAML step definition avec coords_template
steps:
- action: navigate
parameters:
action: login
login_coords_var: navigate_login_coords
- action: mouse_click
coords_template: "{{navigate_login_coords}}"
# Au runtime : x_pct/y_pct résolus depuis navigate_login_coords dict
```
**+ Avantages** :
- Déclaratif — coords templates dans YAML, pas hardcoded
- Séparation compiler/runtime : compiler produit templates, runtime résout
- Extensible à autres types de coords (search, dossier)
** Risques** :
- Plus de changement : schema YAML + template resolver + compiler modifications
- Retro-compatibilité : workflows existants sans coords_template doivent continuer à fonctionner (fallback by_position)
- Validation : templates malformés → runtime errors subtiles
---
## Table Comparative
| Critère | Option 1 (Compiler Injection) | Option 2 (YAML Templates) |
|---------|-------------------------------|---------------------------|
| Changement code | Small — 1 branch + template resolve | Medium — schema + resolver + compiler |
| Retro-compat | Full — by_position fallback intact | Full — fallback by_position si pas de template |
| Ordonnancement | Navigate avant click (BFS order) | Navigate avant click (step order) |
| Extensibilité | Navigate-specific | General — coords_template applicable à tout |
| Risque runtime | Float/string conversion | Template validation errors |
| Tests impact | 1-3 nouveaux tests | 5-8 nouveaux tests (schema + resolver) |
| GO Dom needed | YES | YES |
| Timeline | ~2h implementation | ~4h implementation + schema design |
---
## Test Rouge Proposal
**Objectif**: Démontrer Gap C avec 1 test unitaire qui montre qu'un edge navigate produit une empty action list.
```python
# tests/unit/test_coords_consumption_gap.py — ajout proposé
def test_gap_c_navigate_edge_produces_empty_actions():
"""Gap C: _edge_to_normalized_actions returns [] for navigate edge.
Prove: navigate is in _ALLOWED_ACTION_TYPES but has no branch
in _edge_to_normalized_actions → falls into else → empty list.
"""
from agent_v0.server_v1.replay_engine import _edge_to_normalized_actions
# Minimal mock edge with navigate action type
edge = MockEdge(
edge_id="e1",
from_node="start",
to_node="login",
action=MockAction(
type="navigate",
target=None,
parameters={"action": "login"},
),
)
result = _edge_to_normalized_actions(edge, {})
# GAP: navigate edge produces zero actions
assert result == [], f"Expected empty list, got {result}"
# This proves the handler in api_stream.py is unreachable
```
**Note**: Ce test est un **red flag** — il doit FAIL quand le gap est résolu (navigate branch ajoutée → result ≠ []). Il sert de guardrail : si quelqu'un câble navigate sans résoudre les gaps A+B, le test rouge continue à signaler le problème.
---
## Decision Required from Dom
**⚠️ PAS DE CÂBLAGE SANS GO DOM**
Ce design note documente les gaps et propose des options. La décision appartient à Dom :
1. **Option préférée** : 1 (compiler injection) ou 2 (YAML templates) ?
2. **Timeline** : implémenter maintenant (POC phase) ou post-POC ?
3. **Scope** : navigate login only, ou general coords template system ?
4. **Test rouge** : ajouter le test gap C maintenant (documentation) ou attendre GO ?
---
## Appendix — Code References
| Fichier | Lignes | Rôle |
|---------|--------|------|
| `replay_engine.py:44` | `_ALLOWED_ACTION_TYPES` includes "navigate" | Allowlist |
| `replay_engine.py:1806-1955` | `_edge_to_normalized_actions` — no navigate branch | Gap C |
| `replay_engine.py:1843-1846` | mouse_click bakes literal x_pct/y_pct | Gap A |
| `core/navigation/__init__.py:43-113` | `_handle_navigate_action` — writes coords to variables | Gap B (write) |
| `core/navigation/action_resolver.py:47-62` | `NavigateCoords` dataclass definition | Data model |
| `api_stream.py:4459-4463` | navigate handler dispatch | Wired but unreachable |
| `tests/unit/test_coords_consumption_gap.py` | 10 tests documenting write-only gap | Evidence |
---
*Qwen — design note, pas wiring. GO Dom required.*

View File

@@ -0,0 +1,59 @@
# Design — Auto-réparation OVMF de la VM Léa (gap G2, reprise non-assistée)
- `Auteur`: Claude (infra)
- `Date`: 2026-06-20 ~03:15 CEST
- `Statut`: **PROPOSITION / design read-only** — à appliquer après revue Dom (garde-fou : aucun changement service prod sans Dom).
- `Référence`: post-mortem `docs/POSTMORTEM_PANNE_SECTEUR_DGX_2026-06-20.md` (gap G2).
## 1. Problème
Une coupure brutale corrompt `OVMF_VARS.fd` (NVRAM UEFI) → la VM boucle dans TianoCore/Windows Boot Manager. Le 2026-06-20, blocage 02:07→02:18 jusqu'à intervention manuelle de Codex (restore OVMF connu-bon + TPM frais). En clinique sans technicien, ce blocage serait **permanent**.
## 2. Pourquoi le service ne s'auto-répare pas aujourd'hui
`~/.config/systemd/user/win11-arm-lea.service` :
- `Restart=on-failure`**inopérant** : en boucle TianoCore, QEMU **ne sort pas** (process « running » à 99 % CPU). Aucun échec → aucun restart.
- `ExecStartPre` efface `tpm2-00.permall` (TPM frais à chaque boot) **mais pas `OVMF_VARS.fd`** → un OVMF corrompu **survit aux restarts** → boucle permanente.
## 3. Détecteur fiable
Le **guest agent QEMU** (`windows-11-arm-lea-agent.sock`) ne répond **que** si Windows a réellement booté. En boucle firmware, il ne répond jamais. Signature d'échec = *pas de réponse guest-agent après N min* **+** *CPU QEMU élevé*. (Plus robuste qu'une analyse framebuffer ; v1 suffisante.)
## 4. Design proposé (2 briques + garde-fous)
### Brique A — Snapshot « known-good » après boot sain
Watchdog compagnon (lancé en `ExecStartPost=... &` ou service apparié `vm-health-watchdog.service`) :
1. Fenêtre de boot (0→~6 min), poll guest-agent toutes les 30 s (`guest-ping` via socket agent).
2. **Guest-agent répond** → boot sain : copie atomique `OVMF_VARS.fd``OVMF_VARS.fd.known-good`, écrit sentinel `boot-ok`, log horodaté. C'est le point de restauration.
### Brique B — Détection boucle + restauration auto
Si à T+6 min le guest-agent **ne répond toujours pas** ET CPU QEMU > 80 % (signature boucle) :
1. Écrit sentinel `boot-failed`.
2. Archive l'OVMF suspect : `OVMF_VARS.fd``OVMF_VARS.fd.failed-<ts>` (convention déjà utilisée par Codex).
3. Restaure `OVMF_VARS.fd.known-good``OVMF_VARS.fd`.
4. Arrête le QEMU en boucle firmware. **Sûr** : aucun OS n'a booté (guest-agent jamais monté) → pas de risque de corruption Windows (≠ règle « jamais kill un Windows booté », qui ne s'applique pas ici).
5. systemd relance (`Restart=on-failure` se déclenche enfin) avec le bon OVMF.
### Garde-fous (anti-mauvais comportement)
- **Pas de known-good** (1er boot, jamais eu de boot sain) → ne PAS restaurer ; log + alerte, comportement actuel conservé.
- **Compteur d'essais** : max 2 auto-restaurations consécutives (sentinel compteur). Au-delà → stop + alerte (évite la boucle restore→échec→restore si le known-good est lui aussi mauvais).
- **Faux positifs** : Windows peut booter lentement → fenêtre 68 min + double critère (guest-agent ET CPU). Réglable.
- **TPM** : on garde l'effacement `tpm2-00.permall` existant (évite le hang TPM) ; l'auto-réparation OVMF est complémentaire.
- **Idempotence** : nettoyage des sentinels en début de cycle.
## 5. Points d'intégration (à valider Dom avant écriture)
- `win11-arm-lea.service` : ajouter `ExecStartPre` de garde (si `boot-failed` + known-good → restaurer avant lancement) et `ExecStartPost` qui lance le watchdog.
- Nouveau script `vm-health-watchdog.sh` (briques A+B) dans `~/quickemu-win11-arm-lea/`.
- Optionnel : `vm-health-watchdog.service` (PartOf=win11-arm-lea.service) plutôt qu'un `&`, pour un cycle de vie propre.
## 6. Plan de test (sans risque, sur la VM labo)
1. Boot sain → vérifier création `OVMF_VARS.fd.known-good` + sentinel `boot-ok`.
2. Simuler corruption (copier l'`OVMF_VARS.fd.failed-powercut-20260620-021854` archivé sur le live) → vérifier détection à T+6 min, archivage, restauration, restart, boot sain.
3. Vérifier le compteur d'essais (corrompre aussi le known-good) → stop + alerte, pas de boucle infinie.
4. Mesurer le temps total de reprise auto (cible < 10 min sans intervention).
## 7. Décision attendue (Dom)
GO/NO-GO sur l'écriture + le réglage de la fenêtre (6 vs 8 min) et du mécanisme d'alerte (log seul ? message coordination ? mail ?). Application supervisée, un changement / un test, après ton réveil.

View File

@@ -35,6 +35,11 @@ P0 / P1 / P2 / P3 (alignées sur convention handoffs)
| DETTE-017 | 2026-06-12 | 2026-06-12 | P0 | OPEN | Auth Bearer **désactivée** (`RPA_AUTH_DISABLED=true`) sur streaming `5005` ET agent-chat `5004` du DGX, appliquée comme « fix » heartbeat B3 (rustine). Démontré inutile : les 3 tokens (DGX proc, DGX `.env.local`, Windows `.env`) sont identiques (SHA256 `43749362b1`, len 43) → l'auth peut être réactivée sans casser le heartbeat. Exposition `0.0.0.0:5004/5005` restreinte par iptables au seul poste `192.168.1.11` ; dashboard `5001` conserve son auth. **Exception temporaire validée par Dom (2026-06-12 09:35) pour test M2 local sur données factices.** ROLLBACK OBLIGATOIRE avant toute sortie clinique / données patient : `RPA_AUTH_DISABLED=false` dans `.env.local` DGX + `sudo systemctl restart rpa-streaming.service rpa-agent-chat.service` puis vérif (401 sans token / 200 avec / heartbeat maintenu). | docs/coordination/active/2026-06-12_0935_decision-dom-auth-off-exception-m2.md + alerte 2026-06-11_1535 |
| DETTE-018 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Garde-seuil inopérant sur le chemin grounding **legacy** : `_resolve_by_grounding` retourne `method="grounding_vlm"` (resolve_engine.py:1121, mode `RPA_GROUNDING_ENGINE` OFF), clé absente de `_RESOLUTION_MIN_SCORES` qui ne traite en **préfixe** que `memory_` (toutes les autres clés = match exact) → le Check-1 du validateur (seuil min de confiance) ne s'applique jamais à ce chemin. Le mode `qwen3vl_vllm` est lui correctement gardé (`method="grounding"`, clé exacte, seuil 0.60). Aligner le legacy (clé gardée ou renommage) tant que le mode legacy reste activable. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
| DETTE-019 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Confiance grounding **figée à `0.85` en dur** dans le `return` de `_resolve_by_grounding` (resolve_engine.py:1128-1130 : `matched_element.confidence` et `score`), pour les DEUX modes (legacy et qwen3vl). Le garde-seuil (0.60) reçoit donc toujours 0.85 quel que soit le grounding réel → le filtre ne discrimine jamais la vraie qualité de localisation. Propager une confiance réelle (signal modèle/cascade) pour rendre le seuil opérant. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
| DETTE-020 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Incidents silencieux — aucune détection/alerte des composants critiques d'inférence.** Un composant critique peut tomber sans alerte : `rpa-vllm-grounder.service` (grounder Qwen3-VL/vLLM) trouvé en **crash-loop (auto-restart, restart counter ×3960)** → le runtime a basculé **silencieusement** sur le fallback `qwen2.5vl:7b-rpa` (Ollama, ~×7 plus lent), avec une latence/contention accrue mais **aucune remontée visible** (ni dashboard, ni log d'alerte). Découvert uniquement par vérif manuelle au runtime (session 2026-06-25). La cause de CE crash (SSL HuggingFace au boot vs cache local — manque `HF_HUB_OFFLINE`) se corrige à part ; la dette ici = **le mode dégradé est silencieux**. Cible : health-check + supervision des composants critiques (grounder vLLM, Ollama, services `rpa-*`) avec **remontée VISIBLE** (dashboard 5001 / log d'alerte / notification) → une bascule en mode dégradé ne doit jamais passer inaperçue. ⚠️ Vérifier d'abord l'existant (module monitoring `:5003`) avant de construire. | session vérif runtime DGX clinique 2026-06-25 |
| DETTE-021 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Journalisation client Léa non effective.** `LOG_FILE` (`agent_v0/agent_v1/config.py:88``<install>/logs/agent_v1.log`) est défini mais **jamais branché** : aucun `FileHandler`/`addHandler` dans tout le client. Seul logging actif = `basicConfig` (`main.py:46`) → **stderr**, perdu car Léa tourne en `pythonw.exe` (sans console). Dossier `logs/` vide. Conséquences : (1) **diagnostic terrain aveugle** — impossible de tracer pourquoi Léa « disparaît » côté poste ; (2) **non-conformité Règlement IA Art. 12** (journalisation + conservation 180 j — citée dans le code mais non effective ; `LOG_RETENTION_DAYS` ne couvre que les *sessions*). Cible : brancher un `RotatingFileHandler`/`TimedRotating` vers `LOG_FILE` (rotation + purge 180 j, niveau INFO). ⚠️ modif client → **redéploiement** (cf. DETTE-022). Pendant client du DETTE-020 (observabilité serveur). | session diagnostic « disparition » Léa poste Émilie 2026-06-25 |
| DETTE-022 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Pas de mise à jour automatique du client Léa.** Toute modif du client (`agent_v0/agent_v1/**`) impose un **redéploiement manuel poste par poste** (Léa « gelée »). En clinique (5 postes, croissant), intervenir sur chaque poste à chaque correctif (ex. fix logging DETTE-021) **dérange les TIM et décourage l'adoption** (constat Dom). Cible : mécanisme de **MAJ auto / en tâche de fond** (auto-update silencieux, versionné, piloté serveur/dashboard, avec rollback), **zéro intervention sur le poste**. ⚠️ Vérifier d'abord l'existant côté enrôlement Fleet (dashboard build ZIP + token) avant de construire. | décision Dom 2026-06-25 (« on ne peut pas intervenir constamment sur les postes, on va décourager ») |
| DETTE-023 | 2026-06-30 | 2026-07-14 | P1 | OPEN | **Validation post-action systématique non câblée au replay live.** `core/execution/action_executor.py` expose `verify_postconditions=True` (+ re-vérif/retry, l.187-242) mais le runtime live `replay_engine.py` **n'importe pas `ActionExecutor`** (seulement `LLMActionHandler`, l.2497) → la vérif de post-condition après CHAQUE action est **écrite-non-wired**. Le replay live ne valide qu'à **gros grain** : `precheck` de similarité d'écran avant action (≥ 0.85, replay_engine.py:2844) + `verify_screen` **entre GROUPES** d'actions (l.39), pas après chaque clic. Lié à DETTE-008 (pre-check VLM par-clic désactivé `if False:`, observe_reason_act.py:1704) et DETTE-001 (pré-check OCR spatialement aveugle). **Enjeu produit** (décision Dom 2026-06-30 : « vision = validateur des actions ET de l'apprentissage », pour ZÉRO erreur en récupération de dossiers et scaling multi-VM/postes) : densifier la validation visuelle aux points critiques (login, ouverture dossier, lecture écran→JSON) **ou** rebrancher la vérif post-condition au replay live. ⚠️ Vérifier d'abord l'existant (`verify_screen`, `ActionExecutor`, ORALoop) avant de construire. | session 30/06 trace runtime (replay_engine n'utilise pas ActionExecutor) + décision Dom VM/vision 2026-06-30 |
| DETTE-024 | 2026-06-30 | 2026-07-14 | P1 | OPEN | **Le dashboard fleet `/api/fleet/download/<machine_id>` sert un ZIP NON autoportant.** Test 30/06 : le download a renvoyé un ZIP de **210 Ko** (sans `python-3.12-embed`) au lieu du `Lea_full_v1.0.1.zip` (33 Mo) pourtant déposé dans `deploy/build/` → le dashboard lit le **fallback** `deploy/Lea_v1.0.0.zip` (ou un chemin relatif au cwd, cf. DETTE-015) et NON le full. Conséquence : un poste enrôlé via le dashboard recevrait un exe **non installable** (pas de Python embarqué). Contourné manuellement pour Émilie (ZIP full local + `config.txt` du download + flag). Cible : le download doit servir le **full autoportant à jour** (chemin absolu, pas de fallback silencieux). ⚠️ Bloquant pour s'appuyer sur le dashboard au déploiement GPO/multi-postes. | session livraison exe Émilie 2026-06-30 (web_dashboard/app.py:2379) |
## Convention de référencement

View File

@@ -0,0 +1,540 @@
# Guide d'installation Lea - POC, MVP, production et multi-site
- Date: 2026-06-19
- Statut: version initiale exploitable, a durcir avant production
- Scope: installations Lea/RPA Vision V3 sur DGX + postes ou VM Windows
- Source: etat POC DGX, runbooks coordination, installeur Windows, systemd, dashboard/VWB/worker
## Objectif
Ce document sert de base d'installation reproductible pour plusieurs phases:
1. **POC**: installation controlee sur un site pilote, avec assistance technique forte.
2. **MVP**: installation repetable avec artefacts figes, checklist, rollback et preuves.
3. **Production**: installation industrialisee, secrets par site, supervision, sauvegardes, signature de l'installeur, support.
4. **Multi-etablissement**: meme produit, mais variables reseau, comptes, tokens, politiques de securite et donnees separees par etablissement.
Le principe directeur: une installation Lea ne doit pas dependre de la memoire d'un agent ou d'une session de debug. Elle doit etre executable par une checklist, testable, rollbackable et auditable.
## Architecture cible d'une installation
### Composants cote serveur
| Composant | Role | Port POC | Exposition attendue |
|---|---|---:|---|
| Dashboard Flask | supervision, verite produit, fleet, workflow status | 5001 | LAN/VPN autorise avec auth |
| VWB backend | workflows, anchors, base SQLite VWB | 5002 | local ou LAN controle selon packaging |
| Agent Chat | chat Lea, bulles, feedback temps reel | 5004 | accessible depuis poste/VM Windows |
| Streaming/Fleet | ingestion agent Windows, sessions, API agent | 5005 | accessible depuis poste/VM Windows |
| Upload/API core | API interne upload/traitement | 8000 | loopback par defaut |
| Worker | compilation/apprentissage/replay | n/a | service systemd |
| Ollama/VLM | inference locale | 11434 | local DGX, pas expose sauf decision explicite |
| VM/VNC Windows | console Windows DGX si utilisee | 5902 | tunnel SSH/VPN uniquement |
### Composants cote Windows
| Composant | Role | Artefact |
|---|---|---|
| Lea agent | capture, tray, chat, apprentissage, streaming | `deploy/lea_package` ou installeur Inno |
| `config.txt` | URL serveur, token API, machine id, label utilisateur | genere par dashboard ou installeur |
| Installeur Inno | installation MVP/prod, Python embedded, silent install | `deploy/installer/Lea.iss` |
| VM Windows | poste de test ou execution clinique | VMware, Hyper-V, QEMU standalone ou poste physique |
## Niveaux d'installation
| Niveau | Usage | Acceptable | Interdit |
|---|---|---|---|
| POC labo | tests rapides, validation technique | scripts manuels documentes, tunnel VNC, debug coordonne | secrets reutilises sans trace, actions non consignees |
| POC site | installation pilote chez client | artefact fige, runbook, rollback, preuves | activer reseau/site sans fenetre et rollback |
| MVP | repetition sur plusieurs postes | installeur signe ou controle, config par poste, smoke automatique | ZIP manuel non versionne comme seul chemin |
| Production | support multi-etablissement | CI release, signature, supervision, backups, rotation secrets | `DASHBOARD_AUTH_DISABLED`, tokens partages, ports remote ouverts |
## Fiche site obligatoire
Chaque etablissement doit avoir une fiche separee avant installation.
```text
SITE_ID=
Nom etablissement=
Contact technique=
Contact metier=
Fenetre installation=
Fenetre rollback=
DGX_HOSTNAME=
DGX_IP_LAB=
DGX_IP_SITE=
DGX_PREFIX=
DGX_GATEWAY=
DGX_DNS_1=
DGX_DNS_2=
IPv6=off/on
VLAN=non/oui + id
Interface Ethernet cible=
Dashboard URL=
Streaming URL agent=
Agent Chat URL=
VWB URL=
Windows cible=
Type Windows=poste physique / VMware / Hyper-V / QEMU DGX
Nom machine Windows=
Utilisateur Windows=
Mode acces distant=VNC / RDP / console hyperviseur / aucun
RPA_API_TOKEN=
DASHBOARD_USER=
DASHBOARD_PASSWORD=
ENCRYPTION_PASSWORD=
SECRET_KEY=
Politique retention=
Chemin backup=
Responsable validation GO=
```
Regle: aucun token ou mot de passe d'un site ne doit etre reutilise sur un autre site.
## Pre-requis avant installation
### Pre-requis DGX ou serveur Linux
- OS Linux valide pour le deploiement.
- Acces admin local ou fenetre avec administrateur.
- Python 3.10 a 3.12.
- GPU NVIDIA si inference VLM locale attendue.
- Ollama installe si le mode VLM local l'utilise.
- Repo disponible sur la branche cible, par exemple `poc-dgx` pour le POC.
- Ports internes/externes arbitres avant installation.
- Disque libre suffisant pour sessions, captures, backups et modeles.
- Politique backup validee avant toute migration ou reset.
### Pre-requis Windows
- Windows 10/11.
- Droits suffisants pour installer Lea.
- Acces reseau au DGX sur les ports agent requis.
- Pour MVP/prod, privilegier l'installeur Inno avec Python embedded.
- Pour POC, le ZIP `deploy/Lea_v<version>.zip` reste acceptable si documente.
### Pre-requis securite
- `DASHBOARD_AUTH_DISABLED` interdit hors dev local.
- `RPA_AUTH_DISABLED` interdit hors dev local.
- `RPA_API_TOKEN` obligatoire.
- `DASHBOARD_PASSWORD` obligatoire.
- Remote desktop/VNC/RDP ouverts uniquement en tunnel/VPN, jamais en LAN large par defaut.
- Les secrets sont stockes hors git.
- Le poste Windows doit afficher clairement l'etat d'enregistrement/apprentissage.
## Procedure POC actuelle
Cette section decrit l'etat connu du POC DGX. Elle n'est pas encore le chemin production final.
### 1. DGX - recuperer le code
```bash
git clone <repo> rpa_vision_v3
cd rpa_vision_v3
git checkout poc-dgx
```
Si le DGX contient deja des donnees runtime, ne jamais faire de reset destructif sans backup des chemins suivants:
```text
visual_workflow_builder/backend/instance/workflows.db
visual_workflow_builder/backend/data/
data/training/
data/runtime/
data/workflows
graphify-out/ si l'historique graphe doit etre preserve
```
### 2. DGX - environnement Python
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### 3. DGX - configuration runtime
Creer `.env.local` depuis le modele:
```bash
cp deploy/systemd/rpa_vision_v3.env.example .env.local
```
Valeurs obligatoires a remplacer:
```text
ENCRYPTION_PASSWORD=CHANGE_ME
SECRET_KEY=CHANGE_ME
RPA_API_TOKEN=CHANGE_ME
DASHBOARD_USER=lea
DASHBOARD_PASSWORD=CHANGE_ME
ENVIRONMENT=production
RPA_PROCESSING_WORKER=external
RPA_API_HOST=127.0.0.1
RPA_DASHBOARD_HOST=0.0.0.0
RPA_VLM_MODEL=gemma4:26b
RPA_GROUNDING_ENGINE=qwen3vl_vllm
VLLM_MODEL=Qwen/Qwen3-VL-4B-Instruct
```
Pour un site, documenter explicitement quels services restent en loopback et quels services sont accessibles depuis la VM/poste Windows. En POC DGX, le dashboard est expose au LAN sur `0.0.0.0:5001` avec auth. En clinique durcie, preferer un reverse proxy HTTPS ou un bind loopback derriere proxy/VPN.
### 4. DGX - services
Services systemd attendus ou equivalents:
```text
rpa-agent-chat.service
rpa-firewall.service
rpa-streaming.service
rpa-vision-v3-api.service
rpa-vision-v3-dashboard.service ou fallback rpa-vision-v3-dashboard-user
rpa-vision-v3-worker.service
rpa-vision-v3-stream-worker.service
rpa-vision-v3-vwb-backend.service
rpa-vision-v3-vwb-frontend.service
rpa-vllm-grounder.service si active
rpa-vision.target
```
Commandes de controle:
```bash
systemctl status rpa-agent-chat.service
systemctl status rpa-firewall.service
systemctl status rpa-streaming.service
systemctl status rpa-vision-v3-dashboard.service
systemctl status rpa-vision-v3-worker.service
systemctl status rpa-vision-v3-stream-worker.service
systemctl status rpa-vision-v3-vwb-backend.service
systemctl status rpa-vision-v3-vwb-frontend.service
systemctl list-units 'rpa*'
```
En POC dev, `svc.sh` reste utilisable:
```bash
./svc.sh status
./svc.sh start
./svc.sh restart api
./svc.sh stop
```
### 5. DGX - modele local
Verifier Ollama et les modeles attendus. Le POC DGX distingue:
- cerveau/lecteur Ollama: par exemple `gemma4:26b` selon `.env.local`;
- grounder VLM via vLLM: `Qwen/Qwen3-VL-4B-Instruct` avec `think=false`.
```bash
curl http://localhost:11434/api/tags
ollama list
# Si le modele site manque:
ollama pull <modele_ollama_site>
```
Ne pas exposer `11434` au LAN sans decision explicite.
### 6. DGX - donnees VWB/apprentissage
Verifier:
```bash
test -f visual_workflow_builder/backend/instance/workflows.db
find visual_workflow_builder/backend/data -maxdepth 3 -type f | head
find data/training -maxdepth 3 -type f | head
```
Endpoints utiles:
```bash
curl -s http://127.0.0.1:5002/health
curl -s http://127.0.0.1:5005/health
curl -s http://127.0.0.1:5001/api/system/status
```
Selon l'auth dashboard, `5001` peut repondre `401`; c'est attendu si l'auth est active.
## Installation Windows
### Chemin POC ZIP
Construire le package:
```bash
./deploy/build_package.sh --clean
./deploy/build_package.sh
```
Copier `deploy/Lea_v<version>.zip` sur la machine Windows, puis:
1. Dezipper.
2. Modifier `config.txt`.
3. Lancer `install.bat`.
4. Lancer `Lea.bat`.
`config.txt` minimum:
```text
RPA_SERVER_URL=http://<DGX_IP>:5005/api/v1
RPA_API_TOKEN=<token_site_ou_poste>
RPA_MACHINE_ID=<site-machine-unique>
RPA_USER_LABEL=<nom_utilisateur_ou_poste>
RPA_BLUR_SENSITIVE=false
RPA_LOG_RETENTION_DAYS=180
```
### Chemin MVP/prod installeur
Le chemin cible est l'installeur Inno Setup:
```bash
./deploy/installer/build_installer.sh
```
Sortie attendue:
```text
deploy/releases/Lea-Setup-v1.0.1.exe
```
Installation silencieuse type:
```cmd
Lea-Setup-v1.0.1.exe /VERYSILENT /CONFIG=C:\temp\enroll.txt /DIR="C:\Lea" /LOG="C:\temp\lea-install.log"
```
`enroll.txt`:
```text
USER_NAME=Prenom Nom
USER_EMAIL=prenom.nom@example.local
USER_ID=EMP-00123
SERVER_URL=http://<DGX_IP>:5005/api/v1
API_TOKEN=<token>
```
Production: l'installeur doit etre signe pour eviter les alertes SmartScreen et faciliter le deploiement IT.
## VM Windows DGX - etat POC et regles
Etat confirme le 2026-06-19:
- Windows 11 ARM DGX fonctionne via QEMU standalone.
- Acces utilisateur via VNC tunnel `localhost:5902`.
- Le runtime actif utilise `disk.qcow2` cote utilisateur `aivanov`.
- La definition libvirt `win11-arm-lea` peut apparaitre arretee pendant que le standalone tourne.
Regles:
- Ne pas demarrer la VM libvirt `win11-arm-lea` pendant que QEMU standalone utilise deja `disk.qcow2`.
- Ne pas lancer deux VM sur le meme disque.
- En standalone, `disk.qcow2` doit etre accessible a l'utilisateur qui lance QEMU, actuellement `aivanov`.
- Si retour libvirt, prevoir de remettre l'ownership attendu par libvirt, par exemple `libvirt-qemu:libvirt-qemu`, apres arret complet du standalone.
- Le VNC doit rester en loopback/tunnel, par exemple `127.0.0.1:5902`, pas en exposition LAN large.
Loose ends a traiter avant MVP:
1. Persister proprement le VNC loopback dans le script original ou dans un wrapper.
2. Decider si le VNC a un password pose automatiquement via monitor, ou pas de password car tunnel obligatoire.
3. Choisir un seul runtime officiel pour la VM DGX: standalone documente ou libvirt corrige.
4. Documenter le rollback disk owner standalone <-> libvirt.
5. Decider et implementer l'auto-start au reboot DGX si la VM Windows doit etre disponible apres redemarrage:
- script persiste, pas `/tmp/vmvnc.sh`;
- service systemd `User=aivanov` ou user service avec `loginctl enable-linger`;
- `After=network-online.target`;
- garde-fou contre un demarrage libvirt parallele;
- choix VNC: sans password car loopback+tunnel, ou wrapper qui pose le password via monitor.
## Reseau site
Chaque site doit avoir un plan reseau valide avant installation.
Exemple clinique prepare:
```text
DGX_IP_SITE=192.168.1.178
PREFIX=/24
GATEWAY=192.168.1.243
DNS_1=192.168.1.9
DNS_2=192.168.1.8
IPv6=off
VLAN=non
Interface=Ethernet uniquement
```
Regles:
- Ne pas activer le profil Ethernet site pendant les tests labo sans GO.
- Prevoir un acces local ou une console avant toute bascule reseau.
- Noter le rollback exact avant modification IP.
- Valider depuis Windows: dashboard, chat, streaming.
## Smoke tests d'acceptation
### Serveur
| Test | Attendu |
|---|---|
| `systemctl status rpa-streaming` | actif |
| `systemctl status rpa-vision-v3-dashboard` | actif ou fallback documente |
| `curl :5005/health` | 200/healthy |
| dashboard `:5001` | login/401 ou UI, pas 500 |
| `/api/system/status` | coherent, pas de faux vert |
| `/api/workflows` | workflows VWB visibles |
| worker status | `healthy` ou `idle` non degrade si aucun job |
| ports remote VNC/RDP | fermes au LAN, tunnel only |
### Windows
| Test | Attendu |
|---|---|
| `config.txt` | valeurs site/poste remplacees, aucun `CONFIGURE_ME` |
| lancement Lea | tray visible |
| chat Lea | connexion au DGX |
| capture/apprentissage | demarre avec consentement utilisateur |
| stop apprentissage | session ecrite cote DGX |
| dashboard fleet | machine visible |
| streaming | session recue sur `5005` |
### VWB/apprentissage
| Test | Attendu |
|---|---|
| `workflows.db` | present, backup effectue |
| workflows dashboard | liste chargee |
| anchors | images visibles |
| replay supervise | action ou demande de confirmation, pas de clic non controle |
| worker | session traitee puis retour sain |
## Criteres GO / NO-GO
GO installation site si:
- artefacts versionnes et identifies;
- fiche site complete;
- secrets generes pour le site;
- DGX accessible et services verts;
- Windows connecte au DGX;
- dashboard/VWB/worker coherents;
- ports remote non exposes hors tunnel/VPN;
- rollback documente;
- preuves archivees.
NO-GO si:
- un secret `CHANGE_ME` ou `CONFIGURE_ME` reste en place;
- dashboard auth desactive hors dev;
- `RPA_AUTH_DISABLED=true` hors dev;
- VWB/workflows absents sans decision;
- Windows ne rejoint pas le streaming;
- VM ou poste controle le mauvais DGX;
- deux runtimes VM utilisent le meme disque;
- ports VNC/RDP exposes au LAN sans validation;
- pas de backup `workflows.db` avant reset/deploy.
## Sauvegarde et rollback
Avant chaque installation ou upgrade:
```bash
STAMP=$(date +%Y%m%dT%H%M%S)
mkdir -p .codex_backups/install-$STAMP
cp -a visual_workflow_builder/backend/instance/workflows.db .codex_backups/install-$STAMP/ 2>/dev/null || true
cp -a visual_workflow_builder/backend/data .codex_backups/install-$STAMP/vwb-data 2>/dev/null || true
cp -a data/training .codex_backups/install-$STAMP/training 2>/dev/null || true
cp -a .env.local .codex_backups/install-$STAMP/env.local 2>/dev/null || true
```
Rollback code:
```bash
git fetch origin
git checkout <commit_valide>
systemctl restart rpa-streaming rpa-vision-v3-dashboard rpa-vision-v3-worker rpa-agent-chat
```
Rollback donnees: restaurer uniquement les chemins sauvegardes, jamais faire `git clean -xfd` sur le DGX POC avec donnees runtime non trackees.
## Journal de preuve d'installation
Pour chaque installation, creer un dossier de preuve:
```text
installations/<SITE_ID>/<YYYY-MM-DD>/
site-sheet.md
versions.txt
services.txt
ports.txt
dashboard-smoke.txt
windows-agent-smoke.txt
vwb-smoke.txt
backups.txt
incidents.md
verdict.md
```
`versions.txt` doit contenir:
```bash
git rev-parse HEAD
git status -sb
python --version
pip freeze | sort
systemctl --version
ollama list
```
## Industrialisation requise avant production
| Sujet | Etat POC | Attendu MVP/prod |
|---|---|---|
| Installeur Windows | Inno disponible | release signee, tests install/desinstall |
| Enrollment | config/token manuel ou dashboard | token par poste, expiration/revocation |
| Secrets | `.env.local` manuel | coffre ou procedure secrete auditee |
| Services | systemd partiel selon DGX | target unique, healthcheck, recover |
| Supervision | dashboard + logs | alerting, retention, export incident |
| Backups | manuels | job planifie, test de restauration |
| VM Windows DGX | standalone VNC manuel fonctionnel | runtime officiel choisi, persiste et auto-start arbitre |
| Multi-site | variables en coordination | fiche site versionnee, aucun secret partage |
| Documentation utilisateur | `LISEZMOI.txt` | guide utilisateur + admin par site |
| Support | agents war-room | procedure support N1/N2/N3 |
## Check-list courte jour d'installation
1. Valider fiche site et fenetre rollback.
2. Verifier artefact serveur et installeur Windows.
3. Generer secrets site/poste.
4. Sauvegarder donnees DGX existantes.
5. Installer ou mettre a jour serveur.
6. Configurer reseau sans perdre l'acces de rollback.
7. Demarrer services.
8. Installer Lea sur Windows.
9. Lancer smoke serveur.
10. Lancer smoke Windows.
11. Tester dashboard, VWB, chat, streaming, worker.
12. Archiver preuves.
13. Donner verdict GO/NO-GO.
14. Si GO, remettre les acces temporaires en mode securise.
## Documents sources
- `README.md`
- `deploy/systemd/rpa_vision_v3.env.example`
- `deploy/lea_package/LISEZMOI.txt`
- `deploy/lea_package/config.txt`
- `deploy/build_package.sh`
- `deploy/installer/README.md`
- `deploy/installer/Lea.iss`
- `docs/coordination/RUNBOOK-DGX-POST-REBOOT-CHECK.md`
- `docs/coordination/RUNBOOK-LEA-LIVE-DRAFT.md`
- `docs/coordination/active/2026-06-19_1418_postaction-windows-dgx-fonctionne.md`
- `docs/coordination/inbox_codex/2026-06-19_1420_claude-to-codex_ACK-STOP-DIAG-VM-LOOSE-ENDS.md`

View File

@@ -0,0 +1,34 @@
# Mémo Jour J — Livraison définitive du DGX à la clinique (2026-06-23)
> Contexte : le DGX part **définitivement** à la clinique. Après, travail **100% à distance** depuis le labo. Tout doit marcher avant de débrancher.
## ✅ Validé la veille (2026-06-22) — rien à refaire
- **Installateur Léa 1-clic autoportant** (python-embed, **sans Python système**, raccourci Bureau + démarrage auto) — testé sur VM Win11, enrôlement confirmé (Qwen).
- **Apprentissage RPA** : OK.
- **Reboot complet du DGX** : 11 services + grounder (modèle ~60 s) + VM reviennent **seuls**.
- **Auto-réparation OVMF de la VM** (watchdog user `vm-ovmf-watchdog.service`) **LIVE** : après une coupure secteur, la VM se **répare et reboote seule** (~3-5 min). Testé (détection boucle CPU 99% → restauration known-good → relance).
- **IP DGX clinique `192.168.1.178`** : réservation DHCP côté clinique pour la MAC Ethernet `10:b6:76:f0:2f:f4`. Tous les services `enabled` (auto-start au boot).
- **Dashboard** bascule faite : `RPA_PUBLIC_URL=http://192.168.1.178:5005` → les Léa générées pointent direct `.178`.
## 🏥 Sur site (jour J)
1. Brancher le DGX sur l'**Ethernet clinique** + allumer. **Attendre ~3-5 min** (boot + grounder + VM).
2. Vérifier `.178` : depuis un poste clinique, ouvrir **http://192.168.1.178:5001** (login `lea`). Si le dashboard répond → DGX up + bonne IP.
3. Grounder vision : prêt ~3 min après le boot — **ne pas tester la vision de Léa avant**.
4. **Installer Léa sur les postes TIM** : sur chaque poste → `http://192.168.1.178:5001` → Fleet → enrôler → télécharger le ZIP → **clic droit Extraire** → double-clic **`Installer-Lea.bat`** → vérifier : pas d'erreur Python, Léa dans le systray, apparaît dans la fleet. **Tester l'apprentissage sur 1 poste avant de généraliser.**
5. 🔴 **AVANT DE PARTIR (point de non-retour)** : depuis ton laptop via le **VPN Stormshield**, ouvrir **http://192.168.1.178:5001**. Si le dashboard s'affiche → **ton accès distant est bon, tu peux partir**.
## 🏠 Après le départ — travail à distance (labo)
- Accès DGX : **VPN Stormshield → `http://192.168.1.178:5001`** (dashboard) + `ssh aivanov@192.168.1.178` (cert).
- ⚠️ Déploiement de code/correctifs : **scp/rsync par-dessus le VPN** — PAS `git pull` (la Gitea maison n'est pas exposée sur internet).
- Coupure secteur clinique → la VM s'auto-répare (watchdog OVMF). Accès VM : RDP (presse-papier) ou VNC.
## Identifiants
- Voir ta note `DGX SSH aivanov Dom31.txt` (Bureau VM / clé USB) et `~/ai/rpa_vision_v3/.env.local` sur le DGX. (Non recopiés ici : ce fichier est dans le dépôt.)
- VM Windows : compte `aivanov`.
## Reste / dette (post-installation — « on a encore du boulot »)
- **Compte VM `aivanov`** : définir un **mot de passe** (pas juste un PIN) pour que le RDP + presse-papier marchent.
- Code dev↔DGX : aligné (diff fait — seuls des tests + `config.py`/`index.html` mineurs diffèrent, non bloquant).
- Mot de passe VNC perdu à chaque reboot VM (cosmétique — RDP = voie normale). Persistance VNC à câbler si besoin.
- `hostname` Léa remonte `N/A` (python-embed) — cosmétique.
- Profil **Stormshield laptop** (2ᵉ accès) à confirmer avec PORQUET (atteint `.178` ?).

View File

@@ -0,0 +1,40 @@
# Note DSI — Déploiement / mise à jour de Léa par GPO (AD clinique)
- Date : 2026-06-28
- Pour : Nicolas PORQUET (DSI Hôpital privé Wallerstein)
- De : équipe Léa (Dom)
- Objet : utiliser l'AD/GPO de la clinique comme **canal de déploiement** des mises à jour de l'agent Léa sur les postes pilotes, en remplacement d'un updater applicatif maison.
## 1. Contexte technique de Léa (côté poste)
- Léa s'installe **par utilisateur** (`%LOCALAPPDATA%\Lea\`), **sans droits administrateur ni UAC**, sans Python système (Python embarqué).
- Le programme d'installation est un **EXE (Inno Setup)** supportant le mode **silencieux** (`/SILENT` / `/VERYSILENT`). **Pas de package MSI** à ce jour.
- L'app tourne en contexte utilisateur (`pythonw.exe`), démarrage par raccourci/observation.
## 2. Besoin
Pousser une **mise à jour** (la prochaine, et idéalement les suivantes) sur **un sous-ensemble de postes** (les postes pilotes Léa), **sans intervention manuelle poste par poste**.
## 3. Options GPO envisagées (à valider avec vous)
| Option | Mécanisme | Adapté ? |
|---|---|---|
| **Logon script (utilisateur)** | script au login qui lance l'installeur en silencieux ou copie les fichiers à jour dans `%LOCALAPPDATA%\Lea\` | ✅ **fit naturel** (per-user, sans admin) |
| **GPP — Files / Scheduled Task** | déploiement de fichiers ou tâche planifiée de mise à jour | ✅ alternative |
| GPO **Software Installation** | déploiement **MSI** assigné machine/utilisateur | ❌ nécessite un **MSI** (non disponible) |
- **Idempotence** : le script vérifiera un **marqueur de version** pour ne PAS réinstaller à chaque ouverture de session.
- **Payload** : les fichiers de mise à jour seront déposés sur un **partage atteignable** par les postes (SYSVOL ou share réseau dédié) — à définir avec vous.
- **Rollback** : en cas de version défaillante, repush de la version précédente par la même voie.
## 4. Questions / ce que nous vous demandons
1. **Topologie exacte des postes pilotes** : PC physiques joints à l'AD ? hôtes **RDP** ? applications **Citrix** publiées ? (cela décide GPO **machine** vs **utilisateur**).
2. Pouvez-vous créer une **OU dédiée** ou un **groupe de sécurité** ciblant les postes/utilisateurs Léa ?
3. Un **partage réseau** (ou SYSVOL) pour héberger le payload de mise à jour ?
4. Vos **contraintes de sécurité** : les **logon/startup scripts** sont-ils autorisés par votre politique ? Y a-t-il **AppLocker / SRP / WDAC** ou une exigence de **signature de code** sur les exécutables ? (le cas échéant on fournit l'empreinte SHA256 / on discute signature).
5. Validez-vous le principe **rollback = repush version précédente** ?
## 5. Ce que nous fournissons
- L'**installeur silencieux** (EXE) + son empreinte SHA256.
- Un **script de logon** type (lancement silencieux + contrôle de version/marqueur).
- La **liste des postes** pilotes concernés.
## 6. Bénéfice
Canal de déploiement **standard, piloté par la DSI, traçable**, sans updater applicatif maison (donc sans risque de « briquer » les postes par un mécanisme de mise à jour custom). Compatible mises à jour ultérieures.

View File

@@ -0,0 +1,138 @@
# PLAN — Accès distant DGX consolidé par SSH-certificat (labo + déplacement)
- `Auteur`: Claude (infra), co-construit avec Codex (réseau/box) + Qwen (client Windows)
- `Date`: 2026-06-20
- `Statut`: **PLAN / proposition** — décisions + application supervisées par Dom (rien de modifiant sans GO).
- `Réf`: [[project_remote_access_consolidation_20260620]], `reference_dgx_static_ip`.
## 1. Objectif
Accès distant **robuste et unifié** au DGX, authentifié par **certificat SSH (CA OpenSSH)**, dans 2 contextes — **labo** (LAN/WiFi) et **déplacement depuis le PC Windows de Dom** (internet) — vers 3 cibles :
1. **Terminal DGX** (SSH) ;
2. **VM Windows** (VNC `5902` over SSH) ;
3. **Bureau Ubuntu du DGX** (GNOME, via VNC — « éventuel »).
Contrainte forte : **on-premise / no-cloud** (pas de Tailscale-cloud ni Cloudflare).
## 2. État actuel (relevé read-only)
| Élément | Constat | Conséquence |
|---|---|---|
| SSH DGX | Aucune CA (`trustedusercakeys none`) ; auth par clé OK | terrain vierge propre pour une CA |
| sshd | ⚠️ `PasswordAuthentication yes` (la checklist DSI annonce "no") | à corriger dans le durcissement |
| VPN/overlay | Aucun installé | à mettre en place |
| Bureau Ubuntu | Pas de VNC `5900` actif (DGX headless) | cible 3 à créer |
| IPv4 box | **`82.64.97.95` routable** (Free, pas CGNAT) | **port-forward UDP possible → WireGuard direct viable** |
| Ingress | Box forwarde déjà 80/443 → NPM ; `lea.labs…``82.64.97.95` | DDNS de fait + infra réutilisable |
| IPv6 | DGX a une IPv6 globale `2a01:e0a:28:ad60:…` | option SSH IPv6 direct (secondaire) |
## 3. Architecture cible — 2 couches découplées
**Couche AUTH (identique partout) = CA OpenSSH.** On ne gère plus des `authorized_keys` poste par poste : une CA signe les clés. Un seul point de confiance, révocation centralisée, plus d'avertissement host-key.
**Couche TRANSPORT (selon contexte) :**
- Labo : SSH direct `192.168.1.45` (LAN).
- Déplacement : **WireGuard self-hosted** → une fois le tunnel monté, on est « sur le LAN » et **tout passe par SSH/tunnels** (terminal + VNC VM + VNC bureau) — *exactement le même geste qu'au labo*.
Bénéfice : **un seul mécanisme d'accès (SSH + tunnels) pour les 3 cibles**, que l'on soit au labo ou en déplacement. Le transport ne change que la « route ».
## 4. Approches transport comparées
| | A. WireGuard self-hosted ✅ reco | B. Mesh self-hosted (Netbird/Headscale) | C. SSH-over-443 (NPM/wstunnel) |
|---|---|---|---|
| NAT/CGNAT | OK (IPv4 routable + fwd UDP dispo) | OK même CGNAT (relais) | OK (443 quasi toujours ouvert) |
| No-cloud | ✅ total | ✅ si self-hosted (serveur ctrl à tenir) | ✅ (réutilise NPM) |
| Complexité | Faible/moyenne | Plus élevée (mgmt+signal+relay) | Moyenne (wrap TLS) |
| Réseaux hôtels restrictifs (UDP bloqué) | ⚠️ → fallback nécessaire | OK | ✅ idéal |
| Pertinence ici | **Forte** (on a IPv4 routable) | Surdimensionné maintenant | **Bon comme fallback** |
**Recommandation : A (WireGuard) en primaire + C (WireGuard-over-wstunnel sur 443) en fallback** pour les réseaux qui bloquent l'UDP (hôtels, certains 4G). B (mesh) seulement si une clinique se révèle CGNAT/durcie.
## 5. Couche AUTH — détail CA OpenSSH
Bonnes pratiques (Red Hat / Teleport / OpenSSH cookbook) : **2 CA séparées** (privilege separation), clés CA **hors DGX**.
1. **Générer les CA** (sur le poste Dom, stockage sûr, JAMAIS sur le DGX) :
- `ssh-keygen -t ed25519 -f user_ca -C "rpa-user-ca"`
- `ssh-keygen -t ed25519 -f host_ca -C "rpa-host-ca"`
2. **Certifier l'hôte DGX** (supprime l'avertissement host-key, survit aux rotations) :
- `ssh-keygen -s host_ca -I dgx-zgx2ff4 -h -n 192.168.1.45,dgx.lab,<ipv6> -V +52w /etc/ssh/ssh_host_ed25519_key.pub`
- sshd : `HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub`
- client `known_hosts` : `@cert-authority 192.168.1.45,*.lab <contenu host_ca.pub>`
3. **Certifier l'utilisateur Dom** (laptop + poste labo) :
- `ssh-keygen -s user_ca -I dom-laptop -n aivanov -V +52w id_ed25519.pub` (principal = compte cible)
- sshd : `TrustedUserCAKeys /etc/ssh/user_ca.pub` (+ option `AuthorizedPrincipalsFile`)
4. **Durcir sshd** : `PasswordAuthentication no`, `KbdInteractiveAuthentication no`, `PermitRootLogin prohibit-password`.
5. **Révocation** : KRL (`ssh-keygen -k`) + `RevokedKeys` dans sshd — révoquer un poste perdu sans toucher les autres.
Validité : **certs user 1 an** (simple, + KRL) ou **courts** (plus sûr, nécessite un re-signing régulier). Reco équipe réduite : **1 an + KRL**.
## 6. Couche TRANSPORT — détail
**WireGuard (primaire)** :
- Serveur `wg0` **sur le DGX** (miroir du futur déploiement clinique) : ex. `10.10.0.1/24`, `ListenPort 51820`.
- Box : **port-forward UDP 51820 → DGX**. Nom dédié `vpn.<…>.laurinebazin.design``82.64.97.95` (DDNS).
- Laptop = peer (`10.10.0.2/24`), `Endpoint vpn.…:51820`, `AllowedIPs` couvrant `10.10.0.0/24` + `192.168.1.45/32` (accès DGX + ses services).
- Connecté → `ssh dom@10.10.0.1` (cert) ; `-L 5902` VM ; `-L 5900` bureau.
**Fallback 443 (réseaux UDP-bloqués)** : `wstunnel` encapsule WireGuard dans du WebSocket/`wss` sur **443** via le NPM (`wstunnel … wss://vpn.…:443`). Tout sort en 443 → passe les proxys/hôtels. Réf : Hetzner/eduVPN.
**IPv6 direct (secondaire/dépannage)** : quand le réseau client a l'IPv6, `ssh` direct vers l'IPv6 globale du DGX (épingler `mngtmpaddr`, firewall allow, cert). Pratique mais non garanti (réseaux IPv4-only).
## 7. Accès aux 3 cibles (même geste partout)
| Cible | Mécanisme | Commande type |
|---|---|---|
| Terminal DGX | SSH cert | `ssh dom@<dgx>` |
| VM Windows | VNC over SSH | `ssh -N -L 5902:127.0.0.1:5902 dom@<dgx>` → VNC `localhost:5902` |
| Bureau Ubuntu DGX | VNC over SSH | **DGX = X11/Xorg (confirmé Qwen)**`x11vnc -localhost -rfbauth ~/.vnc/passwd` sur la session `:1` (ou TigerVNC), **bind loopback + password**`ssh -N -L 5900:127.0.0.1:5900 …` → VNC `localhost:5900`. ⚠️ l'ancien x11vnc était en `nohup` non-persisté (mort à la panne) et sans password — à recréer en **service systemd user sécurisé** (P0). |
`<dgx>` = `192.168.1.45` au labo, `10.10.0.1` (wg) en déplacement. **La seule variable est l'hôte.**
**Presse-papier / copier-coller (demande Dom)** :
- Bureau Ubuntu (x11vnc/TigerVNC) : **texte natif** ✅, rien à faire.
- VM Windows (VNC QEMU) : **à activer**. QEMU 8.2.2 supporte le clipboard vdagent et le bus `virtio-serial` est déjà présent → ajouter `-chardev qemu-vdagent,id=vdagent,clipboard=on` + `-device virtserialport,chardev=vdagent,name=com.redhat.spice.0` + installer **SPICE guest tools (vdagent)** dans Windows = **copier-coller texte bidirectionnel** (garde le viewer VNC). Le tunnel SSH ne gêne pas (in-band).
- Transfert **fichiers** (option) : SPICE (client virt-viewer) ou RDP natif (selon édition Windows — Home ARM = pas de serveur RDP).
- ⚠️ Sous-chantier séparé : modif `vm_launch.sh` + install guest + **redémarrage VM gracieux** (jamais kill) → GO Dom.
## 8. Décisions attendues (Dom)
1. **Endpoint WireGuard** : sur le **DGX** (reco, mirroir clinique) ou sur le **poste Dom** (gateway always-on) ?
2. **Fallback 443 (wstunnel)** : oui/non (utile si tu te connectes depuis hôtels/4G restrictifs) ?
3. **Validité cert user** : **1 an + KRL** (reco) ou courts ?
4. **Bureau Ubuntu DGX** : on le met en place maintenant ou « éventuel » plus tard ? (+ Wayland vs X11 — input Qwen).
5. **Garde des clés CA** : sur le poste Dom (reco) — confirmer le lieu de stockage.
**Convergence équipe (Qwen + Claude)** sur les 5 options recommandées : WG sur DGX ✅, fallback 443 ✅, certs 1 an + KRL ✅, bureau Ubuntu maintenant (X11, P0 sécurité) ✅, clés CA hors DGX ✅. Codex (box/transport) à compléter.
**À confirmer par Dom (poste Windows — Qwen n'y a pas accès)** :
- Version OpenSSH du laptop (`ssh -V`) — natif Win10 1803+/11, support cert OK normalement.
- Client VNC Windows : **RealVNC Viewer** ou **TigerVNC viewer** (reco ; Remmina = Linux only).
- A-t-il déjà un accès SSH depuis Windows vers le DGX (aujourd'hui : non testé) ?
**Note credentials** : seul `RPA_API_TOKEN` (config.txt Léa = `.env.local` DGX) est partagé entre Léa et le DGX ; le plan SSH-cert n'y touche pas → pas de conflit, mais si un renouvellement de token est fait par ailleurs, le répercuter aux 2 endroits.
## 9. Mise en œuvre — chirurgie itérative supervisée (un pas = un test)
| Phase | Contenu | Owner | Réversible |
|---|---|---|---|
| 0 | Backups (`sshd_config`, known state), inventaire exposition | Claude/Codex | — |
| 1 | CA générées (off-DGX) + host cert + `@cert-authority` client → **test : plus d'avertissement host-key** | Claude | oui |
| 2 | User cert + `TrustedUserCAKeys`**test : login par cert** *en gardant clé/MDP en secours* | Claude | oui |
| 3 | Durcissement (`PasswordAuthentication no`, KRL) → **test** ; retrait ancienne clé | Claude (GO Dom) | oui (backup) |
| 4 | WireGuard DGX + fwd UDP box + peer laptop → **test reach déplacement (terminal+VM+bureau)** | Codex+Claude | oui |
| 5 (opt) | Fallback wstunnel/443 | Codex | oui |
| 6 (opt) | Bureau Ubuntu (TigerVNC/grd headless) | Qwen | oui |
| 7 | Doc + **portage clinique** (Ethernet `.178`, même CA, même WG) | tous | — |
## 10. Sécurité & garde-fous
- Clé privée **CA hors DGX** (compromission DGX ≠ compromission CA).
- `PasswordAuthentication no` corrige l'écart actuel (DSI annonçait "no").
- WG = port unique UDP exposé (vs SSH brut exposé). SSH reste **non exposé en direct sur internet** (accès via WG), sauf option IPv6/fallback maîtrisée.
- Révocation par KRL (poste volé) sans réémettre les autres certs.
- Rollback : `sshd_config.bak` + redémarrage sshd ; WG = `wg-quick down`.
## 11. Portage clinique
Même CA + même WireGuard rejouables sur le DGX clinique (Ethernet statique `.178`). L'accès distant de Dom (support/maintenance à distance) devient un livrable DSI propre (un port UDP, auth cert, révocable) — argument sécurité pour Nicolas PORQUET.

View File

@@ -0,0 +1,205 @@
# Plan d'action — la suite (post-livraison DGX clinique)
- `Date`: 2026-06-23
- `Auteur`: Claude (mandat Dom)
- `Statut`: vivant — à mettre à jour au fil des validations
- `Portée`: **chapeaute** les plans existants (ne les remplace pas), dédoublonne et priorise les actions **encore ouvertes** après la livraison clinique du jour.
> Contexte : le DGX part **définitivement** en clinique aujourd'hui (`192.168.1.178`), puis travail **100 % à distance** (VPN Stormshield + scp). Le jour J lui-même est couvert par `MEMO_JOUR_J_LIVRAISON_DGX_CLINIQUE_2026-06-23.md` — non répété ici.
---
## 0. Deux clarifications de cadrage (lire avant le reste)
1. **Les gaps « pré-clinique » sont clos.** L'audit (`AUDIT_GAPS_APPLI_100PCT_2026-06-10`) et le postmortem (`POSTMORTEM_PANNE_SECTEUR_DGX_2026-06-20`) listaient des gaps durs (OVMF G2, IP statique G1, reconnexion Léa G4, reboot 11 services). Le `MEMO_JOUR_J` + `TABLEAU_ACTIONS_DOM_PRECLINIQUE_2026-06-21` confirment qu'ils sont **résolus** (watchdog OVMF LIVE testé, `.178` réservé DHCP, installateur autoportant). → **sortis de ce plan.**
2. **Accès distant = multi-VPN par site (correction Dom 23/06).** Pas « WireGuard caduc » : **WireGuard = notre VPN** (labo/éditeur, reste valide) ; **Stormshield = le VPN de la clinique** (côté client) ; d'autres clients auront **d'autres VPN**. ⇒ l'accès distant devient une **fonctionnalité paramétrée par fiche site** (`INSTALLATION_MULTI_SITE`), avec **SSH cert-only + RDP comme transport commun** au-dessus du VPN propre à chaque site. Le `PLAN_ACCES_DISTANT_SSH_CERT_DGX_2026-06-20` (WireGuard) n'est pas suspendu — il devient *un* profil d'accès parmi d'autres.
---
## Tableau de bord — sous-projets (labo d'abord, WIP ≤ 3 actifs)
> Chaque feature F# = **un sous-projet** (objectif, branche, prérequis, statut, done). Backlog priorisé ; **on n'ouvre que 2-3 sous-projets actifs à la fois** (réactivité = focus). **Merge prod = supervisé Dom.** Codex orchestre par sous-projet à son retour (28/06).
**Prérequis socle (avant parallélisme)** : merger `fix/dashboard-complete-installer` (HEAD `d686c3ac2`) sur `poc-dgx` (`1d6efdb1b`) → base git propre ; ménage code mort (Qwen) cadré.
| SP | Feature | Statut | Prérequis bloquant |
|----|---------|--------|--------------------|
| **SP-0** | Socle git (merge branche + base propre) | ✅ **fait** (23/06 — FF `1d6efdb1b``d686c3ac2`, local) | — *(parallélisme débloqué)* |
| **SP-1** | **F14/U-B Anchors** (compound) | ✅ **code FAIT, commit `2cabc6cb7` (br. `sp1/anchors-compound`), validé données réelles + persistance** | ré-import **en place** entravé par U-A (import = doublon) → SP-4 |
| SP-2 | F2 Rejeu intelligent (R1→R7) | 🟢 **débloqué** (Q-F2-1 ✅ ; point d'entrée tracé = agent_chat 5004 / SemanticMatcher) | gros chantier R1→R7, à cadrer |
| SP-3 | F8 Exécution native (durcir + sandbox) | 🔴 bloqué | Q-F8-1..4 + vérif sécurité `/execute/instruction` |
| SP-4 | F1/U-A Consolidation fragmentation (T3) | 🟢 **débloqué (Q-F1-1 ✅ signature trajectoire, source=DB Q-F9-1 ✅)** | — (active quand on veut) |
| SP-5 | F6/U-C Mutualisation/fédération | 🟠 décidé, à coder | dépend SP-1 (anchors) |
| SP-Q | F13 Ménage code mort | 🔵 en cours (Qwen, read-only) | — |
| — | F3 F4 F5 F7 F9 F10 F11 F12 | ⚪ backlog priorisé | — |
**Cadrage 3 sous-projets (23/06, 3 agents read-only) — interfaces communes & séquencement** :
- **3 interfaces partagées à poser UNE fois** : (1) **signature de trajectoire** (SP-4 = propriétaire) ; (2) **index embeddings/FAISS partagé** (`core/embeddings/shared_index.py`, consommé par SP-2-R3 **et** la sélection skill↔tâche) ; (3) **guard `machine_id` centralisé** dans `stream_processor.py` (SP-4 ∩ SP-2-R6, ~L3197/3284).
- **Phase 0** (fondation, série, petit) : signature de trajectoire + accès index partagé.
- **Phase 1** (parallèle, WIP=2, faible collision) : **SP-4** (propriétaire `stream_processor.py`) ∥ **compétences** (propriétaire `core/competences/`).
- **Phase 2** : **SP-2 rejeu** (le plus intriqué : touche `stream_processor` *et* FAISS) — après stabilisation Phase 0 + SP-4.
- ⚠️ **Endpoints compétences `verdict`/`promote` EXISTENT déjà** (`api/lea_competences.py`, blueprint `app.py:277`, test `tests/unit/test_lea_competence_verdict_api.py`) → chantier compétences = **auto-déclenchement (hook) + sélection intelligente** seulement.
- 1 **branche par sous-projet**, merge supervisé Dom.
**QG Qwen (23/06, 18:30) — GO avec 4 ajustements** (intégrés) :
1. **Marqueurs de propriété dans `api_stream.py`** (commentaires par range d'endpoints SP-4 vs compétences) — seul point de contact Phase 1, éviter conflits silencieux. Pas de refactor.
2. **Fallback R2/R3 obligatoire** (SP-2) : chaque nouveau chemin de résolution **retombe sur les coords figées** si la cascade intelligente échoue. Le rejeu *enrichit*, ne remplace pas. **Non-négociable démo.**
3. **`machine_id` guard intouché en Phase 1** : le lifting du silo (`stream_processor` ~L3197/3284) est **entièrement Phase 2 / SP-2-R6**. → lève la collision SP-4 ↔ SP-2.
4. **Dead import `ExternalDecisionClient`** (`api_stream.py:L7285`, module absent, inoffensif via try/except) → à nettoyer dans le **ménage code mort** (catégorie C), pas dans SP-4.
---
## Axe central — Rejeu intelligent des actions apprises 🎯
**C'est le cœur produit** (« Léa apprend, comprend, **rejoue en exploitant ce qu'elle a appris** » — pas du record-and-replay). Vérification **runtime du 2026-06-23** (à reconfirmer `fichier:ligne` avant toute modif — méthode projet) :
### État réel de la chaîne apprentissage → rejeu
| Maillon | État | Preuve (à reconfirmer) | Constat |
|---|---|---|---|
| Import auto Shadow → workflow rejouable | ❌ **débranché** | `finalize` `api_stream` ~2430-2466 : enqueue worker VLM, **pas** de conversion/import auto ; `ShadowLearningHook` jamais appelé | Ce qui est appris n'est **pas** rendu rejouable sans geste manuel |
| Rejeu consulte le fonds appris (TargetMemoryStore) | ❌ **débranché** | `build_replay_from_raw_events` (~1841-2200) ne consulte rien ; `TargetResolver.lookup()` (~3263) jamais appelé par `replay-session` | Le rejeu rejoue des **coords/anchors figés**, pas la cible apprise |
| Lecture FAISS / GlobalFAISSIndex au rejeu | ❌ **write-only** | `workflow_replay.py` accepte `faiss_manager` en param mais ne l'utilise pas ; aucun `.search()` au rejeu | Index **écrit, jamais lu** au rejeu (cohérent fédération dormante) |
| verify post-condition (état UI après action) | ⚠️ **absent** | `safety_checks` + pause supervisée OK si mode supervisé (`api_stream` ~4299-4367) ; **pas** de `verify_screen` post-action | Pas de boucle de feedback succès/échec |
| Templating `{{var.field.sub}}` au rejeu | ✅ **marche** | `_resolve_runtime_vars()` `replay_engine` ~2027-2041, appelé ~4293 | Données récupérées (T2A, extract) **réinjectées** en temps réel — **acquis, ne pas refaire** |
| Filtre `machine_id` (cross-session) | ✅ actif = **silo** | `stream_processor` ~3197-3200 et ~3285 | Apprentissage **siloté par poste** ; rejeu direct non filtré |
**Verdict** : le rejeu est aujourd'hui **« brut » (events → coords), pas « intelligent »**. L'apprentissage tourne (ShadowLearning, TargetMemory, FAISS) mais **en silos jamais wirés au rejeu**. Le templating des données récupérées, lui, fonctionne déjà.
### Chaîne cible (ce vers quoi on va)
```
Capture/Shadow → finalize → [R1] import auto en workflow rejouable
→ au rejeu, chaque action résolue par : cascade UI (OCR/template/YOLO/VLM)
+ [R2] fonds appris (TargetMemoryStore) + [R3] FAISS anchors (similarité)
→ [✅] réinjection des données récupérées (templating)
→ [R4] verify post-condition (échec = pause supervisée, pas stop = apprentissage)
→ [R5] le résultat du rejeu réécrit le fonds (boucle)
→ [R6] mutualisation : lever silo machine_id + brancher fédération
```
### Chantiers rejeu (ordonnés, du plus structurant au plus fin)
| ID | Chantier | Dépend de |
|---|---|---|
| **R1** | Brancher l'**import auto** d'une session apprise en workflow rejouable post-finalize | décision « provider Léa runtime » |
| **R2** | Faire **consulter le fonds appris au rejeu** : câbler `TargetResolver.lookup()` / `TargetMemoryStore` dans le chemin `replay-session` (résoudre par cible apprise, pas coords figées) | R1 |
| **R3** | **Lire FAISS au rejeu** : utiliser le `faiss_manager` déjà passé à `workflow_replay.py` comme fallback de résolution par similarité d'anchor | R2 |
| **R4** | **verify post-condition** : vérif état UI après action ; échec → pause supervisée (cf. *failure-is-learning*) | — |
| **R5** | **Boucle d'apprentissage** : succès/échec/correction humaine du rejeu réécrivent le fonds (TargetMemory + FAISS) | R2, R4 |
| **R6** | **Mutualisation** : lever le filtre `machine_id` + brancher la fédération (`GlobalFAISSIndex.search()` jamais appelé) | décisions produit (silo vs fédéré, PII) |
| **R7** | Bugs rejeu résiduels : reprise sur crash (R4 audit), OCR span/centre-de-ligne (R5 audit) | — |
⚠️ **Tout cet axe touche le chemin runtime de la démo.** Méthode imposée : **chirurgie itérative supervisée** — un maillon = un test ≤ 2 min = GO Dom, jamais de batch, démo `Urgence_aiva_demo` intacte à chaque étape. Reconfirmer le wiring au runtime **avant** chaque modif (imports lazy).
---
## Feature — Unification Léa ↔ VWB (anchors) 🔗
**Manque corrigé (signalé Dom 23/06).** Chantier dédié : `PLAN_CHANTIER_UNIFICATION_LEA_VWB_2026-06-17.md`. Cœur = **les anchors**, le pont entre ce que VWB capture au recording et ce que Léa **récupère au rejeu**. Symptôme T3 « Léa ne trouve pas le bloc-notes » = **fragmentation de l'apprentissage**, pas silo.
Cycle de vie cible de l'anchor : capture (VWB recording / Shadow) → persistance (`visual_anchors`) → **propagation au workflow****récupération au rejeu** (résolution visuelle).
| ID | Sous-chantier | État | Risque |
|---|---|---|---|
| **U-B** | **Anchors — propager `anchor_image_base64` aux substeps *compound*** (`b8b963059` n'a fait que les actions simples ; les compound, majoritaires côté Léa, restent `anchor_id=NULL` → « Ancre requise » sans image) **+ ré-importer** les workflows. `learned_workflow_bridge.py` `_convert_compound_substep` ~L279 | **prêt — fix ciblé bloquant** | faible/additif |
| **U-A** | **Consolidation fragmentation** : `workflow_id` = signature stable de trajectoire + create-or-update (fusion + agrégation d'observations) — débloque T3 | design décidé | moyen (touche build/persist démo) |
| **U-C** | **Fonds commun** = **F6** (décidé cross + intra) : lever filtre `machine_id` (`stream_processor` ~L3197/L3284) + brancher fédération anonymisée (`GlobalFAISSIndex.search()` jamais appelé) | décision prise, à coder | moyen-élevé |
| **U-D** | **Asymétrie grounding** : VWB recording = UI-DETR-1 ; replay Léa = cascade OCR/template/VLM → unifier le chemin de résolution | sujet ouvert post-démo | à trancher |
**Lien fort avec F2 (rejeu intelligent)** : la récupération des anchors au rejeu (U-B) est le **substrat de R2/R3** — sans anchors propagés/retrouvés, le rejeu ne peut pas résoudre par la vision et retombe sur des coords figées.
**⚠️ Implication de la décision F6 = cross-clinique** : un pack fédéré anonymisé **n'emporte ni coordonnées ni templates** (PII) → seule la **re-résolution visuelle par anchors/FAISS** permet de le rejouer ailleurs. Donc **F6 cross-clinique entraîne quasi-mécaniquement le principe « rejeu intelligent » (Q-F2-1)** : il devient un prérequis, pas une option. *(Décision induite Q-F14-1 au registre.)*
**Ordre interne** (du chantier) : confirmer provider Léa (Q-F2-2) → **U-B anchors** (gain visuel immédiat, faible risque) → U-A consolidation → U-C fédération.
**Prép SP-1 / U-B (vérif runtime 23/06, read-only)** :
- **Gap confirmé** : `learned_workflow_bridge.py:_convert_compound_substep` (L279-321) ne pose **jamais** `_anchor_image_base64` ; la branche action simple (L226-233) le fait. → substeps compound = `anchor_id NULL`.
- **Source dispo** : dans le JSON core, l'ancre du compound est à `target.context_hints.anchor_image_base64` (pas `target.anchor_image_base64`). `target` (parent) est **déjà passé** à `_convert_compound_substep`.
- **Fix** (additif, ~1 endroit) : dans la boucle compound (L169-187), poser `_anchor_image_base64` (même fallback que simple) sur le **1er substep cliquable** uniquement.
- **Impact DB** : **487/582 steps `anchor_id NULL`** (84 %) ; démo `Urgence_aiva_demo` = **8/18** manquants.
- ⚠️ **Caveat ré-import** : le ré-import lit la source via `_load_core_workflow(workflow_id, machine_id)` dans `data/training/workflows/{machine_id}/`**disponible par workflow** ; la source de la démo n'est pas trouvée par nom → **vérifier par core_workflow_id avant de compter sur le ré-import de la démo**.
- **Test** : ré-importer un workflow compound → un substep cliquable doit afficher son image via `GET /api/v3/anchor/<id>/thumbnail` + `StepNode.tsx`. **Risque** : code = additif (faible) ; **ré-import = étape sensible** (backup `workflows.db` + par workflow + revérifier le replay).
- **FAIT 23/06** : fix commité (`2cabc6cb7`, br. `sp1/anchors-compound`), TDD RED→GREEN, **validé sur données réelles** (source réelle : 2/2 clics compound désormais ancrés). Persistance confirmée : `import_learned_workflow` (L332) `pop("_anchor_image_base64")``save_anchor_image``VisualAnchor` + `step.anchor_id` (même chemin que les actions simples). Backup DB : `instance/workflows.db.bak-sp1-2026-06-23`.
- ⚠️ **Découverte** : `import_learned_workflow` **crée un nouveau workflow** (`generate_id`, L301) — **pas de mise à jour en place**. Donc rafraîchir les anchors d'un workflow **existant** (ex. démo) par ré-import = **doublon** → dépend de **U-A** (create-or-update). Le fix U-B est correct **en avant** (tout nouvel import aura les anchors compound) ; le rafraîchissement des workflows déjà en base est porté par U-A (SP-4, décision Q-F1-1).
---
## Feature — Exécution native agentique (computer-use zéro-shot) 🤖
**Manque recadré (signalé Dom 23/06).** Donner un **objectif en langage naturel** (« ouvre un navigateur et va sur YouTube ») et l'exécuter **sans workflow appris**, par planification + grounding visuel. Complément du rejeu appris (F2).
**⚠️ Constat runtime (vérif 23/06) : ce n'est PAS absent — les briques existent et sont wirées.** Le manque réel = **durcissement + sandbox + validation humaine**.
| ID | Brique | État runtime | Manque |
|---|---|---|---|
| F8.1 | **Boucle ORA** observe→reason(VLM)→act→verify→retry (`core/execution/observe_reason_act.py:run_instruction`) | ✅ **wired** via endpoint `/execute/instruction` (`api_v3/execute.py:2033`) | durcissement |
| F8.2 | **Planner NL→plan** (`agent_v0/server_v1/task_planner.py:understand()` gemma4, mode `_execute_free()`) | ⚠️ présent, **mode « free » peu mûr/peu testé** | maturation + tests |
| F8.3 | **Grounding cascade** OCR→**UI-TARS**→VLM (= F3, partagé) | ✅ wired (`input_handler.py:_grounding_ui_tars`) | — |
| F8.4 | **Sandbox Worker** (VM/Xvfb/VNC + kill-switch + **validation humaine**) | ❌ **absent** — exécution **directe sur l'host**, sans isolation ni pause | **= le vrai manque** (décision CUA P1) |
| F8.5 | **Boucle vers l'apprentissage** : un run natif réussi → capturé comme workflow appris (alimente F1) | ❌ absent | à câbler |
| F8.6 | **Replanification dynamique** si l'écran change radicalement (app crash…) | ❌ absent (ORA linéaire) | à ajouter |
**🔴 Sécurité — vérifié 23/06 (audit read-only)** : `POST /api/v3/execute/instruction` (VWB backend **5002**, `app.py:321` / `execute.py:2033`) lance la boucle ORA qui pilote **directement l'écran X11 de l'host** (pyautogui/xdotool, **pas de sandbox**, pas de pause humaine, pas de kill-switch) — cible de fait la VM Léa affichée. Auth = middleware Basic global (`DASHBOARD_PASSWORD`) **mais loopback exempté** ; sur le DGX clinique 5002 est **atteignable sur le LAN** (401 sans creds / 200 loopback), pas d'expo WAN. → Un acteur du LAN clinique avec les creds partagés (faibles, `Medecin2026!`) **ou tout process local (loopback)** peut déclencher une instruction agentique arbitraire sur l'host. **Mitigation à décider (prod)** : restreindre `/execute/instruction` à loopback / désactiver le mode « free » tant que F8.4 (sandbox) n'existe pas. Tant que F8.4 n'est pas en place, le mode « free » ne doit **PAS** être ouvert au-delà d'un environnement jetable — « JAMAIS l'hôte » + safety agent.
**Articulation avec le mode appris** : routage — pas de workflow appris pour le but → **mode natif** ; sinon **rejeu** (F2). Et le natif réussi **devient** appris (F8.5, la boucle se referme). Décisions → registre F8.
---
## H1 — Stabilisation clinique (jour J → quelques jours, à distance)
| # | Action | Source |
|---|---|---|
| 1 | **Aligner DGX↔local avant débranchement** : git 5 commits behind (`ec1fb81``d686c3ac2`) ; **backup `workflows.db` AVANT** reset | Qwen + tableau B |
| 2 | Ajouter **`RPA_SIGNING_KEY`** dans `.env.local` DGX (HMAC métadonnées FAISS, absent) | KO-1 Qwen |
| 3 | **Vérifier accès Stormshield** depuis laptop = point de non-retour avant départ | MEMO §5 |
| 4 | **Mot de passe** (pas PIN) compte VM `aivanov` → RDP + presse-papier | handoff |
| 5 | Confirmer **profil Stormshield laptop** avec PORQUET (atteint `.178` ?) | handoff |
| 6 | **Apprentissage e2e sur 1 poste TIM réel** avant de généraliser aux 5 | MEMO + audit |
## H2 — Consolidation & dette (semaines suivantes, scp via VPN)
| # | Chantier | Pourquoi |
|---|---|---|
| 7 | **Merger les branches** : `fix/dashboard-complete-installer` non mergée sur `poc-dgx` ; clarifier topologie `poc-dgx``main` | base git propre avant tout ménage |
| 8 | **Ménage code mort** (mission Qwen en cours) → exécution **par lots + QG** | post-stabilisation |
| 9 | **Test de charge multi-agents** (2-3 puis 5 Léa simultanées) | 1 TIM démo ≠ 5 TIM réels |
| 10 | Bugs non-rejeu résiduels de l'audit (watchdog `_retry_pending` A2, écran verrouillé non détecté…) — **re-vérifier lesquels sont encore ouverts** | audit gaps |
## H3 — Produit & fond (après stabilisation, sur décisions Dom)
| # | Chantier | Bloqué par |
|---|---|---|
| 11 | **Chantier B anchors VWB** (propager `anchor_image_base64` aux substeps compound) — fix ciblé, risque faible | rien, prêt |
| 12 | **CUA Sandbox Worker P1** (décision Dom 18/06 : VM/Xvfb/VNC + kill-switch, jamais l'hôte) | priorisation |
| 13 | **Source vérité workflows** (migration JSON→SQLAlchemy, sortir DETTE-015) | post-POC |
| 14 | **Shadow → Copilot → Autonomous** au runtime (aujourd'hui design) | R1-R6 + décisions |
---
## Décisions qui reviennent à Dom (registre dédié)
→ Suivi vivant dans **`DECISIONS_PRODUIT_EN_ATTENTE_2026-06-23.md`** (Dom remplit à tête reposée).
**Déjà tranché (23/06)** :
-**H3 en premier**, décisions avant exécution (séquencement préservé).
-**F6 = mutualisation cross-clinique ET intra-clinique** → fédération anonymisée **dans le périmètre** + lever le silo `machine_id` entre postes.
-**F11 = accès multi-VPN par site** (cf. §0.2).
**Encore ouvert** (bloque l'exécution des chantiers liés) :
1. **Principe « rejeu intelligent »** (R2/R3) — le rejeu *doit* consulter le fonds appris ? Change l'archi du replay.
2. **Provider Léa au runtime** (R1 + résolution).
3. **Critère de fusion des workflows** (create-or-update).
4. **Source de vérité workflows** (DB vs JSON) + **métrique produit** (24/79/37) — *reco Claude consignée : DB = vérité, JSON = échange ; métrique = rejouables validés*.
## Séquencement imposé
**Stabiliser (H1)****base git propre + merge (H2-7)****ménage code mort****axe rejeu R1→R7** (chirurgie supervisée) → **fond produit (H3)**.
On ne dégraisse pas, et on ne recâble pas le rejeu, sur une base mouvante.
---
## Plans sources (référence, ne pas dupliquer)
`MEMO_JOUR_J_LIVRAISON_DGX_CLINIQUE_2026-06-23` · `TABLEAU_ACTIONS_DOM_PRECLINIQUE_2026-06-21` · `PLAN_CHANTIER_UNIFICATION_LEA_VWB_2026-06-17` · `PLAN_ACCES_DISTANT_SSH_CERT_DGX_2026-06-20` (volet WG suspendu) · `PLAN_MIGRATION_WORKFLOWS_STORE_2026-06-09` · `AUDIT_GAPS_APPLI_100PCT_2026-06-10` · `CARTO_APPRENTISSAGE_FONDS_COMMUN_2026-06-16` · `CHECKLIST_DGX_PRE_CLINIQUE` · `INSTALLATION_MULTI_SITE`.

View File

@@ -0,0 +1,92 @@
# Plan de chantier — Unification Léa + VWB (préparé le 2026-06-17 au soir)
> Préparé par analyse multi-agents (3 agents read-only) + graphify, après le check UI post-reboot DGX.
> **Rien n'a été modifié.** Document de cadrage pour la reprise.
> Méthode imposée : chirurgie itérative supervisée (1 modif = 1 test = validation Dom). Pas de batch.
## 0. Diagnostic corrigé (important — deux hypothèses du soir invalidées)
Le check UI a fait remonter 3 symptômes : **T3** (Léa « ne trouve pas » le bloc-notes), **T5/anchors** (images d'ancres absentes au VWB), et le sujet de fond **fonds commun**. L'analyse de code rectifie le diagnostic « à chaud » :
| Hypothèse du soir | Verdict après analyse code |
|---|---|
| T3 = silo machine_id (Léa-VM ne voit pas le savoir du .11) | **FAUX au niveau sélection.** Le `SemanticMatcher` ne filtre par aucune machine ; Léa tourne sur le DGX qui héberge les dossiers des deux postes → elle *voit* déjà tous les workflows. |
| T3 = filtre `is_production_ready` | **FAUX.** Aucun composant runtime (matcher, exécuteur, chat) ne lit `is_production_ready`/`learning_state`. |
| **Vraie cause T3** | **Fragmentation de l'apprentissage.** 100 répétitions → ~20 workflows distincts nommés d'après les apps vues (« Bloc-notes, Explorateur et Terminal (2)(3)… »), aucun nommé franchement « ouvrir le bloc-notes » → le matching sémantique se dilue sur 20 quasi-doublons. (Le silo existe, mais au niveau *renforcement cross-machine*, pas sélection.) |
---
## Chantier A — Consolidation de l'apprentissage (débloque T3) — PRIORITÉ HAUTE
### Cause racine
Chaque session de streaming **crée** un workflow neuf, **sans jamais chercher ni fusionner** un existant.
- Nommage + suffixe `(2)(3)` : `agent_v0/server_v1/stream_processor.py:4335-4344` (`_generate_workflow_name`) — collision de nom → variante numérotée au lieu de renforcement.
- Persistance directe sans dédup : `stream_processor.py:4417-4445` (`_persist_workflow`), build `:2966-3112` (`_build_workflow_from_session`).
- 1 observation / node : build **toujours séquentiel** (`graph_builder.py:345` `clusters={i:[i]}`), donc `observation_count = 1` (`graph_builder.py:909`). DBSCAN d'agrégation volontairement désactivé.
- **Aucune fonction de merge/dédup de workflows** dans tout `core/`. Le `VariantManager` (`core/variants/variant_manager.py:266`, seul code qui fait `observation_count += 1`) **n'est jamais appelé**.
- `is_production_ready` calculé dans `core/training/quality_validator.py:114,238-246` (seuil `min_observations_per_node=3`) — toujours False car 1 obs/node, **mais sans effet runtime** (label informatif uniquement).
### Leviers (effort / risque / impact)
- **A. Découpler « exécutable » de « production_ready »** — faible / faible / moyen — quick-win sémantique.
- **B. Fusion/dédup create-or-update à la persistance** — élevé / moyen / **fort** — cause racine.
- **C. Rebrancher l'agrégation des observations** (`VariantManager` ou `_run_cross_session_learning` qui réécrit `observation_count`) — moyen / moyen / fort.
- **D. Seuil `min_observations` configurable/contextuel** — faible / moyen / faible — cosmétique seul.
- **E. `workflow_id` = signature stable de la trajectoire** (hash de la séquence d'actions) au lieu des apps vues — moyen / moyen / fort — supprime le `(2)(3)` à la racine, rend B trivial.
**Reco** : **E + B** (signature stable + create-or-update avec agrégation d'observations), D en complément, A en quick-win si on veut juste « rendre exécutable » vite.
### Composants
`stream_processor.py` (L2966-3112, L4335-4344, L4417-4445, cross-session L3149-3268, list L4518) · `graph_builder.py` (L345, L909, L384-456) · `quality_validator.py` (L114, L238) · `semantic_matcher.py` (sélection) · `variant_manager.py` (L266, à rebrancher) · `replay_learner.py:358` (consolidate = hints seulement, leurre).
---
## Chantier B — Affichage anchors VWB (débloque T5) — FIX PRÉCIS, FAIBLE RISQUE
### Cause racine
Le commit `b8b963059` n'a corrigé **que la moitié** : l'import lit `target.context_hints.anchor_image_base64` **uniquement pour les actions simples**.
- `services/learned_workflow_bridge.py` : branche action simple `else` L226-233 (lit le base64, ajouté par le commit) ; **les actions *compound*** (majoritaires dans les workflows Léa) passent par `_convert_compound_substep` L279 qui **ne lit jamais le base64** → substeps `anchor_id=NULL` → frontend affiche « Ancre requise » sans image (`frontend_v4/.../StepNode.tsx:113-119`).
- Aggravant état DGX : les workflows en base datent d'avril, **100% des steps `anchor_id=NULL`** → ré-import nécessaire après fix.
### Chaîne (pour mémoire)
import `api_v3/learned_workflows.py:249` → convert `learned_workflow_bridge.py:72``save_anchor_image` → table `visual_anchors` (`db/models.py:163`) → API `GET /api/v3/anchor/<id>/thumbnail` (`api_v3/capture.py:356`) → React `StepNode.tsx` (`api.ts:136`). Pas de mismatch URL/chemin.
### Fix
Propager `anchor_image_base64` aux substeps compound (passer `target` dans la boucle compound L169-187 / `_convert_compound_substep`, poser l'ancre sur le 1er substep cliquable — éviter de dupliquer sur N substeps). Risque faible/additif. **Puis ré-importer** les workflows cibles.
---
## Chantier C — Fonds commun / mutualisation cross-poste — STRATÉGIQUE, DÉCISION PRODUIT D'ABORD
### État
- Fédération `core/federation/` **entièrement débranchée au runtime** : `GlobalFAISSIndex.search()` (`faiss_global.py:199`) **jamais appelé** ; endpoints export/import (`api_stream.py:6277-6372`) sans aucun déclencheur (ni cron, ni frontend).
- **Anonymisation = le maillon le plus mûr et prêt** : `learning_pack.py` exclut machine_id/hostname/patient/nip/ipp (`_clean_metadata` L410, `_SENSITIVE_METADATA_KEYS` L61-66), hash SHA-256 (L388), export = embeddings 512d + signatures (pas de pixels/OCR brut).
- Silo réel = `stream_processor._run_cross_session_learning` L3197/L3284 (`workflow_machine != machine_id → continue`) : bloque le renforcement cross-machine.
- Identité : `machine_id` workflows = `hostname_os` (ex `DESKTOP-58D5CAC_windows`, `agent_v1/config.py:34-37`) ≠ token enrôlement `cbd8f9f0…` (`agent_registry.py:107-111`, sécurité parc). À ne pas confondre.
### Options
- **Intra-clinique brut** (lever filtre cross-session L3197/L3284, charger toutes machines) — simple, **non anonymisé** → acceptable seulement intra-site.
- **Cross-clinique anonymisé** (brancher `search()` global + déclencheur export/import + index global peuplé) — la vraie « fédération », effort moyen-élevé, **seul canal sûr PII**.
---
## Décisions produit à trancher AVANT de coder (pour Dom)
1. **Quel point d'entrée Léa au runtime ?** Deux providers concurrents : `agent_chat/app.py:678,906` (port 5004, SemanticMatcher sur `data/training/workflows/`) vs chat serveur `api_stream.py:6623` (`_list_available_workflows` qui liste des **sessions live**, pas les workflows entraînés). Les logs DGX du soir montrent le 5004 + SemanticMatcher → **probablement 5004**, à confirmer formellement avant de coder A.
2. **Critère de « même parcours »** pour la fusion (levier E/B) : signature de trajectoire ? nom de base ? (workflows de 7 à 89 nodes pour « le même » parcours → alignement non trivial).
3. **Source de vérité workflows** (DETTE-015) : DB VWB SQLite (5002) vs JSON `data/training/workflows/` — la route `/api/workflows` de Léa fusionne les deux avec une dédup fragile. Trancher la source canonique.
4. **Niveau de mutualisation** : intra-clinique brut (rapide, non anonymisé) vs fonds commun cross-clinique anonymisé (fédération). Implications réglementaires opposées.
5. **Re-exécutabilité des packs fédérés** : l'export n'emporte que des squelettes anonymisés (pas les templates/coordonnées) → un chemin de re-résolution visuelle est nécessaire (cohérent avec le contrat 100% vision).
---
## Ordre recommandé pour la reprise
1. **Confirmer le provider runtime de Léa** (Q1) — 10 min, read-only, débloque tout le reste.
2. **Chantier B (anchors)** — fix ciblé compound + ré-import. Faible risque, gain visuel immédiat (T5).
3. **Chantier A (consolidation)** — E + B, chirurgie itérative. Débloque T3 (le bloc-notes « connu »).
4. **Chantier C (fonds commun)** — décision produit (Q4) puis implémentation anonymisée. Le plus stratégique pour la proposition de valeur, mais le moins urgent pour une démo.
## Garde-fous
- Tout changement au build/persist (Chantier A) touche le chemin qui alimente la démo Urgence → chirurgie itérative, 1 test par modif.
- Champ de mines `core/` : vérifier le wiring runtime réel avant de rebrancher (`VariantManager`), pas seulement la présence du code.
- Mutualisation cross-site = uniquement via `LearningPack` anonymisé, jamais recopie de JSON bruts (PII : OCR, titres fenêtres patients).

View File

@@ -0,0 +1,474 @@
# D1 — NavigateCoords Implementation Plan
**Auteur**: Qwen
**Date**: 2026-07-02
**Statut**: EN ATTENTE GO Dom/Claude — Option 1 vs Option 2
**Référence**: `docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md` (3 gaps documentés)
---
## Résumé des gaps à résoudre
| Gap | Description | Fichier:Ligne | Preuve |
|-----|-------------|---------------|--------|
| A | Compiler bake floats littéraux — aucun template pour coords | `replay_engine.py:1821-1833` | `x_pct = px` (literal float) |
| B | Zéro consommateur de `navigate_*_coords` variables | `replay_engine.py` + `api_stream.py` | grep: 0 occurrences |
| C | `_edge_to_normalized_actions` pas de branche `navigate``[]` | `replay_engine.py:1951-1953` | `else: return []` |
---
## Infrastructure existante (non-modifiée)
### `_ALLOWED_ACTION_TYPES` (replay_engine.py:35-50)
`"navigate"` est **déjà présent** (ligne 44). La validation de sécurité l'accepte déjà.
### `_SERVER_SIDE_ACTION_TYPES` (replay_engine.py:55-64)
`"navigate"` est **déjà présent** (ligne 59). Le dispatch loop le traite comme serveur-side.
### `_handle_navigate_action` (core/navigation/__init__.py:24-113)
Handler **déjà câblé** dans api_stream.py (ligne 4459-4467). Résout screenshot, OCR/VLM, stocke coords dans `replay_state["variables"]`.
### `_resolve_runtime_vars` (replay_engine.py:2031-2045)
Resolver **existant** pour `{{var.field}}` — récursif sur dict/list/str. Retourne `str(value)` au niveau leaf → float→string conversion nécessaire pour coords.
---
## OPTION 1 — Compiler Injection (~2h)
### Principe
Ajouter une branche `navigate` dans `_edge_to_normalized_actions` + ajouter `coords_var` mechanism dans les branches `mouse_click`/`text_input` + runtime resolution + float conversion.
### Patch P1-A : Branche navigate dans `_edge_to_normalized_actions`
**Fichier**: `agent_v0/server_v1/replay_engine.py`
**Position**: Après `elif action_type == "llm_generate":` (ligne 1949), avant `else:` (ligne 1951)
```python
elif action_type == "navigate":
normalized["type"] = "navigate"
normalized["parameters"] = {
"login_field": action_params.get("login_field", "login"),
"password_field": action_params.get("password_field", "password"),
"submit_button": action_params.get("submit_button", "submit"),
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
}
return [normalized]
```
**Justification**: Action serveur-side — pas besoin de `x_pct/y_pct` ni `target_spec`. Le handler `_handle_navigate_action` lit `parameters` pour config, résout coords au runtime.
**Impact**: Gap C résolu. Navigate edge → 1 normalized action au lieu de `[]`.
### Patch P1-B : coords_var dans branches mouse_click / text_input
**Fichier**: `agent_v0/server_v1/replay_engine.py`
**Position**: Lignes 1844-1856 (branches click et type)
**mouse_click** (ligne 1844-1848) — AVANT :
```python
if action_type == "mouse_click":
normalized["type"] = "click"
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
normalized["button"] = action_params.get("button", "left")
```
**mouse_click** — APRES :
```python
if action_type == "mouse_click":
normalized["type"] = "click"
coords_var = action_params.get("coords_var")
if coords_var:
normalized["x_pct"] = f"{{{{{coords_var}.x_pct}}}}"
normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["coords_var"] = coords_var
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
normalized["button"] = action_params.get("button", "left")
```
**text_input** (ligne 1850-1856) — AVANT :
```python
elif action_type == "text_input":
normalized["type"] = "type"
text = action_params.get("text", "")
text = _substitute_variables(text, params, action_params.get("defaults", {}))
normalized["text"] = text
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
```
**text_input** — APRES :
```python
elif action_type == "text_input":
normalized["type"] = "type"
text = action_params.get("text", "")
text = _substitute_variables(text, params, action_params.get("defaults", {}))
normalized["text"] = text
coords_var = action_params.get("coords_var")
if coords_var:
normalized["x_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["coords_var"] = coords_var
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
```
**⚠️ BUG dans le draft ci-dessus**: `x_pct` template pour text_input doit être `{{coords_var.x_pct}}` (pas `.y_pct` deux fois). Version corrigée :
```python
elif action_type == "text_input":
normalized["type"] = "type"
text = action_params.get("text", "")
text = _substitute_variables(text, params, action_params.get("defaults", {}))
normalized["text"] = text
coords_var = action_params.get("coords_var")
if coords_var:
normalized["x_pct"] = f"{{{{{coords_var}.x_pct}}}}"
normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["coords_var"] = coords_var
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
```
**Justification**: `coords_var` = mécanisme minimal pour déclarer "ces coords viennent de la variable navigate_login_coords". Template strings résolus au runtime par `_resolve_runtime_vars`.
**Impact**: Gap A résolu. Gap B partiellement — les actions click/type deviennent consommatrices via `coords_var`.
### Patch P1-C : Coercion helper après resolver existant
**⚠️ CORRECTION IMPORTANT (2026-07-02 14:45)** : Le plan original sur-dimensionnait P1-C en proposant un second resolver runtime. **Codex a correctement identifié** que `_resolve_runtime_vars` est **déjà appelé** dans la boucle dispatch à `api_stream.py:4331-4335` :
```python
# L4331-4335 (EXISTANT, ne pas modifier)
if owning_replay is not None:
runtime_vars = owning_replay.get("variables") or {}
if runtime_vars:
action = _resolve_runtime_vars(action, runtime_vars)
```
**Besoin réel = coercion helper uniquement** : `_resolve_runtime_vars` résout les templates `{{var.field}}` mais retourne `str(value)` au leaf → `{{navigate_login_coords.x_pct}}` devient `"0.15"` (string), pas `0.15` (float). Le client attend des floats pour x_pct/y_pct.
**Fichier**: `agent_v0/server_v1/api_stream.py`
**Position**: Juste après la ligne 4335 (`action = _resolve_runtime_vars(action, runtime_vars)`)
**Politique coords_var non résolu** : Skip + pause supervisée (AGREED Qwen/Codex). Jamais fallback 0.0/0.0 — un clic sur coords (0,0) = top-left = potentiellement dangereux.
```python
def _coerce_action_coords(action: dict) -> dict:
"""Cast x_pct/y_pct en float après template resolution par _resolve_runtime_vars.
Politique : si string non convertible ou template encore present → skip + pause_for_human.
Idempotent sur les actions qui ont déjà des floats (mouse_click existant).
Appelé APRÈS _resolve_runtime_vars dans la boucle dispatch (api_stream.py ~L4335).
"""
for key in ("x_pct", "y_pct"):
val = action.get(key)
if val is None:
continue
if isinstance(val, float):
continue # déjà float, idempotent
if isinstance(val, str):
# Template encore présent = non résolu par _resolve_runtime_vars
if val.startswith("{{") and val.endswith("}}"):
action["_skip_reason"] = f"coords_var non résolu: {key}={val}"
action["type"] = "pause_for_human"
action["safety_level"] = "high"
return action
try:
action[key] = float(val)
except (ValueError, TypeError):
action["_skip_reason"] = f"coords invalide: {key}={val}"
action["type"] = "pause_for_human"
action["safety_level"] = "high"
return action
return action
```
**Appel dans la boucle dispatch** (à insérer après L4335) :
```python
# L4335 existant: action = _resolve_runtime_vars(action, runtime_vars)
# NOUVEAU — coercion coords après resolver existant
action = _coerce_action_coords(action)
```
**Justification**: `_resolve_runtime_vars` (existant à L4335) résout les templates → strings. `_coerce_action_coords` cast les strings en floats. Si template non résolu ou conversion impossible → pause_for_human (fail-safe), jamais fallback coords (0,0). Idempotent sur actions existantes (floats déjà présents).
**Risques additionnels identifiés** :
1. **Résolution partielle** : si seul y_pct est résolu mais x_pct reste template → `_coerce_action_coords` convertit pause_for_human (safe stop, pas top-left click).
2. **Idempotence** : si action existante a déjà x_pct=0.35 (float) → helper passe sans modification (isinstance(float) → continue).
3. **Race condition** : variables dict partagé entre navigate handler et dispatch loop — mais BFS séquentiel garantit que navigate stocke AVANT click consomme.
**Impact**: Gap B résolu — les coords navigate sont consommées au runtime par click/type, avec coercion + fail-safe.
### Patch P1-D : VWB YAML schema — coords_var field
**Fichier**: Schema VWB (workflow YAML format) — documentation
**Nature**: Ajout d'un champ `coords_var` dans `action.parameters` pour les steps `mouse_click` et `text_input`
Exemple de workflow YAML avec navigate + click consommateur :
```yaml
steps:
- id: s1
action:
type: navigate
parameters:
login_coords_var: navigate_login_coords
password_coords_var: navigate_password_coords
to_node: n2
- id: s2
action:
type: mouse_click
parameters:
coords_var: navigate_login_coords
button: left
to_node: n3
- id: s3
action:
type: text_input
parameters:
coords_var: navigate_password_coords
text: "${password}"
to_node: n4
```
**Justification**: Le VWB builder doit savoir qu'un click peut référencer une variable coords au lieu de fournir des pixels littéraux. C'est un changement de schema minimal (1 champ optionnel).
---
## OPTION 2 — Declarative YAML Templates (~4h)
### Principe
Introduire un `coords_template` field dans les step definitions + un resolver typed qui extrait directement les floats du dict variables sans passage string→float.
### Patch P2-A : Même branche navigate (identique à P1-A)
Inchangé — Gap C résolu par la même branche.
### Patch P2-B : coords_template field + typed resolver
**Fichier**: `agent_v0/server_v1/replay_engine.py`
Nouvelle fonction `_resolve_coords_template` :
```python
def _resolve_coords_template(
coords_template: str,
variables: Dict[str, Any],
) -> Optional[Dict[str, float]]:
"""Résoudre un coords_template en dict {x_pct, y_pct, bbox_pct} depuis variables.
Retourne None si la variable n'existe pas ou si les champs ne sont pas floats.
Pas de conversion string→float : les valeurs doivent déjà être des floats.
"""
coords_dict = variables.get(coords_template)
if not coords_dict or not isinstance(coords_dict, dict):
return None
x_pct = coords_dict.get("x_pct")
y_pct = coords_dict.get("y_pct")
if not isinstance(x_pct, (int, float)) or not isinstance(y_pct, (int, float)):
logger.warning(
f"coords_template {coords_template}: x_pct/y_pct not numeric "
f"(x_pct={x_pct}, y_pct={y_pct})"
)
return None
result = {"x_pct": float(x_pct), "y_pct": float(y_pct)}
bbox_pct = coords_dict.get("bbox_pct")
if bbox_pct:
result["bbox_pct"] = bbox_pct # tuple, pas de conversion
return result
```
### Patch P2-C : Branches mouse_click / text_input avec coords_template
```python
if action_type == "mouse_click":
normalized["type"] = "click"
coords_template = action_params.get("coords_template")
if coords_template:
normalized["coords_template"] = coords_template
# x_pct/y_pct résolus au runtime par _resolve_coords_template
normalized["x_pct"] = None # placeholder → resolved at runtime
normalized["y_pct"] = None
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
normalized["button"] = action_params.get("button", "left")
```
### Patch P2-D : Runtime resolution typed dans dispatch loop
```python
# --- Résolution coords_template (typed, no string→float) ---
if action.get("coords_template"):
variables = owning_replay.replay_state.get("variables", {})
from agent_v0.server_v1.replay_engine import _resolve_coords_template
coords = _resolve_coords_template(action["coords_template"], variables)
if coords:
action["x_pct"] = coords["x_pct"]
action["y_pct"] = coords["y_pct"]
if coords.get("bbox_pct"):
action["bbox_pct"] = coords["bbox_pct"]
del action["coords_template"] # résolu, pas besoin de garder le ref
else:
logger.warning(
f"coords_template {action['coords_template']} unresolved — skipping action"
)
# skip → next action
```
**Avantage Option 2**: Pas de string→float conversion. Les coords restent des floats du navigate handler au click handler. Plus clean, plus safe.
**Inconvénient Option 2**: `_resolve_coords_template` est une nouvelle fonction + le `x_pct = None` placeholder nécessite que le client tolère les None temporairement (ou que la resolution se fasse AVANT transmission). Le schema VWB doit documenter `coords_template` comme champ alternatif à `by_position`.
---
## Comparative Table — Patches
| Aspect | Option 1 (Compiler Injection) | Option 2 (YAML Templates) |
|--------|-------------------------------|---------------------------|
| **Gap C fix** | Identique (branche navigate) | Identique (branche navigate) |
| **Gap A fix** | Template strings `{{var.field}}` dans x_pct/y_pct | `x_pct = None` placeholder + typed resolver |
| **Gap B fix** | `_resolve_runtime_vars` + float conversion | `_resolve_coords_template` typed (no conversion) |
| **String→float** | Nécessaire (design smell) | Aucun (floats passent directement) |
| **Nouvelles fonctions** | 0 (reuse `_resolve_runtime_vars`) | 1 (`_resolve_coords_template`) |
| **Schema VWB** | 1 champ `coords_var` | 1 champ `coords_template` |
| **Temps implémentation** | ~2h | ~4h |
| **Extensibilité** | Limitée (coupling navigate→click) | Extensible (any coords source) |
| **Risque POC** | Minimal | Moyen (placeholder None + typed resolver) |
| **Migration post-POC** | Option 2 refactor needed | Already Option 2 |
---
## Test Rouge Proposal
### Test TR-1 : Prouve Gap C (navigate → [])
```python
def test_edge_to_normalized_actionsnavigate_returns_empty():
"""Gap C: _edge_to_normalized_actions retourne [] pour navigate type."""
from agent_v0.server_v1.replay_engine import _edge_to_normalized_actions
edge = WorkflowEdge(
edge_id="e1",
from_node="n1",
to_node="n2",
action=ActionSpec(
type="navigate",
parameters={"login_coords_var": "navigate_login_coords"},
),
)
result = _edge_to_normalized_actions(edge, {})
# BEFORE fix: result == [] (Gap C)
# AFTER fix: result == [{"type": "navigate", "parameters": {...}}]
assert len(result) >= 1, "navigate must produce at least 1 normalized action"
assert result[0]["type"] == "navigate"
```
### Test TR-2 : Prouve coords_var resolution (Option 1)
```python
def test_coords_var_runtime_resolution():
"""Option 1: coords_var template resolved + float conversion."""
from agent_v0.server_v1.replay_engine import _resolve_runtime_vars
variables = {
"navigate_login_coords": {
"x_pct": 0.15,
"y_pct": 0.35,
"method": "ocr+vlm",
}
}
action = {
"type": "click",
"x_pct": "{{navigate_login_coords.x_pct}}",
"y_pct": "{{navigate_login_coords.y_pct}}",
"coords_var": "navigate_login_coords",
}
resolved = _resolve_runtime_vars(action, variables)
# resolved["x_pct"] == "0.15" (string) → needs float conversion
assert resolved["x_pct"] == "0.15" # string from resolver
assert float(resolved["x_pct"]) == 0.15 # conversion works
```
### Test TR-3 : Prouve coords_template typed resolution (Option 2)
```python
def test_coords_template_typed_resolution():
"""Option 2: coords_template returns floats directly, no conversion."""
from agent_v0.server_v1.replay_engine import _resolve_coords_template
variables = {
"navigate_login_coords": {
"x_pct": 0.15,
"y_pct": 0.35,
"method": "ocr+vlm",
}
}
coords = _resolve_coords_template("navigate_login_coords", variables)
assert coords is not None
assert isinstance(coords["x_pct"], float) # float, not string
assert coords["x_pct"] == 0.15
assert coords["y_pct"] == 0.35
```
---
## BFS Ordonnancement — Risque scheduling
Le dispatch loop (`api_stream.py:get_next_action`) traite les actions séquentiellement par path BFS. Navigate est serveur-side → traité en boucle interne avant transmission. Click/type consommant coords_var/template sont visuels → transmis au client.
**Flows correct**:
1. BFS traverse edge navigate → normalized action `type=navigate`
2. Loop interne: `_handle_navigate_action` → stocke coords dans variables
3. BFS traverse edge click → normalized action avec `coords_var`
4. Loop: resolution runtime → float conversion → transmission client
**Risque**: Si le BFS ordonnance le click AVANT le navigate (par ex. edges parallèles), coords_var sera unresolved → fallback 0.0/0.0.
**Mitigation**: VWB builder doit garantir que navigate edge précède click consommateur dans le path topologique. C'est une contrainte de schema, pas un bug runtime.
---
## Decision Matrix
| Critère | Option 1 | Option 2 | Recommandation POC |
|---------|----------|----------|--------------------|
| Temps | 2h | 4h | **Option 1** |
| Risque runtime | string→float edge | None placeholder | Option 1 (conversion simple) |
| Extensibilité | Limitée | Extensible | Option 1 pour POC, migration Option 2 post-POC |
| Code mort risk | 0 nouvelles fonctions | 1 nouvelle fonction | Option 1 |
| Test coverage | TR-1 + TR-2 | TR-1 + TR-3 | Option 1 |
**Recommandation Qwen**: Option 1 pour POC (2h, minimal risk, reuse infrastructure existante). Migration Option 2 post-POC si scaling multi-coords est confirmé (search, dossier).
**GO requis**: Dom + Claude (décision D1).
---
*Qwen — plan implémentation D1 déposé, awaiting GO.*

View File

@@ -0,0 +1,98 @@
---
name: plan-deploiement-navigation-2026-07-01
description: Plan déploiement + vérification pour wiring navigate — diff, smoke, rollback. Commit = GO Dom supervisé. v2 corrigée après revue croisée Claude.
type: project
---
# Plan de déploiement — Wiring navigate (brique navigation serveur)
- Date : 2026-07-01 23:50 → **v2 2026-07-02 11:00** (corrections après revue croisée Claude)
- Branche : `feat/push-log-dgx`
- Commit = **GO Dom supervisé** (serveur DGX clinique live)
## Fichiers modifiés (3 hotspots + 4 modules navigation + 6 fichiers tests)
| Fichier | Changement | Lignes |
|---------|-----------|--------|
| `agent_v0/server_v1/api_stream.py` | +1 import `from core.navigation import _handle_navigate_action` + +1 dispatch `elif type_ == "navigate"` (asyncio.wait_for, timeout=180s) | +10 |
| `agent_v0/server_v1/replay_engine.py` | +1 `"navigate"` dans `_ALLOWED_ACTION_TYPES` + +1 `"navigate"` dans `_SERVER_SIDE_ACTION_TYPES` | +2 |
| `core/navigation/__init__.py` | Nouveau : handler `_handle_navigate_action` + exports `__all__` | +115 |
| `core/navigation/visual_verifier.py` | Nouveau : OCR-ancré verify_before/after, fuzzy match | +408 |
| `core/navigation/grounding.py` | Nouveau : OCR-anchor-first + VLM fallback + coords cache | +375 |
| `core/navigation/visual_login.py` | Nouveau : DPI urgences login, verify + resolve | +227 |
| `core/navigation/action_resolver.py` | Nouveau : coords normalisés, OCR adapters | +205 |
**Tests ajoutés (6 fichiers, 131 tests) :**
| Fichier | Tests | Rôle |
|---------|-------|------|
| `tests/unit/test_visual_verifier.py` | 48 | OCR-ancré, fuzzy match, verify_before/after |
| `tests/unit/test_grounding.py` | 39 | OCR-anchor, VLM fallback, coords cache |
| `tests/unit/test_visual_login.py` | 17 | DPI urgences login, verify + resolve |
| `tests/unit/test_action_resolver.py` | 14 | coords normalisés, OCR adapters |
| `tests/unit/test_navigate_wiring.py` | 7 | Boot non-régression (import, allowed types, handler callable) |
| `tests/unit/test_navigate_handler_e2e.py` | 6 | E2e mocké (nominal, OCR miss, no screenshot, never-fail) |
## Smoke commands post-commit (à exécuter sur DGX après deploy)
```bash
# 1. Boot serveur streaming — pas d'ImportError
RPA_AUTH_DISABLED=true python -c "from agent_v0.server_v1 import api_stream; print('api_stream OK')"
# 2. Types autorisés — navigate présent
RPA_AUTH_DISABLED=true python -c "from agent_v0.server_v1.replay_engine import _ALLOWED_ACTION_TYPES, _SERVER_SIDE_ACTION_TYPES; print('navigate in ALLOWED:', 'navigate' in _ALLOWED_ACTION_TYPES); print('navigate in SERVER_SIDE:', 'navigate' in _SERVER_SIDE_ACTION_TYPES)"
# 3. Handler callable
RPA_AUTH_DISABLED=true python -c "from core.navigation import _handle_navigate_action; print('handler callable:', callable(_handle_navigate_action))"
# 4. Tests non-regression (navigation + wiring)
RPA_AUTH_DISABLED=true python -m pytest tests/unit/ -k "navigat or visual_verifier or grounding or visual_login or action_resolver or wiring or e2e" -v --tb=short
# 5. Service rpa-streaming actif + health endpoint
systemctl is-active rpa-streaming.service
curl -s http://localhost:5005/health | python3 -m json.tool
```
## Critères de rollback (si smoke échoue)
| Critère | Action |
|---------|--------|
| `ImportError` sur `api_stream` | Rollback git — `git revert --no-edit <SHA_PRE_DEPLOY>..HEAD` |
| `"navigate"` absent des `_ALLOWED`/`_SERVER_SIDE` | Rollback — `git revert --no-edit <SHA_PRE_DEPLOY>..HEAD` |
| Handler non callable | Rollback — `git revert --no-edit <SHA_PRE_DEPLOY>..HEAD` |
| Tests wiring/e2e FAIL | Ne pas deploy — investiguer avant |
| Serveur 5005 ne boote pas | `systemctl restart rpa-streaming` + vérifier logs |
## Données à préserver sur DGX (ne pas écraser)
- `visual_workflow_builder/backend/instance/workflows.db`**TRACKÉ par git** (modifié dans working tree). `git reset --hard` l'écraserait → **INTERDIT**. Backup obligatoire avant deploy.
- `.env.local` — creds clinique (DASHBOARD_PASSWORD, RPA_VLM_MODEL)
## Procédure de rollback rapide
```bash
# Étape 0 : NOTER SHA_PRE_DEPLOY avant le merge
cd /home/aivanov/ai/rpa_vision_v3
SHA_PRE_DEPLOY=$(git rev-parse HEAD) # ← noter ce SHA avant git pull/merge
# Backup workflows.db (tracké par git — ne jamais reset --hard)
cp visual_workflow_builder/backend/instance/workflows.db /tmp/workflows.db.backup
# Rollback : revert vers SHA pré-deploy
git revert --no-edit <SHA_PRE_DEPLOY>..HEAD # annule tout ce qui est arrivé APRÈS le SHA noté (pas HEAD~1 — core/navigation/__init__.py est nouveau, checkout échouerait)
systemctl restart rpa-streaming rpa-vision-v3-api
# Restaurer workflows.db runtime si besoin
cp /tmp/workflows.db.backup visual_workflow_builder/backend/instance/workflows.db
```
> **⚠️ INTERDICTION** : `git reset --hard` est **INTERDIT** sur DGX — `workflows.db` est tracké par git et modifié dans le working tree. Un reset hard écraserait les données runtime.
## Statut
- Build+TDD : **FAIT** (131 tests verts, 0 régression)
- Plan déploiement : **FAIT v2** (4 corrections revue croisée Claude appliquées : service name, health URL, DGX path, rollback command + workflows.db tracking)
- Commit : **EN ATTENTE GO Dom** (demain matin)
- Deploy DGX : **EN ATTENTE GO Dom** (supervisé, serveur clinique live)
— Qwen

View File

@@ -0,0 +1,263 @@
# Plan de ménage code mort — 2026-06-23
- `Auteur`: Qwen (audit read-only)
- `Date`: 2026-06-23
- `Statut`: plan — **aucune exécution sans GO Dom**
- `Méthode`: existing-first (graphify-out/ + grep imports), vérification wiring runtime
---
## Synthèse
| Zone | Fichiers Python | A (WIRED) | B (ORPHELIN) | C (MORT) | Lignes C estimées |
|------|----------------|-----------|-------------|----------|-------------------|
| core/ | 226 | ~70 (31%) | ~75 (33%) | ~22 (10%) | ~800 |
| agent_v0/server_v1/ | 26 | 21 | 1 | 4 | ~510 |
| agent_v0/agent_v1/ | 44 | 36 | 2 | 6 | ~290 |
| server/ | 8 | 4 | 2 | 2 | ~300 |
| scripts/ | ~40 | 3 | 0 | ~37 | ~2500 |
| deploy/ | 10+ | 3 | 5 | 2 | ~100 |
| root | 4 | 1 | 2 | 1 | ~80 |
| **Total** | ~360 | ~138 | ~84 | ~74 | **~4580** |
**~20% du codebase est MORT confirmé (catégorie C), ~23% est ORPHELIN/projection (B).**
Les zones les plus chargées : `core/analytics/` (13/17 orphelins), `core/cognition/` (4/5 morts), `core/extraction/` (4/5 morts), `scripts/` (37/40 morts).
---
## NE PAS TOUCHER (runtime démo + systemd DGX + installateur)
- `agent_v0/server_v1/api_stream.py` (7747 lignes) — serveur principal runtime
- `agent_v0/server_v1/stream_processor.py` (6085 lignes) — orchestrateur central
- `agent_v0/server_v1/resolve_engine.py` — résolution anchors OCR/VLM
- `agent_v0/server_v1/replay_engine.py` — replay actions
- `agent_v0/agent_v1/core/executor.py` — exécuteur agent Windows
- `visual_workflow_builder/backend/app.py` — VWB backend
- `web_dashboard/app.py` — dashboard
- `server/api_upload.py` — upload API (loopback DGX)
- Tous les services systemd DGX (rpa-vision-v3-*.service, ollama, rpa-vllm-grounder)
- `deploy/installer/` (Lea.iss, config_template.txt, build_installer.sh)
- `deploy/lea_package/` (config.txt, requirements_agent.txt)
- Chemin démo `Urgence_aiva_demo` end-to-end
---
## Inventaire par zone — catégorie C (MORT confirmé)
### core/ — MORT (22 fichiers, ~800 lignes)
| Fichier | Lignes | Preuve mort | Doublon de ? |
|---------|--------|-------------|-------------|
| `cognition/precondition.py` | ~50 | Zero imports | — |
| `cognition/scene_expected.py` | ~50 | Zero imports | — |
| `cognition/trace.py` | ~80 | Zero imports | — |
| `cognition/vram_orchestrator.py` | ~100 | Zero imports (sauf __init__ test) | gpu/device_policy.py |
| `extraction/data_store.py` | ~80 | Zero imports | — |
| `extraction/extraction_engine.py` | ~120 | Zero imports (sauf __init__ try/except) | field_extractor.py |
| `extraction/iteration_controller.py` | ~60 | Zero imports | — |
| `extraction/schema.py` | ~40 | Zero imports | — |
| `execution/spatial_index.py` | ~60 | Zero imports | — |
| `execution/target_memory.py` | ~80 | Zero imports | learning/target_memory_store.py |
| `execution/workflow_runner.py` | ~200 | Zero imports (sauf __init__.py) | dag_executor.py |
| `interfaces/action_executor_interface.py` | ~40 | Tests only | — |
| `interfaces/error_handler_interface.py` | ~30 | Tests only | — |
| `interfaces/target_resolver_interface.py` | ~30 | Tests only | — |
| `graph/simple_state.py` | ~40 | Zero imports | — |
| `grounding/server.py` | ~500 | **SUPPRIMÉ** (n'existe plus) | — |
| `detection/seeclick_adapter.py` | ~300 | Zero runtime imports, __init__ fallback mort | — |
| `supervision/circuit_breaker.py` | ~40 | Zero imports, doublon system/circuit_breaker | system/circuit_breaker |
| `supervision/supervisor.py` | ~200 | Zero imports, docstrings mentionnent modules jamais wired | — |
| `gpu/clip_manager.py` | ~60 | Zero imports | embedding/clip_embedder |
| `gpu/ollama_manager.py` | ~80 | Zero imports | detection/ollama_client |
| `auth/manage_vault.py` | ~40 | Zero imports | auth/credential_vault |
### agent_v0/ — MORT (10 fichiers, ~800 lignes)
| Fichier | Lignes | Preuve mort | Doublon de ? |
|---------|--------|-------------|-------------|
| `server_v1/vm_controller.py` | 143 | Importé uniquement par visual_wait (mort) | — |
| `server_v1/visual_wait.py` | 54 | Importé uniquement par vm_controller (mort) | — |
| `server_v1/workflow_replay.py` | 256 | Zero imports, projection jamais intégrée | replay_engine.py |
| `agent_v1/window_info.py` | 55 | Zero imports | window_info_crossplatform.py |
| `agent_v1/tools/test_lea_pause_flow.py` | ~60 | Script debug standalone | — |
| `agent_v1/tools/test_lea_toast.py` | ~80 | Script debug standalone | — |
| `agent_v1/ui/_test_paused_toast.py` | ~40 | Script debug standalone | — |
| `agent_v1/monitoring/__init__.py` | 0 | Stub vide jamais développé | — |
| `agent_v0/config.py` | 58 | Zero imports | agent_v1/config.py |
| `agent_v0/setup_v1.sh` | 30 | Vestige, requirements.txt absent | — |
### server/ — MORT (2 fichiers, ~300 lignes)
| Fichier | Lignes | Preuve mort | Doublon de ? |
|---------|--------|-------------|-------------|
| `server/api_upload_dev_8001.py` | ~150 | Pas dans services.conf, dev-only | api_upload.py |
| `server/api_upload_dev_8002.py` | ~150 | Pas dans services.conf, dev-only | api_upload.py |
### scripts/ — MORT (~37 fichiers, ~2500 lignes)
| Fichier pattern | Count | Preuve mort | Notes |
|----------------|-------|-------------|-------|
| `demo_*_*_vwb_*_*.py` (dated) | ~12 | Scripts debug Jan 2026, jamais appelés par runtime | Vestiges développement VWB propriétés |
| `diagnostic_*_*.py` (dated) | ~6 | Scripts debug Jan 2026 | Diagnostic palette/catalogue |
| `test_*_vwb_*_*.py` (dated) | ~10 | Scripts test standalone Jan 2026 | Tests ad-hoc palette/propriétés |
| `implementer_*.py`, `implementation_*.py` | ~2 | Scripts debug Jan 2026 | Implémentation propriétés |
| `creer_sauvegarde_vwb_*.py` | 1 | Script debug | Sauvegarde ad-hoc |
| `analyse_cas_undefined_*.py` | 1 | Script debug | Analyse ad-hoc |
| `start_vwb_backend_*.py` (4 variants) | 4 | Doublons de `start_vwb_backend.py` | Versions ultra_stable, thread_safe, final, catalogue_complet |
| `start_system_complet_*.sh` | 1 | Script dated | Remplacé par systemd |
| `start_vwb_complete_*.sh` (2) | 2 | Scripts dated | Remplacé par systemd |
**scripts/ WIRED (3)** : `record_and_build.py` (A), `bench_t2a_dryrun.py` (A), `backup_vwb_and_audit.sh` (A)
### deploy/ — MORT (2 fichiers)
| Fichier | Preuve mort | Doublon de ? |
|---------|-------------|-------------|
| `deploy/configs/config_dev_windows.txt` | Config dev, pas utilisé par installateur | config_template.txt |
| `agent_v0/setup_v1.sh` | Vestige, requirements.txt absent | — |
### root — MORT (1 fichier)
| Fichier | Preuve mort |
|---------|-------------|
| `mcp_rpa_vision.py` | MCP server jamais importé/callé par runtime ou systemd |
---
## Inventaire par zone — catégorie B (ORPHELIN/projection)
### core/ — ORPHELIN (~45+30 borderline, principalement analytics/ et healing/strategies/)
| Zone | Fichiers | Preuve orphelin | Projection plausible ? |
|------|---------|-----------------|----------------------|
| `analytics/` (13 orphelins) | metrics_collector, anomaly_detector, insight_generator, performance_analyzer, success_rate_calculator, archive_storage, timeseries_store, query_engine, report_generator, analytics_api, dashboard_manager, realtime_analytics, resource_collector | Test-only ou zero imports | ✅ Analytics = fonctionnalité produit future (dashboard insights) |
| `healing/strategies/` (5) | base_strategy, format_transformation, semantic_variants, spatial_fallback, timing_adaptation | Test-only via `test_self_healing.py` | ✅ Self-healing = produit prévu |
| `cognition/working_memory.py` (1) | Intra-core lazy import seulement | ✅ Cognition = produit futur |
| `detection/owl_detector.py` (1) | Importé par agent_chat/autonomous_planner seulement | ✅ OWL = grounder alternatif |
| `detection/roi_optimizer.py`, `spatial_analyzer.py` | Test-only | ✅ Optimisation ROI future |
| `grounding/smart_resize.py` | Test-only, jamais importé runtime | ✅ Resize intelligent utile |
| `gpu/vram_monitor.py` | Zero imports | ✅ GPU monitoring utile |
| `monitoring/automation_scheduler.py` | __init__ try/except | ✅ Automation scheduling |
| `security/flask_security.py`, `input_validator.py`, `ip_allowlist.py`, `rate_limiter.py`, `audit_log.py` | __init__ exports mais jamais importés directement | ✅ Sécurité = P0 produit |
| `precision/` (5 fichiers) | Test-only | ✅ Metrics/precision utile |
| `system/artifact_retention.py` | __init__ export, jamais utilisé runtime | ✅ Rétention utile |
### agent_v0/ — ORPHELIN
| Fichier | Preuve | Projection ? |
|---------|--------|-------------|
| `server_v1/session_worker.py` | Commentaires seulement, pas importé | ✅ Background session processing |
| `agent_v1/core/anchor_catalog.py` + `anchor_relative.py` | Importé par tests seulement | ✅ Anchor resolution future |
| `deploy_windows.py` | Script packaging manuel | ✅ Packaging Windows |
| `deploy/windows_client/` (23 fichiers) | Copie déployée sur Windows, pas importée depuis repo Linux | ✅ = package agent Windows |
### server/ — ORPHELIN
| Fichier | Preuve | Projection ? |
|---------|--------|-------------|
| `server/processing_pipeline.py` | Importé par api_upload seulement | ✅ Pipeline processing |
| `server/processing_queue.py` | Importé par api_upload seulement | ✅ Queue processing |
| `server/storage_encrypted.py` | Importé par api_upload seulement | ✅ Encrypted storage |
| `server/worker_daemon.py` | Importé par api_upload seulement | ✅ Worker daemon |
### root — ORPHELIN
| Fichier | Preuve | Projection ? |
|---------|--------|-------------|
| `monitoring_server.py` | Optional dans services.conf (5003), pas déployé DGX | ✅ Monitoring utile |
| `run_gui.py` | Script standalone GUI launcher | ✅ GUI dev tool |
| `cli.py` | CLI entry point, importé par core modules | ✅ CLI utile |
### deploy/ — ORPHELIN
| Fichier | Preuve | Projection ? |
|---------|--------|-------------|
| `deploy/dgx/vm_launch.sh` + `vm_stop.sh` | Scripts QEMU VM DGX | ✅ VM management |
| `deploy/configs/config_pc_fixe_lan.txt` + `config_tim_pauline.txt` + `config_vm_lan.txt` | Configs déployées manuellement | ✅ Déploiement multi-site |
| `deploy/windows-rdp-launcher/` | Scripts RDP | ✅ Remote access |
| `deploy/hyperv_glpi_ubuntu24/` | Script HyperV | ✅ VM creation |
---
## Quick wins sûrs (C évident, zéro risque)
| # | Fichier | Lignes | Risque | Action |
|---|---------|--------|--------|--------|
| 1 | `core/interfaces/` (3 fichiers) | ~100 | Zéro | Supprimer — jamais importé runtime |
| 2 | `core/cognition/` (4 morts: precondition, scene_expected, trace, vram_orchestrator) | ~280 | Zéro | Supprimer — zero imports |
| 3 | `core/extraction/` (4 morts: data_store, engine, controller, schema) | ~300 | Zéro | Supprimer — field_extractor seul wired |
| 4 | `core/supervision/` (2: circuit_breaker doublon, supervisor jamais wired) | ~240 | Zéro | Supprimer — doublons |
| 5 | `core/detection/seeclick_adapter.py` | ~300 | Zéro | Supprimer — fallback mort |
| 6 | `server_v1/vm_controller.py` + `visual_wait.py` | ~200 | Zéro | Supprimer pair — jamais importé |
| 7 | `agent_v1/window_info.py` | 55 | Zéro | Supprimer doublon |
| 8 | `agent_v0/config.py` | 58 | Zéro | Supprimer doublon |
| 9 | `scripts/ dated Jan 2026` (~37) | ~2500 | Zéro | Supprimer — vestiges dev |
| 10 | `server/api_upload_dev_8001.py` + `api_upload_dev_8002.py` | ~300 | Faible | Supprimer dev-only |
**Total quick wins** : ~10 actions, ~4133 lignes supprimées, zéro risque runtime
---
## Zones à risque (à instruire avant GO)
| Zone | Risque | Pourquoi |
|------|--------|----------|
| `core/analytics/` (13 orphelins) | Moyen | Projection produit plausible — supprimer = perdre design analytics |
| `core/healing/strategies/` (5) | Moyen | Self-healing = produit prévu — test-only mais intention future |
| `core/grounding/infigui_server.py` + `infigui_worker.py` | Moyen | Server jamais lancé mais worker existe — dépendance circulaire mort |
| `core/security/` (5 orphelins) | Moyen | Sécurité P0 produit — supprimer = perdre auth/rate-limiting design |
| `core/gpu/` (3 morts: vram_monitor, clip_manager, ollama_manager) | Faible | GPU monitoring plausible mais doublon vérifié |
---
## Proposition de réorganisation
**Pas de réorganisation proposée à ce stade.** Le ménage C suffira à dégraisser ~4500 lignes. Les B (projections) resteront en place avec documentation `# PROJECTION: <raison>` pour les distinguer du code wired.
**Après ménage C**, réorganisation possible :
- `scripts/` dated → archiver dans `scripts/archive/` ou supprimer
- `core/interfaces/` mort → supprimer (remplacé par protocols Python si besoin)
- `deploy/configs/` → consolider dans `deploy/installer/config_template.txt`
---
## Plan d'exécution par lots (chirurgie itérative supervisée)
**Chaque lot = petit, testable (≤ 2 min), QG entre chaque, GO Dom requis.**
| Lot | Action | Lignes | Test QG | Risque |
|-----|--------|--------|---------|--------|
| **L1** | Supprimer `core/interfaces/` (3 fichiers) | ~100 | `pytest tests/ -k "not e2e"` | Zéro |
| **L2** | Supprimer `core/cognition/` morts (4 fichiers) | ~280 | pytest | Zéro |
| **L3** | Supprimer `core/extraction/` morts (4 fichiers) | ~300 | pytest | Zéro |
| **L4** | Supprimer `core/supervision/` (2 fichiers) | ~240 | pytest | Zéro |
| **L5** | Supprimer `core/detection/seeclick_adapter.py` | ~300 | pytest + vérifier __init__.py fallback | Zéro |
| **L6** | Supprimer `server_v1/vm_controller.py` + `visual_wait.py` | ~200 | pytest | Zéro |
| **L7** | Supprimer `agent_v1/window_info.py` + `agent_v0/config.py` + `setup_v1.sh` | ~140 | pytest | Zéro |
| **L8** | Nettoyer __init__.py exports morts dans core/ | ~50 lignes | pytest + import check | Faible |
| **L9** | Supprimer `scripts/` dated Jan 2026 (~37 fichiers) | ~2500 | pytest (aucun script dans test path) | Zéro |
| **L10** | Supprimer `server/api_upload_dev_8001/8002.py` | ~300 | pytest + vérifier services.conf | Faible |
| **L11** | Supprimer `core/gpu/` morts (3 fichiers) | ~200 | pytest | Faible |
| **L12** | Supprimer `core/auth/manage_vault.py` + `execution/workflow_runner.py` | ~240 | pytest | Faible |
**Total** : 12 lots, ~4580 lignes, risque zéro à faible. Chaque lot précédé de GO Dom + QG post-lot.
---
## Liste NE PAS TOUCHER
- Runtime démo `Urgence_aiva_demo` (VWB → backend → agent_v1 → Léa → Easily)
- Services systemd DGX (10 services actifs)
- Installateur clinique (`deploy/installer/`, `deploy/lea_package/`)
- `api_stream.py` (7747 lignes, serveur principal)
- `stream_processor.py` (6085 lignes, orchestrateur)
- `resolve_engine.py` (résolution anchors)
- `replay_engine.py` (replay actions)
- `learned_workflow_bridge.py` (pont VWB ↔ core)
- `core/competences/` (verdicts, promotions, persist, catalog — wired)
- `core/federation/` (GlobalFAISSIndex, LearningPack — wired)
- `core/embedding/` (clip, faiss, state, fusion — wired)
- `core/execution/` (observe_reason_act, target_resolver, input_handler — wired)
- `core/healing/healing_engine.py` + `execution_integration.py` — wired
- `core/analytics/analytics_system.py` + `screen_change_detector.py` — wired
- `core/workflow/` (semantic_matcher, variable_manager, execution_plan, shadow_observer — wired)

View File

@@ -0,0 +1,46 @@
# Plan de migration — persistance workflows (JSON → SQLAlchemy)
- `Date`: 2026-06-09
- `Auteur`: Claude (proposition, validation Dom requise)
- `Statut`: PLANIFIÉ — **post-POC, aucun refactor engagé**
- `Réf dette`: DETTE-015 (docs/DETTE_TECHNIQUE.md)
- `Priorité`: P2 (fragilité réelle, contournée proprement pour le POC par symlink)
## Constat
Trois stockages de workflows coexistent, sans source de vérité unique :
| Store | Emplacement | Utilisé par | État |
|-------|-------------|-------------|------|
| Fichiers JSON | `visual_workflow_builder/backend/data/workflows/*.json` | route API VWB `/api/workflows/` (`api/workflows.py:53`, **relatif au cwd**) | **source réelle** (42 fichiers) |
| DB SQLAlchemy | `…/backend/instance/workflows.db`, table `workflows` + Alembic | composants SQLAlchemy (`db.models`) | propre mais **pas lue par la route** (23 lignes) |
| JSON legacy | `data/training/workflows/` | dashboard `web_dashboard` (`app.py:187-189`) | vide partout |
### Problèmes
1. **Pas de source unique** → divergences (les 42 JSON ≠ 23 lignes DB).
2. **Résolution par cwd** → le bug P0-1 du 2026-06-09 (dev cwd=backend OK ; DGX cwd=racine = 0 workflows).
3. Pas d'écriture atomique ni de validation de schéma côté JSON.
4. Confusion : 3 emplacements, dont un legacy mort.
## Cible
**Unifier sur la DB SQLAlchemy déjà présente** (infra à moitié en place : table + Alembic). La route API lit/écrit la DB ; le store fichier JSON et le legacy sont retirés. Bénéfices : source unique, transactions, intégrité, requêtes, fin de la dépendance au cwd.
> Note : un store fichier n'est pas disqualifiant en soi ; c'est la **coexistence non synchronisée** + la dépendance au cwd qui posent problème. On choisit SQLAlchemy car l'infra existe déjà (vs fiabiliser le JSON, qui laisserait le double store).
## Plan (TDD, post-POC, validation Dom entre étapes)
1. **Audit d'usage** : recenser tous les appels à `WorkflowDatabase` (lecture **et** écriture) — API, moteur d'exécution, agent, frontend VWB. Cartographier le contrat (méthodes `list/load/save/delete`).
2. **Repository SQLAlchemy** : implémenter un `WorkflowRepository` exposant le **même contrat** que `WorkflowDatabase`, adossé à la table `workflows` (réutiliser `db.models`). Tests unitaires CRUD.
3. **Script de migration** : importer les 42 JSON → table `workflows` (idempotent, backup DB avant). Vérifier parité (42 JSON ↔ N lignes, diff de contenu).
4. **Bascule de la route** derrière un flag (`RPA_WORKFLOWS_BACKEND=sqlalchemy|json`, défaut `json`) → tests d'équivalence API (mêmes réponses qu'avant).
5. **Bascule par défaut** sur SQLAlchemy une fois la parité prouvée ; retrait du symlink (DETTE-015).
6. **Nettoyage** : retirer le store legacy `data/training/workflows` (dashboard) ou le rebrancher sur le repository ; supprimer `WorkflowDatabase` quand plus aucun appelant.
## Préconditions / risques
- **Ne pas engager avant la fin du POC** (refactor de persistance = risque pour la démo).
- Touche frontend VWB + agent + moteur d'exécution → bascule progressive sous flag obligatoire.
- Le symlink (DETTE-015) reste le contournement stable jusqu'à la migration.
## Effort estimé
~1,52,5 j en TDD (audit + repository + migration + tests d'équivalence + bascule).

View File

@@ -0,0 +1,103 @@
# Plan de remise au carré — chaîne d'apprentissage & rejeu de Léa
- `Date`: 2026-06-27
- `Auteur`: Claude (mandat Dom)
- `Statut`: actif — formalise l'analyse du 27/06 ; **n'invente rien**, chapeaute `PLAN_ACTION_SUITE_2026-06-23` (axe « Rejeu intelligent ») et `CARTO_APPRENTISSAGE_FONDS_COMMUN_2026-06-16`.
- `Contrainte cardinale (Dom 27/06)`: **câbler la chaîne ET rendre Léa correcte AVANT la dernière manip manuelle de Dom** (= avant la perte de l'accès hands-on / on-site). Cette contrainte est la **barre d'acceptation** de ce plan. *(À préciser : quoi exactement, et quelle date.)*
---
## 1. Pourquoi ça ne marche pas (vérifié le 27/06)
**En une phrase : la chaîne a été diagnostiquée (16/06), décidée (23/06) et planifiée (R1→R7), puis la livraison clinique a absorbé les 2 semaines — le câblage end-to-end n'a jamais démarré. Le blocage est d'EXÉCUTION, pas de décision.**
Faits vérifiés (grep/fichier, ce jour) — la chaîne n'est pas câblée au runtime :
| Maillon | Preuve vérifiée 27/06 | Effet |
|---|---|---|
| Worker d'enrichissement | `data/training/_worker_queue.txt` = **0 octet, mtime 11/06** | enrichissement **à l'arrêt depuis 16 j** |
| Import auto session→workflow | `ShadowLearningHook()` instancié **uniquement dans son propre fichier**, jamais dans `finalize`/runtime | l'appris **n'est pas** rendu rejouable sans geste manuel |
| Lecture du fonds au rejeu | `GlobalFAISSIndex.search()`**grep = 0 appel** ; `TargetResolver.lookup()` jamais appelé par `replay-session` | rejeu = **coords figées**, pas cible apprise |
| Dé-silo | filtre `machine_id` à **5 points** de `stream_processor.py` (3199, 3285, 4499, 4530, 5062) | apprentissage **siloté par poste** |
Causes racines (au-delà du « c'est débranché ») :
1. **Les 2 semaines = infra de survie** (portage DGX ARM, installateur EXE, réseau/firewall/VPN, reprise panne secteur + reboot, watchdog OVMF, enrôlement, streaming, push-log). Indispensable pour livrer — mais **0 h sur le câblage de la boucle**.
2. **Découpage par composant, jamais par boucle.** Hook, FAISS, TargetMemory bâtis et validés isolément (sessions/agents différents). **Personne n'a possédé ni testé la boucle entière sur une vraie session** → trous accumulés à chaque soudure, invisibles.
3. **Le raccourci démo est devenu permanent.** Le rejeu `Urgence_aiva` marchait en coords figées + templating `{{var}}` (seule pièce câblée). « Ça marchait » → le chemin intelligent n'a **jamais été allumé**.
⚠️ **À confirmer en amont (audit runtime Qwen, NON vérifié indépendamment)** : « **11/15 postes heartbeat-only, 0 % résolution vision/OCR/anchors** ». Si avéré, le premier problème n'est pas l'apprentissage mais que **les postes ne font pas encore le geste du POC** (pas de vraie capture, cascade vision non exercée). → **à prouver, chiffré, AVANT tout recâblage** (Phase 0).
---
## 1bis. CARTE DE CÂBLAGE VÉRIFIÉE (28/06 — 3 agents read-only, sourcé code)
> Cette section **corrige** le §1 sur deux points (diagnostic affiné, moins grave qu'annoncé).
**Deux corrections vérifiées (mes affirmations antérieures étaient fausses) :**
1. **« sessions → squelettes sans action » = FAUX.** Les actions (clics/saisies) sont attachées au workflow sur les **edges** (`WorkflowEdge.action`, `graph_builder.py:1457`), pas sur `node.variants` (qui = variantes *visuelles* d'écran, champ non peuplé au runtime). 48/71 workflows auto-appris portent leurs actions ; 23 sont vides (sessions trop courtes).
2. **« rejeu = coords figées » = FAUX.** Léa **résout chaque cible par la vue** à chaque rejeu (cascade OCR→template→YOLO→VLM sur anchors, `resolve_engine.py:1804`). Coords = fallback ultime seulement. Conforme 100 % vision.
**Ce qui MARCHE** : capture→workflow avec actions ; worker traite ; 36/71 atteignent `AUTO_CANDIDATE` ; rejeu visuel (VWB-DB 226 `click_anchor` + JSON auto-appris non vides) ; **R2 à moitié branché** (`TargetMemoryStore` consulté en tête de `_resolve_target_sync`, `resolve_engine.py:1862`).
**Les 4 vrais trous (sourcés) :**
| # | Trou | Preuve | Type |
|---|---|---|---|
| **1 (P0)** | **11/15 postes n'enregistrent rien** | démarrage capture 100 % manuel (clic TIM « Apprenez-moi », `smart_tray.py:349` / `chat_window.py:1716`) ; heartbeats auto (`main.py:378`). Risque : dialogue consentement `Tk()` (`smart_tray.py:54`) invisible en RDP/Citrix `pythonw` | **amont / UX (à confirmer bug vs usage par logs client)** |
| 2 | **Apprentissage incrémental débranché** | `LearningManager` non instancié serveur ; mute `WorkflowStats` mémoire non re-persisté ; `record_observation` (`learning_manager.py:54`) **0 appelant**. Seul `GraphBuilder` écrit `learning_state`, fige sur OBSERVATION si qualité faible (`graph_builder.py:400`) | promotion jamais déclenchée |
| 3 | **2 mondes disjoints** : JSON auto-appris ≠ DB VWB rejouable | stores/loaders/matchers séparés ; une session apprise ne devient pas un workflow DB rejouable | = **R1** (pont JSON→DB) |
| 4 | **Fonds commun jamais lu au rejeu** | `GlobalFAISSIndex.search()` = 0 appel (seul `add_pack` écrit) | = **R3** (FAISS au rejeu) |
**Points d'insertion confirmés** : R1 = worker `_process_session` après `_persist_workflow` (réutiliser `import_learned_workflow`/`learned_workflow_bridge`, idempotence par `workflow_trajectory_signature` existante). R2/R3 = `resolve_engine.py:1862-1878` (élargir `memory_lookup` + insérer `GlobalFAISSIndex.search()`).
**Priorité (contrainte « Léa correcte avant dernière manip ») : #1 (amont) d'abord** — si 11/15 postes ne capturent pas, l'aval est sans objet. Test décisif = grep logs client `"Session … en cours"` vs `"Session finalisée"` (Qwen).
---
## 2. État des décisions (rappel — la plupart sont déjà prises)
Tranchées le 23/06 (`DECISIONS_PRODUIT_EN_ATTENTE_2026-06-23.md`) → **on exécute, on ne re-décide pas** :
- **F2-1/F14-1** : rejeu intelligent = **OUI, prérequis** (consulter le fonds appris, pas de coords figées).
- **F1-1** : critère de fusion = **signature de trajectoire**. ~~create-or-update~~**create-or-skip** (révisé Dom 2026-07-02 : un ré-apprentissage ne doit PAS écraser un workflow validé par revue humaine ; la 1ʳᵉ version importée fait foi. Refresh explicite = chantier séparé si besoin. Implémenté ainsi dans `learned_workflow_bridge.py`, cf. `CARTO_CODE_NON_BRANCHE_2026-07-02.md`).
- **F9-1** : **DB = vérité**, JSON = échange ; métrique = workflows rejouables validés.
- **F6-1** : mutualisation **cross + intra-clinique** (fédération anonymisée dans le périmètre + lever silo `machine_id`).
**Reste ouvert (1 seule, vraie décision) :**
- **Q-F2-2 — Provider Léa au runtime** : quel modèle/route sert la **résolution** au rejeu. ⚠️ Gap tracé 23/06 : le point d'entrée actif = **agent_chat 5004 → `SemanticMatcher.find_workflow()` sur fichiers JSON**, pas la DB → **contredit F9-1**. Se résout **en chemin** à la Phase 2 (où la résolution est recâblée). Reco modèle : Qwen3-VL-4B grounder + gemma4 (bench 13/06).
**Décisions potentiellement induites par la Phase 0** : si « 0 % vision » confirmé, une décision « comment forcer/garantir la capture vision réelle sur poste » surgira (priorité absolue, avant R1).
---
## 3. Plan d'exécution (séquencé, chirurgie supervisée)
> Les chantiers R1→R7 détaillés sont dans `PLAN_ACTION_SUITE_2026-06-23` (§ Axe central) — **non dupliqués ici**. Ce plan ajoute la **Phase 0 de mesure** (nouvelle) et l'**ordre/critères**.
**Phase 0 — MESURER (avant tout recâblage).** Établir la vérité terrain : par poste, nb de **vraies sessions**, la **cascade vision est-elle déclenchée** (compteur de résolutions par méthode), captures reçues, état queue. **C'est ce que push-log + une télémétrie vision apportent** (lien direct avec briques 1-4 livrées). → *Mission Qwen (accès runtime DGX).* **Critère de sortie : on sait, chiffré, ce que font les 15 postes.**
**Phase 1 — RECONNECTER L'AMONT (R1).** Import auto session→workflow post-`finalize` + **relancer le worker** (queue morte 11/06). *Critère : une session TIM réelle devient un workflow rejouable sans geste manuel.*
**Phase 2 — RECONNECTER L'AVAL (R2+R3) + résoudre Q-F2-2.** Câbler `TargetResolver.lookup()` + lecture FAISS au rejeu, **fallback obligatoire sur coords figées** (non négociable Qwen — enrichir, pas casser) ; aligner le point d'entrée résolution sur la DB (F9-1). *Critère : Léa résout par cible apprise, retombe sur coords si échec.*
**Phase 3 — BOUCLE + DÉ-SILO (R4/R5/R6).** verify post-condition (échec → pause supervisée), réécriture du fonds, lever silo `machine_id` + brancher fédération (`GlobalFAISSIndex.search()`).
---
## 4. Gouvernance (corrige la cause racine #2)
- **Un seul propriétaire de la boucle entière** (pas un découpage par composant).
- **Critère d'acceptation = un test end-to-end sur une session réelle**, pas une validation par brique.
- Chirurgie itérative supervisée : un maillon = un test ≤ 2 min = GO Dom ; démo `Urgence_aiva` intacte à chaque étape ; reconfirmer le wiring runtime avant chaque modif (imports lazy = verdicts « orphelin » non fiables).
- **Merge prod supervisé Dom.**
## 5. Lien avec la « dernière manip manuelle » (deadline)
La contrainte de Dom fait de ce plan un **chemin critique** : tant que Léa n'est pas correcte (au moins Phase 0 + Phase 1-2 sur 1 poste pilote), **la dernière manip manuelle ne doit pas avoir lieu** — sinon plus d'accès hands-on pour réparer. → **Définir avec Dom : quelle est cette manip, et sa date butoir**, pour caler le séquencement.
## 6. Première action concrète
**Phase 0 confiée à Qwen** (chiffres runtime). Doc + page décisions = ce fichier. Reste : GO Dom sur le séquencement + définition de la deadline « dernière manip ».
---
*Plans sources (ne pas dupliquer) : `PLAN_ACTION_SUITE_2026-06-23`, `CARTO_APPRENTISSAGE_FONDS_COMMUN_2026-06-16`, `DECISIONS_PRODUIT_EN_ATTENTE_2026-06-23`, `PLAN_CHANTIER_UNIFICATION_LEA_VWB_2026-06-17`.*

View File

@@ -0,0 +1,74 @@
# Post-mortem — Panne secteur DGX 2026-06-20 (test reboot non planifié)
- `Auteur`: Claude (infra)
- `Date`: 2026-06-20 ~03:00 CEST
- `Scope`: La coupure électrique réelle de 02:07 traitée comme **exécution non planifiée du test de stabilité reboot** (`CHECKLIST_DGX_PRE_CLINIQUE.md` §6). Objectif : mesurer ce qui se rétablit **seul** vs ce qui exige une **intervention** — c'est le critère clé pour une clinique sans personnel technique sur place.
- `Méthode`: diagnostic read-only multi-agents (Claude infra, Qwen appli/guest, Codex consolidation). Aucun reset Git, aucun changement réseau hors IP statique arbitrée par Dom.
---
## 1. Timeline
| Heure | Événement |
|---|---|
| 02:07 | Coupure secteur. DGX reboot. Poste Dom (Linux) reboot. Laptop Windows `.11` (sur batterie) jamais coupé. |
| 02:07:42 | `win11-arm-lea.service` (user) auto-démarre la VM. |
| ~02:09 | Watcher coordination (systemd user) revenu seul. Services rpa système remontés. |
| 02:07→02:18 | VM bloquée en boucle TianoCore (QEMU 99% CPU, guest agent/SSH absents). |
| 02:18 | Codex diagnostique : `OVMF_VARS.fd` corrompu par coupure brutale. |
| 02:21 | Codex restaure OVMF connu-bon (18/06) + TPM frais → VM boot prouvé (écran verrouillage Windows). |
| ~02:28 | Claude : DGX revenu en DHCP sur `.46` (au lieu de `.45`) → IP statique `.45` appliquée (décision Dom). |
| ~02:35 | Accès VM Dom rétabli (tunnel + VNC, mot de passe OK). |
| ~02:55 | Crash-loop `dashboard-user` éteint (Qwen). Revue infra/appli consolidée. |
---
## 2. Ce qui s'est rétabli SEUL (✅ socle solide)
| Domaine | Constat | Réf checklist |
|---|---|---|
| Boot DGX + services rpa | Tous `active+enabled` (dashboard, streaming, agent-chat, vwb back/front, api, worker, stream-worker, **vllm-grounder**, firewall) | §1 PASS |
| Firewall | Réappliqué : 5900/5902/3389/22220/8000/11434 **filtrés LAN**, seuls 5001/5002/5004/5005 ouverts | §2 PASS (fort) |
| Auth | Dashboard 401, VWB 401 (basic auth), streaming Bearer | §3 majoritaire PASS |
| Auto-start VM | `win11-arm-lea.service` a bien démarré la VM (linger=yes) | §4.1 — prouvé (était « à implémenter ») |
| Coordination | Watcher couche-1 (systemd user) revenu seul | — |
**Le socle infra/services/sécurité survit à une coupure brutale sans intervention.**
## 3. Ce qui a EXIGÉ une intervention (⚠️ gaps reprise non-assistée)
| # | Problème | Cause | Correctif (qui) | Risque clinique |
|---|---|---|---|---|
| G1 | **DGX IP a dérivé `.45`→`.46`** | bail DHCP après reboot | IP statique `.45` (Claude/Dom) | **Élevé** — casse tous clients/tunnels pointant `.45`. DHCP non fiable. |
| G2 | **VM bloquée TianoCore** | `OVMF_VARS.fd` corrompu (coupure brutale) | restore OVMF connu-bon + TPM frais (Codex) | **Élevé** — sans agent, VM morte jusqu'à intervention manuelle. |
| G3 | **`dashboard-user` crash-loop** (244 restarts) | fallback user clash port 5001 (service système le sert déjà) | stop + mask session (Qwen) | Moyen — bruit/ressources ; `disabled` mais relancé. |
| G4 | **Léa guest non reconnectée** | `config.txt` = `CONFIGURE_ME` + login Windows requis | à renseigner `.45`+token (Qwen) | **Élevé** — VM redémarre mais Léa ne reprend pas le travail seule. |
| G5 | **Mot de passe VNC** | `-vnc password=on` sans `set_password` dans les scripts | rétabli de fait (tunnel) | Faible/Moyen — fragile si relaunch sans repose mdp. |
---
## 4. Recommandations de durcissement — reprise CLINIQUE non-assistée
> Toutes **modifiantes → à valider par Dom** (mises en file, non appliquées cette nuit).
1. **BIOS DGX = « Power On » au retour AC** (physique, Dom) — sinon une coupure laisse le DGX éteint.
2. **IP statique** : fait au labo (`.45`) ; cible clinique = Ethernet statique `.178` (DHCP = point faible démontré par G1).
3. **Auto-réparation OVMF** dans `win11-arm-lea.service` : au boot Windows réussi, snapshot `OVMF_VARS.fd` sain ; `ExecStartPre` : si boucle TianoCore détectée (CPU 99% + guest agent absent N s), restaurer le snapshot sain automatiquement. → neutralise G2 sans agent.
4. **`rpa-vision-v3-dashboard-user`** : `mask` **persistant** (pas seulement session) — G3.
5. **Léa reprise auto** (G4) : `config.txt` persistant vers IP DGX + token ; auto-login Windows + Léa auto-start (`pythonw`) + reconnexion fleet sans geste humain.
6. **Mot de passe VNC** (G5) : poser le mot de passe au lancement via le monitor (script), ou documenter la procédure de repose.
---
## 5. Propositions de MAJ pour `CHECKLIST_DGX_PRE_CLINIQUE.md` (Qwen, propriétaire)
- §4.1 « auto-start VM » : passer **À IMPLÉMENTER → VALIDÉ** (prouvé par la panne, 02:07:42).
- §1.10 / Items à fixer #1 : dashboard service **système actif** confirmé ; le fallback user est l'orphelin → masquer.
- §6 « Test reboot » : **exécuté en réel le 2026-06-20** → renseigner les résultats (col. Résultat) à partir des sections 2 et 3 ci-dessus.
- Ajouter une ligne **G1 dérive IP DHCP** et **G2 corruption OVMF** comme items de durcissement explicites.
---
## 6. Verdict test
Le **socle technique tient** (services, firewall, auth, auto-start VM). Les **deux points durs** pour une clinique sans technicien sur place sont **G1 (dérive IP DHCP)** et **G2 (corruption OVMF VM non auto-réparée)** : tous deux ont nécessité un agent cette nuit. La cible clinique doit les **automatiser** (IP statique Ethernet + auto-réparation OVMF). G4 (Léa ne reprend pas seule) est le troisième chantier reprise.

View File

@@ -0,0 +1,135 @@
# QG Review Framework — D1 NavigateCoords Patch
**Auteur**: Qwen
**Date**: 2026-07-02
**Statut**: EN ATTENTE patch Codex
**Scope**: Review du patch D1 (Option 1 — Compiler Injection) produit par Codex
---
## Baseline test coverage (pré-patch)
| Fichier | Classes | Tests | Rôle |
|---------|---------|-------|------|
| `test_navigate_handler_e2e.py` | 4 | 8 | Handler mock — nominal, OCR miss, no screenshot, never-fail |
| `test_navigate_wiring.py` | 4 | 5 | Import/wiring non-regression |
| `test_action_resolver.py` | 6 | 10 | NavigateCoords, NavigateResult, grounded_to_coords, navigate_login |
| `test_coords_consumption_gap.py` | 3 | 10 | **GAP DOCUMENTATION** — résolution viable, compiler gap, navigate→[] |
| **Total** | **17** | **33** | |
**Tests critiques à mettre à jour après D1 patch**:
- `test_coords_consumption_gap.py::test_navigate_action_type_unknown` — affirme actuellement `actions == []`; doit affirmer `len(actions) >= 1` et `actions[0]["type"] == "navigate"` après D1
- `test_coords_consumption_gap.py::TestCompilerGapLiteralFloats` — 4 tests documentant le gap literal-floats; après D1, les tests coords_var doivent affirmer templates strings ARE produites quand coords_var présent
**Point d'insertion exact D1**:
- Fichier: `replay_engine.py`
- Entre `elif action_type == "llm_generate"` (retourne `[normalized]` ~L1949) et `else:` clause (~L1953)
- Navigate branch: `elif action_type == "navigate"``normalized["type"] = "navigate"` + parameters dict → `return [normalized]`
**P1-C root cause**:
- `_resolve_runtime_vars_in_str` (L2025): `return str(value)` — tout {{var.field}} résolu devient string "0.35" pas float 0.35
- Coercion helper `_coerce_action_coords` doit agir APRÈS `_resolve_runtime_vars` (L4335), AVANT `type_ = action.get("type")` (L4337)
---
## Critères de review — Checklist
### C1 : Branche navigate dans `_edge_to_normalized_actions` (Gap C)
| # | Critère | GO | NOGO |
|---|---------|----|------|
| C1-1 | Branche `elif action_type == "navigate"` ajoutée entre `llm_generate` (L1949) et `else` (L1951) | Present, position correcte | Absente ou mal positionnée |
| C1-2 | `normalized["type"] = "navigate"` | Oui | Type incorrect |
| C1-3 | Parameters dict avec `login_coords_var`, `password_coords_var`, `submit_coords_var` | Noms exacts, valeurs default | Noms divergent ou absents |
| C1-4 | Retourne `[normalized]` (1 action serveur-side) | `[normalized]` | `[]` ou autre |
| C1-5 | Test TR-1 : `test_navigate_action_type_unknown` mis à jour — affirme `len(result) >= 1` et `result[0]["type"] == "navigate"` | Test updated + passes | Test non mis à jour ou fails |
### C2 : coords_var dans branches mouse_click / text_input (Gap A)
| # | Critère | GO | NOGO |
|---|---------|----|------|
| C2-1 | `coords_var = action_params.get("coords_var")` check dans mouse_click | Present | Absent |
| C2-2 | Si coords_var → `x_pct = f"{{{{{coords_var}.x_pct}}}"` et `y_pct = f"{{{{{coords_var}.y_pct}}}"` | Template strings correctes | Syntaxe template incorrecte ou .y_pct pour x_pct |
| C2-3 | Si coords_var absent → literal floats comme avant (fallback existant) | Branch else intacte | Branch else modifiée ou supprimée |
| C2-4 | `normalized["coords_var"] = coords_var` ajouté pour traçabilité | Oui | Absent |
| C2-5 | Même mécanisme dans text_input branch | Identique à mouse_click | Absent ou divergent |
| C2-6 | BUG vérifié : text_input x_pct template = `{{coords_var.x_pct}}` (pas `.y_pct` deux fois) | Correct | y_pct en double |
### C3 : `_coerce_action_coords()` helper (Gap B / P1-C)
| # | Critère | GO | NOGO |
|---|---------|----|------|
| C3-1 | Helper défini dans api_stream.py (pas replay_engine.py) | api_stream.py | Autre fichier |
| C3-2 | Appel APRÈS `_resolve_runtime_vars` (L4335), AVANT `type_ = action.get("type")` (L4337) | Position correcte | Avant resolver ou après type_ check |
| C3-3 | float pass-through : `isinstance(val, float) → continue` | Idempotent sur actions existantes | Pas de float check → conversion inutile |
| C3-4 | string→float : `try: action[key] = float(val)` | Conversion correcte | Pas de try/except → crash possible |
| C3-5 | Template non résolu → pause_for_human (pas fallback 0.0/0.0) | `val.startswith("{{") and val.endswith("}}")` → pause_for_human | Fallback 0.0/0.0 ou autre coords dangereux |
| C3-6 | Conversion impossible → pause_for_human | ValueError/TypeError → pause_for_human | Exception non catchée |
| C3-7 | `_skip_reason` documenté pour debug | Oui | Absent |
| C3-8 | `safety_level = "high"` pour pause_for_human | Oui | Absent ou autre valeur |
| C3-9 | Retourne action mutée (pas de new dict) | Mutation in-place | Copie → risque race |
| C3-10 | Keys itérées = ("x_pct", "y_pct") uniquement | Pas de sur-itération | Autres keys modifiées |
### C4 : Never-fail contract
| # | Critère | GO | NOGO |
|---|---------|----|------|
| C4-1 | `_handle_navigate_action` ne lance jamais d'exception non catchée | Contract preserved | Nouvelle exception possible |
| C4-2 | `_coerce_action_coords` ne lance jamais — tout cas couvert par try/except ou pause_for_human | Contract preserved | Exception possible |
### C5 : Limites de scope POC
| # | Critère | GO | NOGO |
|---|---------|----|------|
| C5-1 | Maximum 4 fichiers modifiés | ≤ 4 | > 4 |
| C5-2 | Pas de changement schema VWB dans POC patch | Pas de modification VWB code | VWB code modifié |
| C5-3 | Pas de nouvelle dépendance pip | 0 nouvelles deps | Nouvelle dep |
| C5-4 | Pas de modification OmniParser wiring | `_omniparser_available = False` intact | Modifié |
### C6 : Test coverage
| # | Critère | GO | NOGO |
|---|---------|----|------|
| C6-1 | TR-1 : navigate compile à 1 action (pas []) | Passes | Fails |
| C6-2 | TR-2 : coords_var template resolution + float conversion | Passes | Fails |
| C6-3 | Test `_coerce_action_coords` : float pass-through | Passes | Absent |
| C6-4 | Test `_coerce_action_coords` : string→float conversion | Passes | Absent |
| C6-5 | Test `_coerce_action_coords` : template non résolu → pause_for_human | Passes | Absent |
| C6-6 | Test `_coerce_action_coords` : conversion impossible → pause_for_human | Passes | Absent |
| C6-7 | Test idempotence : action existante float non modifiée | Passes | Absent |
| C6-8 | `pytest tests/unit/` passe en intégralité | 0 failures | ≥1 failure |
### C7 : Risques additionnels (3 identifiés dans PLAN_D1)
| # | Risque | Mitigation attendue | GO | NOGO |
|---|--------|--------------------|----|------|
| C7-1 | Résolution partielle (x_pct résolu, y_pct template) | `_coerce_action_coords` → pause_for_human si ANY key unresolved | Mitigation presente | Pas de mitigation |
| C7-2 | Idempotence sur mouse_click existant | `isinstance(val, float) → continue` | Idempotent | Risque de double conversion |
| C7-3 | Race condition sur variables dict partagé | BFS séquentiel garantit navigate→click ordre | Note dans code/doc | Pas de mention |
---
## Procédure de review
1. **Lire le patch** : `git diff` sur les fichiers modifiés par Codex
2. **Vérifier chaque critère C1-C7** : GO/NOGO par ligne
3. **Exécuter les tests** : `cd /home/dom/ai/rpa_vision_v3 && .venv/bin/python -m pytest tests/unit/ -x -v`
4. **Produire le verdict** : Table GO/NOGO avec justification + verdict global
## Format verdict
```
## QG Verdict — D1 NavigateCoords Patch
| Critère | GO/NOGO | Note |
|---------|---------|------|
| C1 | GO | Branche navigate correcte |
| C2 | NOGO | BUG: y_pct en double dans text_input |
| ... | ... | ... |
**Verdict global**: GO / NOGO (avec réserves listées)
```
---
*Qwen — framework QG prêt, awaiting Codex patch pour exécution.*

View File

@@ -27,6 +27,9 @@ Spécification complète pour l'implémentation :
### Autres Documents
- **`ROADMAP_RPA_100_VISION.md`** - Vision et roadmap du projet
- **`INSTALLATION_MULTI_SITE.md`** - Guide installation POC/MVP/production et multi-etablissement
- **`PLAN_ACTION_SUITE_2026-06-23.md`** - Plan d'action consolidé post-livraison clinique (chapeaute les plans existants ; axe central = rejeu intelligent des actions apprises)
- **`PLAN_REMISE_AU_CARRE_APPRENTISSAGE_2026-06-27.md`** - Remise au carré de la chaîne apprentissage/rejeu : pourquoi elle n'est pas câblée (vérifié) + plan d'exécution Phase 0 (mesure) → R1-R6, contrainte « Léa correcte avant la dernière manip manuelle »
## 🎯 Par Où Commencer ?

View File

@@ -0,0 +1,272 @@
# RPA Vision V3 — Automatisation basée sur la compréhension visuelle des interfaces
> ⚠️ **Projet en phase POC clinique** — déployé mais **non production-ready**.
> Voir [`docs/STATUS.md`](STATUS.md) pour l'état réel par module. Certaines
> briques tournent en clinique de bout en bout, d'autres sont **codées mais
> gated/dormantes** (activation supervisée par Dom uniquement).
*Dernière mise à jour : 2 juillet 2026 (brouillon proposé — remplace le README daté du 14 avril 2026)*
> **Note de relecture** : ce brouillon reflète la trajectoire mai→juillet 2026
> (livraison DGX Spark, flotte clinique Wallerstein, PII, extraction, grounder
> Qwen3-VL, installeur EXE, MAJ silencieuse). L'ancien README/STATUS s'arrêtait
> au « premier replay Notepad du 13 avril 2026 » et ne reflétait plus la réalité.
## Intention
Automatiser des workflows métier hospitaliers par **compréhension sémantique
de l'écran** plutôt que par coordonnées de clic fixes. Le système observe le
TIM, reconstruit un graphe d'états de l'interface, et cherche à **rejouer
intelligemment** la procédure en reconnaissant visuellement les éléments cibles
— y compris quand l'UI change légèrement. Objectif produit : Léa **apprend** un
parcours et le **généralise**, ce n'est **pas** du record-and-replay.
Terrain cible : postes hospitaliers hétérogènes (**vrais logiciels métier en
mode web, RDP et Citrix**), TIM sur **2 écrans** → capture de la **fenêtre
active**. C'est cette hétérogénéité qui justifie l'approche « 100 % vision ».
Contraintes fortes :
- **100 % vision** : résolution de l'UI par la vue, pas par sélecteurs DOM/API.
- **100 % local** : inférence sur GPU local (Ollama / vLLM). Aucun appel cloud
dans le pipeline par défaut (passeport souverain santé).
> Historique : la maquette « Easily Assure » a servi de banc de test jusqu'à
> mi-2026 ; elle est **abandonnée comme cible** (recadrage juin 2026). Ne plus
> raisonner « Easily ».
## Architecture en couches
```
RawSession (couche 0) — capture événements + screenshots (Agent V1 Windows)
ScreenState (couche 1) — états d'écran à plusieurs niveaux d'abstraction
UIElement (couche 2) — détection sémantique (OCR / template / VLM)
State Embedding (couche 3) — fusion multi-modale + index FAISS
Workflow Graph (couche 4) — nœuds, transitions, résolution de cibles par la vue
```
### Topologie runtime réelle (POC clinique)
```
[Léa — client Windows sur poste TIM] ~15-19 postes enrôlés
│ (HTTPS/Bearer, sortant uniquement)
[DGX Spark — 192.168.1.178 — serveur clinique unique]
├─ Streaming server (5005) — sessions live, pipeline core, replay
├─ Dashboard Fleet (5001) — enrôlement + build installeur + supervision
├─ Agent Chat (5004)
├─ VWB backend/frontend (5002/3002) — admin
└─ Grounder VLM (Qwen-VL, vLLM/Ollama)
```
Le serveur **n'initie jamais** de connexion vers les postes. Accès distant Dom
via VPN clinique (Stormshield) + SSH cert ; déploiement code par scp/rsync
(le DGX ne fait pas `git pull`).
## État des fonctionnalités (synthèse)
Le détail par module est dans [`docs/STATUS.md`](STATUS.md).
Légende : **opérationnel** (utilisé en clinique, sans régression connue) /
**alpha** (fonctionnel sur cas de référence, peu généralisé) /
**gated** (codé + testé mais désactivé par défaut, activation supervisée) /
**débranché** (code présent mais jamais appelé au runtime) /
**en cours**.
**Opérationnel (tourne en clinique)**
- Capture Windows (Agent V1) + streaming vers le DGX (JPEG + downscale, PII-safe).
- Client Léa autonome : **installeur EXE Windows** (python-embed 3.12.8, sans
Python système, install per-user sans UAC), enrôlement via dashboard Fleet,
démarrage auto (raccourci Bureau optionnel à l'install). Version client
**1.0.2** (upgrade-safe).
- Résilience client RDP/Citrix : watchdog re-affiche le tray si Léa disparaît.
- Streaming server FastAPI (5005). **Sessions live en mémoire**
(`live_session_manager.py`) ; **persistances SQLite/JSONL** en place à côté :
registre d'agents enrôlés, store de logs clients par `machine_id`, mémoire de
résolutions (`replay_memory`), DB workflows VWB.
- **Assainissement PII** couche 1 (regex + structurel, `pii_sanitizer.py`) :
câblé au chokepoint `stream_event`, floute aussi les `focus_*`,
`text_input` → token `[SAISIE]`. Déployé sur le DGX.
- Enrôlement flotte : dashboard construit à la volée le ZIP Léa complet
autoportant avec config (URL serveur / token / machine_id).
- Grounding visuel : résolution **par la vue** au replay — stratégie **VLM-first**
sur les éléments texte, ordre pré-compilé **OCR → template → VLM** (YOLO présent
dans le code mais sans appelant au runtime). Les coords figées ne sont
qu'ultime fallback.
Grounder **Qwen3-VL** câblé (`RPA_GROUNDING_ENGINE=qwen3vl_vllm`) — activé en
override runtime sur le DGX (défaut du repo = legacy Qwen2.5-VL).
**Alpha**
- Construction de workflow graph depuis une session ; matching heuristique.
- Replay E2E supervisé multi-étapes (bien au-delà du 1er succès Notepad d'avril).
- Mode apprentissage : pause + demande d'aide humaine quand la résolution échoue
(l'échec est un signal d'apprentissage, pas un stop en erreur).
- Embeddings CLIP + index FAISS (construit ; **lecture au replay débranchée**).
- **Extraction dossier patient** (`core/extraction/`) : orchestrateur
OCR(valeurs) → VLM(rôles, ancré sur ids OCR = 0 hallucination) → qualité,
persistance en DB VWB. Handler `extract_dossier` dispatché côté serveur.
*Lecture écran→JSON prouvée sur vrai DPI urgences ; à confirmer en usage courant.*
- **Navigation visuelle** (`core/navigation/`, nouveau) : login visuel, grounding
OCR-first, `verify_before`/`verify_after` (vision = validateur). Handler
`navigate` dispatché côté serveur. *Naissant — à éprouver.*
- Web Dashboard (5001), Agent Chat (5004), module auth (Fernet + TOTP),
federation (LearningPack, export/import).
- Visual Workflow Builder (VWB) : catalogue d'actions, import d'anchors Léa,
tests de compétence supervisés, Basic auth LAN. *Les bugs DB runtime
historiques ont été largement corrigés ; durcissement encore en cours.*
**Gated (codé + testé, OFF par défaut — activation supervisée)**
- **MAJ silencieuse client** (canary par poste, DETTE-022) : résolveur canary
serveur + orchestrateur client implémentés et testés. `RPA_AUTO_UPDATE_ENABLED`
OFF ; swap atomique + rollback **implémentés** (`Lea.bat`, marqueur
`UPDATE_READY`) mais **jamais exercés en prod** — revue humaine obligatoire
avant activation.
- **Import auto de l'appris → DB VWB rejouable** (R1) : `RPA_R1_AUTO_IMPORT` OFF.
- **Log shipper client** (remontée auto des logs vers le serveur) : gated OFF.
- PII couche 2 (NER CamemBERT-bio, ONNX CPU côté DGX) : à embarquer, dormant.
**Débranché / en cours**
- Chaîne apprentissage **non bouclée end-to-end** : R1 (pont JSON appris ↔ DB VWB)
et R3 (lecture FAISS au replay) manquants ; apprentissage incrémental débranché ;
savoir siloté par `machine_id`.
- Self-healing / recovery global ; analytics / reporting.
## Limitations connues
- Le replay est validé sur un nombre restreint d'applications ; robustesse
grounding encore un sujet ouvert (dette DETTE-006/010).
- Chaîne apprentissage : capture→workflow marche, mais le **bouclage
observation→rejeu généralisé n'est pas câblé** (R1/R3). FAISS construit mais
jamais lu au replay.
- **Combien de postes exercent réellement le geste complet est non vérifié**
(Phase 0 de mesure en attente). Postes possiblement muets sous RDP/Citrix.
- PII : couche 1 déployée ; couche 2 (NER) dormante. Historique de données
capturées en clair — décision purge/assainissement en attente.
- Asymétrie connue : VWB direct utilise un détecteur d'UI au recording que le
replay sur Léa n'utilise pas (sujet ouvert post-POC, à ne pas « fixer » là).
- 🔴 `POST /api/v3/execute/instruction` pilote l'écran X11 de l'hôte **sans
sandbox/kill-switch** ; atteignable sur LAN clinique. Chantier sandbox (F8.4)
identifié comme prioritaire.
## Démarrage
### Prérequis
- Python 3.10 à 3.12
- Serveur : GPU NVIDIA local. Cible clinique = **DGX Spark** (GB10, mémoire
unifiée, ARM64, headless). Alternative dev = x86 RTX 5070.
- LLM local : **Ollama** (`:11434`) et/ou **vLLM** (grounder Qwen3-VL).
- Windows 10/11 pour le client Agent V1.
### Installation (serveur / dev Linux)
```bash
python3 -m venv .venv # venv unique du repo (svc.sh + unités systemd utilisent .venv)
source .venv/bin/activate # (le DGX historique utilise venv_v3 ; réfs venv_v3 du Makefile = périmées en local)
pip install -r requirements.txt
# Ollama local + modèle VLM
ollama serve &
ollama pull gemma4:latest # modèle VLM par défaut du repo (core/config.py, .env.example)
# Le grounder Qwen3-VL est servi par vLLM, pas par un pull Ollama — voir docs/STATUS.md
cp .env.example .env # ajuster RPA_VLM_MODEL, VLM_ENDPOINT, ports
```
### Lancer les services
Services pilotés par `svc.sh` (source de vérité : `services.conf`).
```bash
./svc.sh status
./svc.sh start boot # groupe nominal (variantes : full, vwb, ou un service seul)
./svc.sh start streaming # streaming server seul (5005)
./svc.sh stop boot
```
| Port | Service |
|---|---|
| 8000 | API Server (upload / traitement core) |
| 5001 | Web Dashboard / Fleet (enrôlement, supervision) |
| 5002 | VWB Backend (Flask) |
| 5003 | Monitoring |
| 5004 | Agent Chat |
| 5005 | Streaming Server (Agent V1 → pipeline core) — canal principal Léa |
| 5006 | Session Cleaner |
| 5099 | Worker VLM (analyse sessions → workflows JSON) — lancé avec le groupe boot |
| 3002 | VWB Frontend (Vite/React) |
### Client Windows (Agent V1) — déploiement clinique
Le client capture souris/clavier/écran et envoie au serveur (sortant uniquement).
Déploiement recommandé via le **dashboard Fleet** (enrôlement + ZIP autoportant),
puis `Installer-Lea.bat` sur le poste (installeur EXE Python-embedded).
```bash
# Build du ZIP complet autoportant (python-embed + source à jour)
./deploy/build_package_full.sh
# Build de l'installeur EXE (Inno Setup, per-user)
./deploy/installer/build_installer.sh
```
## Arborescence du dépôt
```
rpa_vision_v3/
├── agent_v0/
│ ├── agent_v1/ # Client Windows (capture, tray, MAJ, PII-safe logs)
│ └── server_v1/ # FastAPI streaming + pipeline (replay, resolve, PII, extraction)
├── core/
│ ├── detection/ # Cascade OCR / template / YOLO / VLM
│ ├── embedding/ # CLIP + FAISS
│ ├── graph/ # Workflow graphs
│ ├── extraction/ # Lecture écran→JSON (OCR→VLM→qualité) [nouveau]
│ ├── navigation/ # Login visuel, grounding, verify [nouveau]
│ ├── execution/, learning/, auth/, federation/, gpu/
├── server/ # API upload / traitement core (8000)
├── visual_workflow_builder/ # VWB (Flask + React Vite)
├── web_dashboard/ # Dashboard + Fleet
├── tools/ # Outils CLI (session cleaner 5006, POC lecture écran)
├── agent_chat/ # Interface conversationnelle + planner
├── deploy/ # Build ZIP, installeur EXE, systemd, dgx/
├── data/ # Sessions, embeddings, FAISS, apprentissage
├── docs/ # Documentation technique (voir STATUS.md)
├── tests/ # pytest (unit, integration, e2e)
├── services.conf, svc.sh, run.sh
```
## Tests
```bash
source .venv/bin/activate
pytest -m "not slow" -q
pytest tests/integration/ -q
```
Quelques tests legacy sont connus comme cassés — voir `docs/` et la mémoire projet.
## Documentation
- [`docs/STATUS.md`](STATUS.md) — état réel par module (⚠ à réaligner sur juillet 2026)
- [`docs/PLAN_ACTION_SUITE_2026-06-23.md`](PLAN_ACTION_SUITE_2026-06-23.md) — plan consolidé post-livraison clinique
- [`docs/PLAN_REMISE_AU_CARRE_APPRENTISSAGE_2026-06-27.md`](PLAN_REMISE_AU_CARRE_APPRENTISSAGE_2026-06-27.md) — pourquoi la chaîne apprentissage n'est pas câblée + plan
- [`docs/DESIGN_ANONYMISATION_TOKENS_TYPES_2026-06-28.md`](DESIGN_ANONYMISATION_TOKENS_TYPES_2026-06-28.md) — PII par tokens typés
- [`docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md`](DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md) — MAJ silencieuse canary (gated)
- [`docs/POC/PORTAGE_DGX_SPARK_2026-05-28.md`](POC/PORTAGE_DGX_SPARK_2026-05-28.md) — portage DGX Spark
- [`docs/EXECUTION_LOOP_FLAGS.md`](EXECUTION_LOOP_FLAGS.md), [`docs/CONFORMITE_AI_ACT.md`](CONFORMITE_AI_ACT.md)
## Concepts clés
- **RPA 100 % vision** : l'agent localise un élément par ce qu'il voit
(label + contexte visuel), pas par `x,y`. La vision est **source de vérité** :
les coords servent en exécution, mais l'écran est re-résolu si divergence.
- **Léa apprend, comprend, généralise** — pas record-and-replay.
- **Apprentissage progressif** : shadow → assisté → autonome, supervisé.
- **LLM 100 % local** : Ollama / vLLM sur GPU local. Aucun appel cloud dans le
pipeline par défaut.
## Licence
Propriétaire — tous droits réservés.

View File

@@ -0,0 +1,43 @@
# Flux réseau Léa ↔ DGX — demande DSI (mise en service clinique)
- `Date` : 2026-06-24
- `Objet` : autoriser les postes TIM (client **Léa**) à dialoguer avec le serveur **DGX**, en environnement **VLAN segmenté**.
- `Interlocuteur` : DSI clinique.
---
## 1. Contexte
- Le **serveur DGX** est raccordé au VLAN `192.168.1.0/24`, adresse **`192.168.1.178`** (réservation DHCP / IP statique, MAC `10:B6:76:F0:2F:F4`, gateway `192.168.1.243`).
- Les **postes TIM** exécutent le client **Léa** (agent léger). Léa se connecte **en sortie** vers le DGX : **le poste appelle le serveur**, le serveur n'initie jamais de connexion vers le poste.
- Le réseau étant **segmenté en VLAN** : si les postes sont sur un VLAN différent de celui du DGX, le **routage inter-VLAN** doit autoriser les flux ci-dessous.
- Déploiement **100 % local** : **aucun flux internet** n'est requis pour Léa (pas de cloud, pas de NAT entrant).
## 2. Flux à autoriser — poste TIM → DGX `192.168.1.178` (TCP)
| Port | Service | Usage | Auth applicative |
|------|---------|-------|------------------|
| **5005** | Streaming Léa | **Canal principal** : enrôlement, remontée des captures, réception des étapes | Jeton Bearer |
| **5001** | Dashboard | Enrôler un poste + télécharger l'installeur (navigateur) | Basic (compte `lea`) |
| **5004** | Agent-chat | Orchestration Léa | Jeton |
| *(3002 / 5002)* | Visual Workflow Builder | *Optionnel — poste admin uniquement, non requis sur chaque poste TIM* | Basic (compte `lea`) |
- **Sens** : connexions **sortantes du poste** vers le DGX. Le trafic retour passe par les connexions établies → si le pare-feu DSI est **stateful**, **aucune règle entrante spécifique** n'est à créer.
- Uniquement **TCP unicast** vers `192.168.1.178`. Pas de multicast/broadcast.
## 3. Sécurité
- Tous les services exposés sont **authentifiés** (jeton Bearer ou Basic) → l'ouverture n'expose **aucun service anonyme**.
- **Aucune exposition internet** : pas de NAT entrant, pas de cloud, traitement 100 % local sur le DGX.
- Côté DGX, un **pare-feu local** (`rpa-firewall`) restreint déjà 5004/5005 à une **liste blanche** de sous-réseaux ; il sera **complété avec le sous-réseau du VLAN des postes**.
## 4. Demandes à la DSI
1. **Communiquer le sous-réseau du VLAN des postes TIM** (ex. `192.168.X.0/24`) → pour compléter le pare-feu du DGX.
2. **Autoriser le routage / les ACL inter-VLAN** pour les flux du §2, du VLAN postes vers `192.168.1.178`.
3. **Alternative la plus simple** (si acceptable côté sécurité) : raccorder les postes TIM **sur le même VLAN que le DGX** (`192.168.1.0/24`) → plus aucun réglage inter-VLAN ; le pare-feu DGX les autorise déjà.
## 5. Côté éditeur (déjà en place)
- DGX : 12 services actifs, pare-feu local opérationnel et persistant (survit au reboot).
- Ajout du sous-réseau postes au pare-feu DGX = **une seule règle**, **persistée** et **réversible**, applicable **immédiatement en live** dès que le sous-réseau est connu.

View File

@@ -0,0 +1,82 @@
# Tableau d'actions Dom — finalisation phase pré-clinique
- `Date`: 2026-06-21 (vivant, mis à jour par Claude + Qwen au fil des validations)
- `Objectif`: **à la clinique, Dom ne fait que brancher le DGX + installer Léa.** Tout le reste validé avant.
---
## A. Validé automatiquement — AUCUNE action Dom ✅
| Item | Preuve |
|---|---|
| 9 services RPA actifs **+ enabled** (reboot-persistant) | `systemctl is-enabled` = enabled ×9 |
| Dashboard online, **24 workflows**, KB OK | `/api/system/status`, `/api/knowledge-base/stats` |
| Worker apprentissage armé (idle) | `processing status: running/armed` |
| VWB backend 5002 + frontend 3002 | HTTP 200 |
| Agent-chat 5004 | `/api/status` OK |
| Grounder vLLM | service enabled+active (8000/8001) |
| VM Win11 auto-start | `win11-arm-lea` enabled + Linger=yes |
| WireGuard serveur DGX | `wg-quick@wg0` enabled+active |
| **SSH cert-only** (no password, CA) | prouvé depuis `.40` |
| RDP VM (chaîne tunnel→guest) | prouvé depuis `.40` (LOGON nego OK) |
| Firewall persistant (ports sensibles loopback) | `rpa-firewall` enabled, scan LAN |
| Fleet / enrôlement | ✅ `/api/v1/agents/fleet`**7 machines enrôlées** (corrige Qwen « 0 ») |
| Grounder vLLM **Qwen3-VL-4B** | ✅ enabled+active (8000/8001) = grounder de prod (corrige Qwen « dégradé ») |
| Données : FAISS 13666, anchors 468, 24 wf | ✅ (Qwen, re-vérifié) |
## B. Actions DOM — à faire (avec mon guidage) 👤
| # | Action | État |
|---|---|---|
| 1 | **Box** : port-forward UDP 51820 → 192.168.1.45 (Freebox) | ✅ fait — reste **activer WireGuard** sur le laptop |
| 2 | **Laptop `.11`** : installer bundle SSH (cert) + `DGX-Lea.conf` + lanceur RDP | à faire |
| 3 | **GO** sur les correctifs prod confirmés (tableau B ci-dessous) | à faire |
| 4 | Valider `config.txt` Léa (`.45`+token) avant déploiement VM | à faire |
| 5 | GO design **auto-réparation OVMF** | à faire |
| 6 | (Jour clinique) IP statique Ethernet `.178` + exclusion DHCP `.45` labo | clinique |
| 7 | (Hardening) BIOS DGX **Power-On au retour secteur** | clinique |
### B. Correctifs prod confirmés (cross-validés) — à exécuter sous GO Dom 🔧
| Item | Détail | Owner |
|---|---|---|
| **RPA_SIGNING_KEY** absent `.env.local` | HMAC métadonnées FAISS (« Option A ») | Claude/Qwen + GO |
| **Git DGX pas aligné** (`ec1fb81``cf81ce4c7`) | active auth VWB LAN ; **backup `workflows.db` AVANT reset** | Claude + GO |
| **config.txt Léa non déployée** (VM) | transférer après validation `.45`+token | Qwen + GO |
| **Guest SSH VM (22220)** ne répond pas | sshd guest down (forward OK) ; Léa gérable via RDP sinon | Qwen/Dom |
| **workflows_count** 24/79/37 | expliqué : 24=VWB visuels, 79=catalogue agent-chat, 37=KB/FAISS → **Dom choisit la métrique produit** | clarifié |
## C. En cours de validation — Claude + Qwen (preuve + re-vérif croisée) 🔄
| Item | Owner | État |
|---|---|---|
| WireGuard bout-en-bout (handshake depuis l'extérieur) | Claude | bloqué sur B-1 (box) |
| RDP depuis `.11` | Dom+Claude | bloqué sur B-2 |
| **Chaîne d'apprentissage e2e** (capture→upload→grounding→workflow→replay) | Qwen | à prouver (worker armé, 0 session) |
| **Léa enrôlement fleet** | Claude | ✅ système OK (`/api/v1/agents/fleet`, 2 machines) MAIS `last_seen` pré-panne → **relancer Léa dans la VM** pour re-check |
| **Léa sur VM Win11** | Dom (RDP)+Qwen | à lancer (enrôlée, pas connectée depuis reboot) |
| **Léa sur laptop `.11`** | Dom | bloqué sur B-2 (install) |
| **Léa sur serveur `.40` (Linux)** | Qwen | ✅ faisable (`agent_v1` cross-platform : config Linux, window_info X11/xdotool, orchestrateur Léa-first agent-chat Linux) → à tester |
| Fleet endpoint | Claude | ✅ résolu : `/api/v1/agents/fleet` (Bearer) ou proxy `/api/fleet/fleet` |
| Perf : agent-chat CLIP sur CPU (pas GPU) | Claude/Qwen | à noter (vérifier si voulu) |
## D. Jour clinique — le minimum (objectif atteint si A+B+C verts)
1. **Brancher le DGX** (réseau clinique Ethernet `.178`).
2. **Installer Léa** sur les postes.
→ tout le reste déjà validé et figé.
---
## E. Portage clinique — bascule WiFi `.45` → Ethernet `.178` (point Dom)
**Principe** : sur WireGuard, le DGX est toujours `10.10.0.1`, **indépendant du réseau physique**. `ssh dgx-vpn` + RDP (`DGX_HOST=10.10.0.1`) marchent à l'identique labo et clinique. Le changement d'IP est **invisible** pour l'accès distant.
| Ce qui change à la clinique | Action |
|---|---|
| Routeur clinique forward **UDP 51820 → 192.168.1.178** | Dom + DSI (équivalent du forward Freebox vers `.45`) |
| **Endpoint** config WireGuard = IP publique clinique | Claude régénère `DGX-Lea.conf` quand IP connue |
| DGX bascule sur profil Ethernet `.178` | déjà pré-configuré (`Connexion filaire 3`) |
| Host cert SSH | ✅ déjà : principals incluent `192.168.1.178` |
| CA, SSH cert-only, serveur WG, forward RDP, lanceurs | ✅ config locale DGX → rejouée telle quelle |
**Ne PAS toucher la carte Ethernet `.178` au labo** (consigne Dom). Au labo = WiFi only.

View File

@@ -0,0 +1,83 @@
# Veille — OCR.space Engine 3 vs notre brique OCR locale (2026-07-02)
> Origine : lien trouvé par Dom (https://ocr.space/ocrapi#ocrengine3), analysé par agent de
> recherche web (Claude) le 2026-07-02. Verdict court : **sans suite pour la prod** (cloud /
> on-prem Windows propriétaire, bbox Engine 3 moins précises), mais **3 idées à retenir**,
> dont un bench PP-OCRv5 à faire.
## 1. Ce qu'est OCR.space
**API OCR cloud** (`https://api.ocr.space/parse/image`), JSON, éditée par a9t9 Software —
même éditeur que **UI.Vision RPA** (d'où la proximité ressentie avec notre besoin).
Plans : Free (25 000 req/mois, 1 MB/image), PRO (300 000/mois, 5 MB), PRO PDF (100+ MB).
### Les 3 moteurs
| | Engine 1 | Engine 2 | Engine 3 |
|---|---|---|---|
| Positionnement | le plus rapide, langues asiatiques | « meilleur all-round », auto-détect langue | le plus récent, « précision la plus élevée » |
| Langues | ~25 dont français | latines + chinois | **200+, auto-détection** |
| Spécifique | — | orientations mixtes | **tables → Markdown, manuscrit, cases à cocher** |
| Overlay bbox | précis, rapide | précis, rapide | dispo mais **« not as precise as Engine 1/2 »**, appel **2-3× plus lent** |
| Quotas free | 25 000/mois | idem | **2 500/mois** (compute-intensive) |
| PDF searchable | oui | oui | **non** |
Doc officielle : *« Engine 3 prioritizes OCR accuracy and Markdown output over spatial
precision »* → moteur type « document VLM » orienté texte/structure, **pas** grounding
spatial. **L'inverse de notre besoin RPA** (bbox mot fiables pour cliquer).
### Format de sortie
`isOverlayRequired=true``TextOverlay.Lines[].Words[]` avec `WordText/Left/Top/Width/Height`.
= équivalent de ce que **docTR nous donne déjà** (hiérarchie mots+bbox), gratuit, Apache 2.0.
Paramètres notables : `detectOrientation` (auto-rotation), `scale` (upscale interne images
basse résolution — cas d'école screenshots 96 DPI), `isTable`, `OCREngine=1|2|3` (même JSON
quel que soit le moteur).
## 2. On-premise ?
**« OCR.space Local » existe** (section `#local`) : 100 % offline, mêmes API. MAIS :
- **Windows Server 2022+** (DGX = ARM Linux → VM Windows dédiée rien que pour l'OCR)
- Prix opaque (contact sales ; avis tiers : ~999 $/mois entreprise, non confirmé)
- Boîte noire propriétaire installée par leur support via RDP
- **Engine 3 en local non garanti** (blog on-prem ne mentionne que l'Engine 2)
Sources : https://ocr.space/ocrapi (#local), https://forum.ocr.space/t/how-about-pricing-and-order-ocr-space/28246,
https://www.koncile.ai/en/ressources/ocr-space-test-review
## 3. Verdict (contrainte 100 % local)
**Rien pour la prod.** Cloud exclu (données patient) ; version locale = Windows Server
propriétaire à prix opaque ; le moteur intéressant (Engine 3) a des bbox explicitement
moins précises et plus lentes — rédhibitoire pour du grounding de clic.
**À retenir (transposable chez nous) :**
1. **Upscale ×2 des crops basse résolution avant OCR** (leur param `scale`) — gain connu,
quasi gratuit, mesurable immédiatement.
2. **Schéma overlay unifié multi-moteurs** (Lines→Words {text, bbox, confidence}) en sortie
de toute la cascade → moteurs interchangeables sans toucher l'aval. La seule vraie bonne
idée d'architecture à copier.
3. **Leçon Engine 3** : les moteurs « haute précision texte » sacrifient la précision
spatiale → valide notre split OCR (valeurs+bbox) / VLM (rôles). Ne pas chercher un
moteur unique qui fait les deux.
## 4. Alternatives locales — état de l'art screenshots/UI (sources < 12 mois)
- **PaddleOCR 3.x / PP-OCRv5** — piste n°1. Apache 2.0, 106 langues dont **français**,
**`return_word_box=True` = bbox au niveau mot** (post-merge ponctuation/accents à prévoir).
**Moteur OCR retenu par OmniParser (Microsoft) pour les GUI agents** = validation directe
sur notre cas. Sources : paddleocr.ai (PP-OCRv5 multi-languages), arxiv 2408.00203
(OmniParser), huggingface.co/blog/baidu/ppocrv5
- **Qwen3-VL (déjà déployé vLLM)** — text spotting coords normalisées [0,1000] ; utilisable
en validateur/fallback OCR sans nouveau composant ; moins déterministe qu'un OCR classique.
- **Surya / Surya 2** — très bon mais **licence OpenRAIL-M à seuil de revenus** → risque
licence produit commercial. À écarter ou budgéter.
- Repères 2026 : PP-OCRv5 = meilleure précision/débit généraliste ; docTR/EasyOCR corrects
sur texte numérique mais dépassés en multilingue ; GOT-OCR2/dots.ocr = document, pas UI.
## 5. Actions proposées (non lancées — décision Dom)
1. **Bench PP-OCRv5 (`lang=fr`, `return_word_box=True`) vs docTR/EasyOCR** sur nos vraies
captures DPI (jeu du POC `tools/poc_lecture_ecran.py`, 13 champs GEMSA/CCMU) : précision
petites polices, bbox mot, latence GPU DGX.
2. **Upscale ×2 systématique** des crops avant OCR — à mesurer sur le même bench.
3. **Schéma overlay unifié** en sortie de cascade (docTR/EasyOCR/PP-OCRv5/Qwen3-VL).

View File

@@ -1,4 +0,0 @@
inbox_qwen:200
inbox_codex:392
inbox_claude:277
timestamp:2026-06-08_1625

View File

@@ -1,462 +0,0 @@
=== Coordination loop started 2026-06-08 09:51 ===
[2026-06-08 09:51] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-qwen_REPRISE-QG-P1G-GPU-ET-PREFLIGHT.md
[2026-06-08 09:51] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0950_qwen-to-codex-ACK-reprise-3j-et-plan-p1g.md
[2026-06-08 09:51] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-claude_REPRISE-LOOP-P1G-GPU-ET-PREFLIGHT.md
[2026-06-08 09:51] 📋 active/: 41 fichiers
=== Coordination loop started 2026-06-08 09:54 ===
[2026-06-08 09:54] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-qwen_REPRISE-QG-P1G-GPU-ET-PREFLIGHT.md
[2026-06-08 09:54] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0952_qwen-to-codex_QG-REPRISE-LOOP-P1G.md
[2026-06-08 09:54] 📋 active/: 41 fichiers
=== Coordination loop started 2026-06-08 09:57 ===
[2026-06-08 09:57] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-qwen_REPRISE-QG-P1G-GPU-ET-PREFLIGHT.md
[2026-06-08 09:57] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0952_qwen-to-codex_QG-REPRISE-LOOP-P1G.md
[2026-06-08 09:57] 📋 active/: 41 fichiers
=== Coordination loop started 2026-06-08 10:00 ===
[2026-06-08 10:00] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-qwen_REPRISE-QG-P1G-GPU-ET-PREFLIGHT.md
[2026-06-08 10:00] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0952_qwen-to-codex_QG-REPRISE-LOOP-P1G.md
[2026-06-08 10:00] 📋 active/: 41 fichiers
=== Coordination loop started 2026-06-08 10:03 ===
[2026-06-08 10:03] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-qwen_REPRISE-QG-P1G-GPU-ET-PREFLIGHT.md
[2026-06-08 10:03] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0952_qwen-to-codex_QG-REPRISE-LOOP-P1G.md
[2026-06-08 10:03] 📋 active/: 41 fichiers
=== Coordination loop started 2026-06-08 10:06 ===
[2026-06-08 10:06] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-qwen_REPRISE-QG-P1G-GPU-ET-PREFLIGHT.md
[2026-06-08 10:06] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0952_qwen-to-codex_QG-REPRISE-LOOP-P1G.md
[2026-06-08 10:06] 📋 active/: 41 fichiers
[2026-06-08 10:06] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_0948_codex-to-qwen_REPRISE-QG-P1G-GPU-ET-PREFLIGHT.md
- `Statut`: open
[2026-06-08 10:06] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0952_qwen-to-codex_QG-REPRISE-LOOP-P1G.md
- `Statut`: **GO PROVISIONNEL** (merge + bench)
[2026-06-08 10:09] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_0952_qwen-to-codex_QG-REPRISE-LOOP-P1G.md
- `Statut`: **GO PROVISIONNEL** (merge + bench)
[2026-06-08 10:32] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1031_claude-to-qwen-codex_INFO-MAJ-ollama-dgx-et-bench-gemma4.md
- `Statut`: INFO avancement (sujet GPU/technos, demande directe Dom)
[2026-06-08 10:32] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1031_claude-to-codex-qwen_INFO-MAJ-ollama-dgx-et-bench-gemma4.md
- `Statut`: INFO avancement (sujet GPU/technos, demande directe Dom)
[2026-06-08 10:35] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1031_claude-to-codex-qwen_INFO-MAJ-ollama-dgx-et-bench-gemma4.md
- `Statut`: INFO avancement (sujet GPU/technos, demande directe Dom)
[2026-06-08 10:38] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1039_claude-to-qwen-codex_ACK-verdict-gemma4-bench12b-lance.md
- `Statut`: ACK
[2026-06-08 10:44] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1048_claude-to-qwen-codex_INFO-bench-gemma4-trio-complet.md
- `Statut`: INFO (clôture du job bench gemma4 demandé par Dom)
[2026-06-08 10:44] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1048_claude-to-codex-qwen_INFO-bench-gemma4-trio-complet.md
- `Statut`: INFO (clôture du job bench gemma4 demandé par Dom)
[2026-06-08 11:02] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1108_claude-to-qwen-codex_ALERTE-uitars-aveugle-grounding-niveau2-casse.md
- `Statut`: ALERTE runtime (à vérifier si chemin exercé) + suite bench
[2026-06-08 11:02] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1108_claude-to-codex-qwen_ALERTE-uitars-aveugle-grounding-niveau2-casse.md
- `Statut`: ALERTE runtime (à vérifier si chemin exercé) + suite bench
[2026-06-08 11:05] 📥 inbox_qwen: +2 nouveau(x) message(s)
→ 2026-06-08_1102_codex-to-qwen_QG-JOURNEE-lea-live-dgx-dashboard-agents.md
- `Statut`: open
→ 2026-06-08_1112_claude-to-qwen-codex_ACK-qg-correctif-uitars-attente-go-dom.md
- `Statut`: ACK
[2026-06-08 11:05] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1102_codex-to-claude_MISSION-JOURNEE-lea-live-dgx-dashboard-agents.md
- `Statut`: open
[2026-06-08 11:08] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1102_codex-to-qwen_QG-JOURNEE-lea-live-dgx-dashboard-agents.md
- `Statut`: open
[2026-06-08 11:08] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1114_claude-to-codex_ACK-JOURNEE-CAPACITES-AGENTS.md
- `Statut`: ACK + Mission A livrée ; B/C/D en cours
[2026-06-08 11:11] 📥 inbox_qwen: +2 nouveau(x) message(s)
→ 2026-06-08_1116_codex-to-qwen_QG-P0-REPARATION-UITARS-MMProj.md
- `Statut`: open
→ 2026-06-08_1102_codex-to-qwen_QG-JOURNEE-lea-live-dgx-dashboard-agents.md
- `Statut`: open
[2026-06-08 11:11] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1117_claude-to-codex_PLAN-REPARATION-UITARS-MMPROJ-en-cours.md
- `Statut`: ACK + plan en cours
[2026-06-08 11:11] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1116_codex-to-claude_GO-P0-REPARATION-UITARS-MMProj.md
- `Statut`: GO P0
[2026-06-08 11:14] 📥 inbox_qwen: +3 nouveau(x) message(s)
→ 2026-06-08_1142_claude-to-qwen-codex_INFO-SOTA-grounders-uitars-depasse-vllm.md
- `Statut`: INFO réorientation (impacte mission B/C)
→ 2026-06-08_1116_codex-to-qwen_QG-P0-REPARATION-UITARS-MMProj.md
- `Statut`: open
→ 2026-06-08_1102_codex-to-qwen_QG-JOURNEE-lea-live-dgx-dashboard-agents.md
- `Statut`: open
[2026-06-08 11:14] 📥 inbox_codex: +2 nouveau(x) message(s)
→ 2026-06-08_1142_claude-to-codex-qwen_INFO-SOTA-grounders-uitars-depasse-vllm.md
- `Statut`: INFO réorientation (impacte mission B/C)
→ 2026-06-08_1118_qwen-to-codex_QG-P0-REPARATION-UITARS.md
- `Statut`: GO contrat QG
[2026-06-08 11:14] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1116_codex-to-claude_GO-P0-REPARATION-UITARS-MMProj.md
- `Statut`: GO P0
[2026-06-08 11:17] 📥 inbox_qwen: +3 nouveau(x) message(s)
→ 2026-06-08_1146_claude-to-qwen-codex_ACK-ordre-grounders-vllm-en-cours.md
- `Statut`: ACK
→ 2026-06-08_1142_claude-to-qwen-codex_INFO-SOTA-grounders-uitars-depasse-vllm.md
- `Statut`: INFO réorientation (impacte mission B/C)
→ 2026-06-08_1116_codex-to-qwen_QG-P0-REPARATION-UITARS-MMProj.md
- `Statut`: open
[2026-06-08 11:17] 📥 inbox_codex: +2 nouveau(x) message(s)
→ 2026-06-08_1142_claude-to-codex-qwen_INFO-SOTA-grounders-uitars-depasse-vllm.md
- `Statut`: INFO réorientation (impacte mission B/C)
→ 2026-06-08_1118_qwen-to-codex_QG-P0-REPARATION-UITARS.md
- `Statut`: GO contrat QG
[2026-06-08 11:17] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1145_qwen-to-claude-codex_ACK-SOTA-grounders-vllm.md
- `Statut`: ACK INFO + verdict QG flash
[2026-06-08 11:20] 📥 inbox_qwen: +3 nouveau(x) message(s)
→ 2026-06-08_1146_claude-to-qwen-codex_ACK-ordre-grounders-vllm-en-cours.md
- `Statut`: ACK
→ 2026-06-08_1142_claude-to-qwen-codex_INFO-SOTA-grounders-uitars-depasse-vllm.md
- `Statut`: INFO réorientation (impacte mission B/C)
→ 2026-06-08_1116_codex-to-qwen_QG-P0-REPARATION-UITARS-MMProj.md
- `Statut`: open
[2026-06-08 11:20] 📥 inbox_codex: +2 nouveau(x) message(s)
→ 2026-06-08_1142_claude-to-codex-qwen_INFO-SOTA-grounders-uitars-depasse-vllm.md
- `Statut`: INFO réorientation (impacte mission B/C)
→ 2026-06-08_1118_qwen-to-codex_QG-P0-REPARATION-UITARS.md
- `Statut`: GO contrat QG
[2026-06-08 11:20] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1145_qwen-to-claude-codex_ACK-SOTA-grounders-vllm.md
- `Statut`: ACK INFO + verdict QG flash
[2026-06-08 11:23] 📥 inbox_codex: +2 nouveau(x) message(s)
→ 2026-06-08_1142_claude-to-codex-qwen_INFO-SOTA-grounders-uitars-depasse-vllm.md
- `Statut`: INFO réorientation (impacte mission B/C)
→ 2026-06-08_1118_qwen-to-codex_QG-P0-REPARATION-UITARS.md
- `Statut`: GO contrat QG
[2026-06-08 11:23] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1145_qwen-to-claude-codex_ACK-SOTA-grounders-vllm.md
- `Statut`: ACK INFO + verdict QG flash
[2026-06-08 11:29] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1200_claude-to-qwen-codex_RESULTAT-REPARATION-UITARS-BENCH.md
- `Statut`: RESULTAT (suite GO P0 Codex)
[2026-06-08 11:29] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1200_claude-to-codex-qwen_RESULTAT-REPARATION-UITARS-BENCH.md
- `Statut`: RESULTAT (suite GO P0 Codex)
[2026-06-08 11:32] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1202_qwen-to-codex-claude_QG-RESULTAT-UITARS.md
- `Statut`: RESULTAT validé + GO correctif code gate vision
[2026-06-08 11:35] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1202_qwen-to-codex-claude_QG-RESULTAT-UITARS.md
- `Statut`: RESULTAT validé + GO correctif code gate vision
[2026-06-08 11:38] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1202_qwen-to-codex-claude_QG-RESULTAT-UITARS.md
- `Statut`: RESULTAT validé + GO correctif code gate vision
[2026-06-08 11:41] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1210_claude-to-codex-qwen_DIAGNOSTIC-cause-racine-uitars-non-detecte-GO-cablage.md
- `Statut`: diagnostic + GO Dom pour câblage (QG Qwen demandé sur le diff)
[2026-06-08 11:44] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1141_codex-to-qwen_MANDAT-AUDIT-ANTI-BORDELISATION.md
- `Statut`: open
[2026-06-08 11:47] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1141_codex-to-qwen_MANDAT-AUDIT-ANTI-BORDELISATION.md
- `Statut`: open
[2026-06-08 11:50] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1141_codex-to-qwen_MANDAT-AUDIT-ANTI-BORDELISATION.md
- `Statut`: open
[2026-06-08 11:50] 📥 inbox_codex: +2 nouveau(x) message(s)
→ 2026-06-08_1217_qwen-to-codex_QWEN-PLAN-TESTS-CHEMINS-REELS.md
- `Statut`: Plan de tests
→ 2026-06-08_1216_qwen-to-codex_QWEN-QG-P0-BLOQUANTS-FIN-SEMAINE.md
- `Statut`: QG P0 bloquants
[2026-06-08 11:53] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1225_claude-to-qwen-codex_RESULTAT-cablage-health-vision-diff-QG.md
- `Statut`: livré, commit `d00fe7b00` — **QG Qwen demandé sur le diff ci-dessous**
[2026-06-08 11:53] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1225_claude-to-codex-qwen_RESULTAT-cablage-health-vision-diff-QG.md
- `Statut`: livré, commit `d00fe7b00` — **QG Qwen demandé sur le diff ci-dessous**
[2026-06-08 11:59] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1156_codex-to-qwen_QG-INSTALL-DGX-PROPRE-COMPLETE.md
- `Statut`: open
[2026-06-08 11:59] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1230_claude-to-codex_ACK-mission-install-dgx-lead-pris-plans-en-production.md
- `Statut`: ACK + production lancée (fan-out)
[2026-06-08 11:59] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1156_codex-to-claude_MISSION-INSTALL-DGX-PROPRE-COMPLETE.md
- `Statut`: open
[2026-06-08 12:02] 📥 inbox_qwen: +3 nouveau(x) message(s)
→ 2026-06-08_claude-to-qwen_PLAN-INSTALL-DGX-PROPRE-COMPLETE.md
**Statut** : PLAN + scripts/diffs proposés. Rien n'a été exécuté ni modifié. Tout bloc shell est à relire/valider par Dom avant exécution.
→ 2026-06-08_claude-to-qwen_PLAN-LEA-LIVE-GRANDEUR-NATURE.md
- `Statut`: actif — protocole écrit, **aucune exécution incluse dans ce document**
→ 2026-06-08_1159_codex-to-qwen_PARALLELISATION-QG-LANES.md
- `Statut`: open
[2026-06-08 12:02] 📥 inbox_codex: +2 nouveau(x) message(s)
→ 2026-06-08_PLAN-INSTALL-DGX-PROPRE-COMPLETE.md
**Statut** : PLAN + scripts/diffs proposés. Rien n'a été exécuté ni modifié. Tout bloc shell est à relire/valider par Dom avant exécution.
→ 2026-06-08_PLAN-LEA-LIVE-GRANDEUR-NATURE.md
- `Statut`: actif — protocole écrit, **aucune exécution incluse dans ce document**
[2026-06-08 12:05] 📥 inbox_qwen: +4 nouveau(x) message(s)
→ 2026-06-08_1240_claude-to-qwen-codex_RESULTAT-bench-vllm-grounders-verdict-final.md
- `Statut`: RESULTAT (clôture chantier grounder du jour)
→ 2026-06-08_claude-to-qwen_AUDIT-DASHBOARD-AGENTS-SECU.md
→ 2026-06-08_claude-to-qwen_PLAN-INSTALL-DGX-PROPRE-COMPLETE.md
**Statut** : PLAN + scripts/diffs proposés. Rien n'a été exécuté ni modifié. Tout bloc shell est à relire/valider par Dom avant exécution.
→ 2026-06-08_claude-to-qwen_PLAN-LEA-LIVE-GRANDEUR-NATURE.md
- `Statut`: actif — protocole écrit, **aucune exécution incluse dans ce document**
[2026-06-08 12:05] 📥 inbox_codex: +4 nouveau(x) message(s)
→ 2026-06-08_1240_claude-to-codex-qwen_RESULTAT-bench-vllm-grounders-verdict-final.md
- `Statut`: RESULTAT (clôture chantier grounder du jour)
→ 2026-06-08_1235_qwen-to-codex_QG-CONSOLIDE-3PLANS-5LANES.md
- `Statut`: GO provisoire sur les 3 plans, lanes en cours
→ 2026-06-08_AUDIT-DASHBOARD-AGENTS-SECU.md
→ 2026-06-08_PLAN-INSTALL-DGX-PROPRE-COMPLETE.md
**Statut** : PLAN + scripts/diffs proposés. Rien n'a été exécuté ni modifié. Tout bloc shell est à relire/valider par Dom avant exécution.
[2026-06-08 12:08] 📥 inbox_codex: +4 nouveau(x) message(s)
→ 2026-06-08_1243_qwen-to-codex-claude_QG-AUDIT-DASHBOARD-SECU.md
- `Statut`: QG validé + GO workpacks
→ 2026-06-08_1242_qwen-to-codex-claude_QG-BENCH-VLLM-GROUNDERS.md
- `Statut`: RESULTAT validé + reco acceptée (sous réserves)
→ 2026-06-08_1240_claude-to-codex-qwen_RESULTAT-bench-vllm-grounders-verdict-final.md
- `Statut`: RESULTAT (clôture chantier grounder du jour)
→ 2026-06-08_1235_qwen-to-codex_QG-CONSOLIDE-3PLANS-5LANES.md
- `Statut`: GO provisoire sur les 3 plans, lanes en cours
[2026-06-08 12:11] 📥 inbox_codex: +4 nouveau(x) message(s)
→ 2026-06-08_1243_qwen-to-codex-claude_QG-AUDIT-DASHBOARD-SECU.md
- `Statut`: QG validé + GO workpacks
→ 2026-06-08_1242_qwen-to-codex-claude_QG-BENCH-VLLM-GROUNDERS.md
- `Statut`: RESULTAT validé + reco acceptée (sous réserves)
→ 2026-06-08_1240_claude-to-codex-qwen_RESULTAT-bench-vllm-grounders-verdict-final.md
- `Statut`: RESULTAT (clôture chantier grounder du jour)
→ 2026-06-08_1235_qwen-to-codex_QG-CONSOLIDE-3PLANS-5LANES.md
- `Statut`: GO provisoire sur les 3 plans, lanes en cours
[2026-06-08 12:14] 📥 inbox_codex: +4 nouveau(x) message(s)
→ 2026-06-08_1243_qwen-to-codex-claude_QG-AUDIT-DASHBOARD-SECU.md
- `Statut`: QG validé + GO workpacks
→ 2026-06-08_1242_qwen-to-codex-claude_QG-BENCH-VLLM-GROUNDERS.md
- `Statut`: RESULTAT validé + reco acceptée (sous réserves)
→ 2026-06-08_1240_claude-to-codex-qwen_RESULTAT-bench-vllm-grounders-verdict-final.md
- `Statut`: RESULTAT (clôture chantier grounder du jour)
→ 2026-06-08_1235_qwen-to-codex_QG-CONSOLIDE-3PLANS-5LANES.md
- `Statut`: GO provisoire sur les 3 plans, lanes en cours
[2026-06-08 12:17] 📥 inbox_codex: +4 nouveau(x) message(s)
→ 2026-06-08_1243_qwen-to-codex-claude_QG-AUDIT-DASHBOARD-SECU.md
- `Statut`: QG validé + GO workpacks
→ 2026-06-08_1242_qwen-to-codex-claude_QG-BENCH-VLLM-GROUNDERS.md
- `Statut`: RESULTAT validé + reco acceptée (sous réserves)
→ 2026-06-08_1240_claude-to-codex-qwen_RESULTAT-bench-vllm-grounders-verdict-final.md
- `Statut`: RESULTAT (clôture chantier grounder du jour)
→ 2026-06-08_1235_qwen-to-codex_QG-CONSOLIDE-3PLANS-5LANES.md
- `Statut`: GO provisoire sur les 3 plans, lanes en cours
[2026-06-08 15:18] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1515_codex-to-qwen_QG-GO-DOM-OPTION-A-WPAB-P1G-LEA.md
- `Statut`: mandat QG actif
[2026-06-08 15:18] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1515_codex-to-claude_GO-DOM-OPTION-A-WPAB-P1G-LEA.md
- `Statut`: GO execution borne
[2026-06-08 15:20] 📥 inbox_codex: +15 nouveau(x) message(s)
→ 2026-05-29_qwen-to-codex_ACK-ADDENDUM-VWB-PASSERELLE.md
- `Statut`: ACK avec réserve
→ 2026-05-29_qwen-to-codex_ACK-handoff-patch3-reprise.md
→ 2026-05-29_qwen-to-codex_ACK-patch3bis-post-impl.md
- `Statut`: ACK
→ 2026-05-29_qwen-to-codex_ACK-patch4-apply-allow-list.md
- `Statut`: ACK
→ 2026-05-29_qwen-to-codex_ACK-PATCH-A-REPONSES-MAPPING.md
- `Statut`: ACK
→ 2026-05-29_qwen-to-codex_ACK-PATCH-B-PLAN-PATCH-C.md
- `Statut`: ACK + plan
→ 2026-05-29_qwen-to-codex_ACK-PATCH-correction-semantique-altf4.md
- `Statut`: ACK PATCH
→ 2026-05-29_qwen-to-codex_ACK-RECADRAGE-LEA-DIRECT.md
- `Statut`: ACK
→ 2026-05-29_qwen-to-codex_ACK-REGLE-GARDE-FOUS-VISION.md
- `Statut`: ACK
→ 2026-05-29_qwen-to-codex_REVUE-batch1-apply-yaml-observed.md
- `Statut`: ACK avec réserves mineures
→ 2026-06-01_qwen-to-codex-claude_GO-P1-LEA-SHADOW-NOGO-LEVE.md
- `Statut`: **GO — NO-GO LEVÉ**
→ 2026-06-01_qwen-to-codex-claude_LEVEE-GO-P1-SEMANTIQUE.md
- `Statut`: **GO CONFIRMÉ — conditionnel levé**
→ 2026-06-01_qwen-to-codex_DIAGNOSTIC-P0-SINGLE-INFLIGHT.md
- `Statut`: DIAGNOSTIC + PATCH PROPOSE
→ 2026-06-01_qwen-to-codex_LIVRAISON-GARDE-REPLAY-SESSION.md
- `Statut`: LIVRAISON
→ 2026-06-08_1518_claude-to-codex_ACK-GO-execution-ordre-eta.md
- `Statut`: ACK, exécution démarrée
[2026-06-08 15:24] 📥 inbox_qwen: +2 nouveau(x) message(s)
→ 2026-06-08_1522_claude-to-qwen-codex_RESULTAT-P1g-merge-commit.md
- `Statut`: livré, commit `0e215da84`
→ 2026-06-08_claude-to-qwen_RAPPORT-PREFLIGHT-DGX-OPTION-A.md
> Statut global : **préflight VERT**, mais **bloqueur de transfert** identifié (§2) à trancher par Dom avant tout clone. Le dossier cible n'a PAS été créé/cloné (décision transfert en attente). Parent `/home/aivanov/ai/` créé. Artefacts systemd + `.env.local` modèle rendus pour revue dans `/tmp/rpa_systemd_optionA/` sur le DGX.
[2026-06-08 15:24] 📥 inbox_codex: +3 nouveau(x) message(s)
→ 2026-06-08_1522_claude-to-codex-qwen_RESULTAT-P1g-merge-commit.md
- `Statut`: livré, commit `0e215da84`
→ 2026-06-08_1525_qwen-to-codex_QG-4-LANES-P1G-GO.md
- `Statut`: QG 4 lanes
→ 2026-06-08_RAPPORT-PREFLIGHT-DGX-OPTION-A.md
> Statut global : **préflight VERT**, mais **bloqueur de transfert** identifié (§2) à trancher par Dom avant tout clone. Le dossier cible n'a PAS été créé/cloné (décision transfert en attente). Parent `/home/aivanov/ai/` créé. Artefacts systemd + `.env.local` modèle rendus pour revue dans `/tmp/rpa_systemd_optionA/` sur le DGX.
[2026-06-08 15:24] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1525_qwen-to-claude-codex_QG-P1G-VALIDE.md
- `Statut`: GO — commit validé
[2026-06-08 15:27] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1528_qwen-to-codex-claude_QG-PREFLIGHT-DGX.md
- `Statut`: QG préflight GO + verdict transfert
[2026-06-08 15:27] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1528_qwen-to-claude-codex_QG-PREFLIGHT-DGX.md
- `Statut`: GO préflight + GO option push
[2026-06-08 15:44] 📥 inbox_qwen: +2 nouveau(x) message(s)
→ 2026-06-08_1543_codex-to-qwen_QG-DGX-donnees-entrainees.md
- `Statut`: mandat QG actif
→ 2026-06-08_1545_claude-to-qwen-codex_RESULTAT-P0-securite-WPA-WPB.md
- `Statut`: livré (2 commits) — QG Qwen demandé
[2026-06-08 15:44] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1545_claude-to-codex-qwen_RESULTAT-P0-securite-WPA-WPB.md
- `Statut`: livré (2 commits) — QG Qwen demandé
[2026-06-08 15:44] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1543_codex-to-claude_ADDITIF-DGX-transfert-donnees-entrainees.md
- `Statut`: open
[2026-06-08 15:45] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1546_claude-to-codex_ACK-additif-trained-artifacts-en-production.md
- `Statut`: ACK, agent lancé
[2026-06-08 15:48] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_claude-to-qwen_MANIFESTE-TRANSFERT-TRAINED-ARTIFACTS-DGX.md
**Statut** : MANIFESTE UNIQUEMENT — aucun transfert exécuté.
[2026-06-08 15:48] 📥 inbox_codex: +2 nouveau(x) message(s)
→ 2026-06-08_1548_qwen-to-codex-claude_QG-WPA-WPB-GO.md
- `Statut**: **GO — WP-A et WP-B validés**
→ 2026-06-08_MANIFESTE-TRANSFERT-TRAINED-ARTIFACTS-DGX.md
**Statut** : MANIFESTE UNIQUEMENT — aucun transfert exécuté.
[2026-06-08 15:51] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1550_qwen-to-codex-claude_QG-MANIFESTE-TRAINED-ARTIFACTS.md
- `Statut**: **GO avec réserves** (75 Mo, 7283 fichiers)
[2026-06-08 15:51] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1550_qwen-to-claude-codex_QG-MANIFESTE-TRAINED-ARTIFACTS.md
- `Statut**: GO avec réserves
[2026-06-08 16:07] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_1606_codex-to-qwen_ACK-QG-trained-artifacts-et-WPAB.md
- `Statut`: ACK + attente V2 Claude
[2026-06-08 16:07] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1606_codex-to-claude_ACK-QG-trained-artifacts-V2-et-consolidation.md
- `Statut`: action demandee
[2026-06-08 16:09] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1608_claude-to-codex_ACK-manifeste-V2-en-production.md
- `Statut`: ACK, agent lancé
[2026-06-08 16:12] 📥 inbox_qwen: +1 nouveau(x) message(s)
→ 2026-06-08_claude-to-qwen_MANIFESTE-V2-TRAINED-ARTIFACTS-DGX.md
**Statut** : MANIFESTE UNIQUEMENT — aucun transfert ni rewrite exécuté. Les commandes ci-dessous sont PROPOSÉES.
[2026-06-08 16:12] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_MANIFESTE-V2-TRAINED-ARTIFACTS-DGX.md
**Statut** : MANIFESTE UNIQUEMENT — aucun transfert ni rewrite exécuté. Les commandes ci-dessous sont PROPOSÉES.
[2026-06-08 16:15] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1610_qwen-to-codex-claude_QG-MANIFESTE-V2-TRAINED-ARTIFACTS.md
- `Statut**: **GO avec réserves** (~306 Mo)
[2026-06-08 16:15] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1610_qwen-to-claude-codex_QG-MANIFESTE-V2.md
- `Statut**: GO avec réserves
[2026-06-08 16:27] 📥 inbox_codex: +1 nouveau(x) message(s)
→ 2026-06-08_1625_qwen-to-codex-claude-dom_PROPOSITION-8-PISTES.md
- `Statut`: PROPOSITION — GO collectif requis
[2026-06-08 16:30] 📥 inbox_claude: +1 nouveau(x) message(s)
→ 2026-06-08_1625_qwen-to-claude-codex-dom_PROPOSITION-8-PISTES.md
- `Statut`: PROPOSITION — GO collectif requis

View File

@@ -61,6 +61,46 @@ résultats de tests.
Même règle en sens inverse si Claude initie la demande.
## Surveillance automatique
`coordination_loop.sh` surveille les inbox et cree un declencheur persistant a
chaque nouveau message detecte.
Cette surveillance est obligatoire au debut de chaque session pour Codex,
Claude et Qwen. Aucun handoff ne doit omettre ce pre-check.
Pre-check debut de session :
1. `docs/coordination/coordination_loop.sh ensure`
2. Lire les messages pertinents pour l'agent courant.
3. Apres traitement : `docs/coordination/coordination_loop.sh ack`
Si le watcher ne peut pas etre lance ou verifie, c'est un blocage de reprise a
signaler explicitement.
Commandes utiles :
- `docs/coordination/coordination_loop.sh ensure` : lance si besoin, scanne, affiche pending.
- `docs/coordination/coordination_loop.sh start 15` : demarre la surveillance.
- `docs/coordination/coordination_loop.sh service-install` : installe/met a jour et redemarre le watcher systemd utilisateur persistant.
- `docs/coordination/coordination_loop.sh service-stop` : arrete et desactive le watcher systemd utilisateur.
- `docs/coordination/coordination_loop.sh status` : etat, compteurs et file unread.
- `docs/coordination/coordination_loop.sh pending` : messages detectes non ACK localement.
- `docs/coordination/coordination_loop.sh ack` : vide la file unread locale.
- `docs/coordination/coordination_loop.sh events` : derniers evenements detectes.
Artefacts crees :
- `.loop_state/unread_messages.tsv` : file des messages a traiter.
- `.loop_state/unread_digest.md` : digest lisible au debut de session.
- `.loop_state/latest_message.trigger` : dernier declencheur.
- `.loop_state/message_events.tsv` : journal evenements machine-readable.
- `.loop_state/triggers/*.trigger` : un fichier declencheur par message.
Un hook externe peut etre branche avec `COORD_LOOP_TRIGGER_CMD`. Le hook recoit
`COORD_MESSAGE_DIR`, `COORD_MESSAGE_FILE`, `COORD_MESSAGE_PATH`,
`COORD_MESSAGE_STATUS` et `COORD_TRIGGER_FILE`.
## Règle de capitalisation
Un message de coordination est un flux. Une synthèse ou un registre est une

View File

@@ -0,0 +1,81 @@
# Rôles — Coordination multi-agents rpa_vision_v3
- `Date`: 2026-06-08
- `Auteur`: Qwen
- `Statut`: actif
---
## Équipe
| Agent | Rôle | Inbox | Sortie attendue |
|---|---|---|---|
| **Dom** | Propriétaire du projet, décideur final, filtre produit | Direct | GO/NOGO, arbitrages, validation démo |
| **Codex** | Coordinateur, orchestration, arbitrages techniques, synthèse | `inbox_codex/` | Missions, QG consolidés, plans, décisions |
| **Claude** | Implémentation, patches, benchmarks, plans techniques | `inbox_claude/` | Commits, RESULTAT, plans, analyses |
| **Qwen** | Quality Gate (QG), audit, historien, garde-fou, propositions | `inbox_qwen/` | Verdicts GO/NOGO, audits, synthèses, docs |
| **Gemini** | Consultation ponctuelle, recherche externe | `inbox_gemini/` | Recherche, analyse comparative |
---
## Règles de coordination
### 1. Inbox routing
- 1 question = 1 fichier dans l'inbox du destinataire
- Format : `YYYY-MM-DD_HHMM_auteur-to-destinataire_SUJET.md`
- Réponse courte et actionnable, pas de rapport long sauf demande explicite
### 1bis. Orchestration Codex
- Codex est l'orchestrateur actif du projet.
- Tant que Claude et Qwen ont des loops de coordination actifs, Codex doit leur donner en continu une prochaine tâche actionnable.
- Exception unique : Codex attend explicitement un feu vert de Dom ou un QG bloquant avant d'autoriser l'étape suivante.
- Si une exécution est bloquée, Codex doit quand même distribuer du travail non destructif utile : préparation, QG, audit read-only, rollback, plan de tests, registre décisions.
- Claude ne doit pas rester sans mission d'exécution/preuves ou préparation technique.
- Qwen ne doit pas rester sans mission de QG, audit, contradiction check ou registre.
### 2. Statuts
| Statut | Signification |
|---|---|
| `open` | En attente de réponse |
| `ACK` | Lu et pris en compte |
| `RESULTAT` | Livraison d'un résultat |
| `GO` / `NO-GO` | Verdict qualité |
| `DRAFT` | Brouillon, pas encore prêt pour revue |
| `archived` | Archivé (ne pas supprimer) |
### 3. Règle QG
- Pas d'action structurelle sans QG (Quality Gate) explicite
- Pas de promotion de competence sans QG + GO Dom
- Qwen signale à Dom toute contradiction entre agents
### 4. Règles de sécurité
- Pas de secret en clair dans les messages de coordination
- Pas de replay autonome sans Dom devant Windows
- Pas de code mort supprimé sans GO Dom explicite
- Pas d'activation modèle/grounder sans bench + QG + GO Dom
### 5. Capitalisation
- Chaque avancée importante doit être capitalisée dans `active/` ou `registre/`
- Les inboxes sont un flux, pas une mémoire
- `syntheses/` = rapports d'inventaires et benchmarks
- `registre/` = décisions formelles
### 6. Sessions
- Qwen reste dans la session longue (historien, garde le fil complet)
- Codex et Claude ont chacun leur session propre
- Si Qwen voit des contradictions entre les instructions de Codex/Claude, il signale à Dom avant d'agir
---
## Flux de travail typique
```
Codex → mission → Claude/ Qwen
Claude → implémentation → commit → RESULTAT → Codex/Qwen
Qwen → QG → GO/NO-GO → Codex
Codex → synthèse → Dom → GO/NOGO
```
---
*Document vivant — mettre à jour quand les rôles évoluent.*

View File

@@ -0,0 +1,233 @@
# RUNBOOK DGX - Check post-deploiement et post-reboot POC
- Date: 2026-06-17
- Responsable coordination: Codex
- Scope: POC DGX uniquement
- Cible labo actuelle: DGX `192.168.1.45`
- Cible clinique preparee: Ethernet `192.168.1.178/24`, gateway `192.168.1.243`, DNS `192.168.1.9` puis `192.168.1.8`, IPv6 inactive, profil inactif tant que Dom/Codex ne donnent pas le GO.
## Objectif
Valider qu'apres deploiement puis redemarrage du DGX, toute la chaine POC reste fonctionnelle sans dependance `localhost` cote utilisateur:
- dashboard;
- VWB;
- import workflow;
- anchors visibles;
- agent-chat / "Discuter avec Lea";
- streaming/fleet;
- worker/replay;
- installateur Lea autonome;
- configuration reseau DGX sans bascule clinique accidentelle.
## Regle de conduite
- Dom execute uniquement les gestes UI de la section "Check Dom".
- Les agents techniques collectent les preuves systeme et reseau.
- Toute divergence est notee avec: etape, symptome, impact, rollback propose.
- Pas de changement reseau clinique actif pendant le check labo: le Wi-Fi reste le lien de test.
## 0. Prerequis avant reboot
| Point | Attendu | Statut |
|---|---|---|
| Branche DGX | `poc-dgx` | A confirmer par Claude |
| Commit DGX | `9605cc9d9` ou commit ulterieur explicitement valide | A confirmer par Claude |
| Commits POC presents | `667575c3a`, `9605cc9d9` | A confirmer par Claude/Qwen |
| Deploy propre | pas de fichier parasite embarque dans le runtime POC | A confirmer |
| Donnees runtime | backup `backend/instance/workflows.db` avant reset/deploy, restore apres deploy, hash/size verifies | A confirmer par Claude |
| Donnees non trackees | `data/workflows` (symlink) + donnees VWB sous `visual_workflow_builder/backend/data/` non supprimes | A confirmer |
| VWB build | bundle reconstruit depuis `poc-dgx` | A confirmer |
| Installateur Lea | version `1.0.1`, Python embedded obligatoire | A confirmer |
| Profil Ethernet clinique | prepare si besoin, `autoconnect off`, non active | A confirmer par Qwen |
| Enrolement VM Lea | VM/poste pointe vers DGX labo `192.168.1.45` pour le test, pas vers l'URL cloud | A confirmer |
NO-GO reboot si le deploiement n'est pas fige, si le DGX ne pointe pas sur le bon commit, ou si `workflows.db`/anchors/workflows ne sont pas verifies apres deploy.
## 1. Baseline avant reboot
Un agent technique releve l'etat avant redemarrage:
| Famille | Controle | Attendu |
|---|---|---|
| Services principaux | dashboard, VWB backend, streaming, agent-chat, worker/fleet actifs | actifs |
| Ports utilisateur | `5001`, `5004`, `5005` disponibles depuis le LAN autorise | OK |
| Ports internes | `5002`, `8000`, `11434` selon architecture DGX | OK ou documente |
| Dashboard | acces par `http://192.168.1.45:5001` | login ou 401 attendu, pas page blanche |
| Streaming | health `:5005` | 200/healthy |
| Auth streaming | route protegee sans token | 401 attendu |
| Agent chat | status/health `:5004` | 200/online |
| VWB | frontend charge depuis IP DGX | OK |
| Bundle VWB | aucune URL active `localhost`/`127.0.0.1` | OK |
| VM Windows Lea | Lea pointe vers DGX `192.168.1.45` pour `5004/5005` | OK |
Si un point est deja rouge avant reboot, on corrige avant de redemarrer. Le reboot ne doit pas servir a masquer une panne.
## 2. Reboot DGX
1. Claude annonce le debut du reboot dans la coordination.
2. Codex garde la coordination ouverte.
3. Attendre le retour reseau du DGX sur le Wi-Fi labo.
4. Ne pas activer le profil Ethernet clinique pendant ce cycle.
5. Claude poste l'heure de retour et le premier etat des services.
NO-GO immediat si le DGX ne revient pas sur le reseau labo ou si le dashboard reste inaccessible plus de 10 minutes apres retour reseau.
## 3. Check technique post-reboot
### 3.1 Services
Noms constates/attendus a verifier sur le DGX. Ne pas supposer qu'un service existe: si une unite est absente, le noter et verifier si sa fonction est couverte par un autre service.
- `rpa-streaming`
- `rpa-agent-chat`
- `rpa-vision-v3-dashboard`
- `rpa-vision-v3-vwb-frontend`
- `rpa-vision-v3-vwb-backend`
- `rpa-vision-v3-worker`
- `rpa-vision-v3-stream-worker` si installe, sinon documenter l'absence
- `rpa-vision-v3-api`
- `rpa-grounding` ou service grounder equivalent si present; sinon documenter le fallback
- `rpa-vision-v3-healthcheck.timer` si actif dans le deploiement final; sinon ne pas bloquer mais documenter
Attendu:
- services POC critiques actifs apres reboot;
- services POC critiques enabled si necessaires au demarrage automatique;
- pas de boucle restart;
- logs recents sans traceback bloquant;
- healthcheck timer actif ou decision documentee si non utilise;
- VWB frontend servi par le vrai frontend POC (`frontend_v4`), pas l'ancien `frontend/`.
- Ne jamais utiliser `git clean -xfd` sur DGX pendant la sequence POC: cela detruirait les donnees runtime non trackees.
### 3.2 Reseau et ports
| Port | Usage | Attendu labo |
|---|---|---|
| `5001` | dashboard / VWB integre | accessible via `192.168.1.45` |
| `5004` | agent-chat / ChatWindow Lea | accessible depuis VM Windows autorisee |
| `5005` | streaming/fleet/replay | accessible depuis VM Windows autorisee |
| `5002` | VWB backend si separe | conforme au deploiement DGX |
| `8000` | upload/API core si actif | conforme au deploiement DGX |
| `11434` | Ollama local DGX | local/interne sauf decision contraire |
Attendu:
- aucun appel navigateur produit vers `localhost` ou `127.0.0.1`;
- `5004/5005` repondent depuis la VM Windows;
- le dashboard charge toutes ses ressources depuis l'IP DGX;
- la carte Ethernet clinique reste inactive ou non connectee pendant le check labo.
### 3.3 Endpoints et fonctions serveur
| Controle | Attendu |
|---|---|
| Dashboard `5001` | page login/auth visible, pas 500 |
| Streaming `5005/health` | healthy |
| Streaming route protegee sans token | 401 |
| Fleet machines | VM Lea visible ou reenrolement documente |
| Enrolement VM Lea | `config.txt`/runtime pointe vers DGX, pas vers URL cloud |
| Replay next | repond correctement pour machine enrollee |
| Agent chat `5004` | online et session chat possible |
| VWB catalog/workflows | liste chargee |
| Anchors API | upload/thumbnail/original accessibles depuis IP DGX |
| Worker | pas de backlog bloquant, pas de crash loop |
| Modele/grounding | service disponible ou fallback documente |
## 4. Check Dom UI post-reboot
Dom ne fait que ces gestes:
| Etape | Geste Dom | GO | NO-GO |
|---|---|---|---|
| T1 Dashboard | Ouvrir l'URL DGX fournie | page login/dashboard visible | page blanche, site inaccessible, erreur 500 |
| T2 Chat Lea | Dans la VM, ouvrir "Discuter avec Lea", envoyer "Bonjour" | reponse en quelques secondes | pas connectee, erreur serveur, fermeture fenetre |
| T3 Bulles action | Demander une action simple | bulles/progression visibles | aucune progression visible pendant une action lancee |
| T4 Import VWB | Importer un workflow JSON Lea | succes, workflow visible | erreur, rien ne se passe |
| T5 Anchors | Ouvrir une etape avec ancre | image d'ancre/crop visible | image absente, plein ecran au lieu de crop |
| T6 Replay supervise | Lancer un replay safe/supervise | execution ou demande de confirmation | clic hors cible, blocage, absence de reaction |
| T7 Reconnexion apres reboot | Fermer/rouvrir Lea sur VM | reconnecte DGX sans reconfig manuelle | demande Python/outils externes ou config manuelle non prevue |
## 5. Criteres GO/NO-GO
GO livraison labo si:
- DGX revient apres reboot;
- services critiques actifs;
- dashboard accessible via IP DGX;
- VWB charge sans `localhost`;
- chat Lea fonctionne depuis VM;
- import workflow OK;
- anchors visibles;
- replay supervise OK ou comportement d'arret/confirmation conforme;
- installateur Lea autonome confirme;
- profil Ethernet clinique non active par erreur.
NO-GO livraison si un des points suivants echoue:
- DGX ne revient pas proprement apres reboot;
- dashboard/VWB inaccessible;
- `5004` ou `5005` indisponible depuis la VM;
- "Discuter avec Lea" ne fonctionne pas;
- installateur demande Python ou un outil externe;
- VWB appelle encore `localhost` cote navigateur;
- import/anchors/replay cassent;
- configuration Ethernet clinique activee accidentellement pendant les tests labo.
## 6. Preuves a archiver
Claude fournit:
- commit DGX courant;
- preuve backup/restore `workflows.db` si reset/deploy effectue;
- confirmation que `data/workflows` et les vrais chemins `visual_workflow_builder/backend/data/anchors`, `visual_workflow_builder/backend/data/anchor_images`, `visual_workflow_builder/backend/instance/workflows.db` sont presents apres deploy/reboot;
- liste services actifs/enabled;
- ports et endpoints avec resultats;
- resultat grep bundle no-localhost;
- resultat test VM Lea chat;
- resultat import/anchors/replay;
- chemin de l'artefact installateur Lea 1.0.1;
- incidents et rollback si besoin.
Qwen fournit:
- controle croise commits/fichiers parasites;
- matrice GO/NO-GO post-reboot;
- verification profil Ethernet clinique inactif;
- etat VM Windows 11 ARM DGX ou decision fallback VMware clinique.
Codex consolide:
- verdict final pret/pas pret;
- liste des anomalies restantes;
- decision de passage au check site ou retour correction.
## 7. Rollback minimal
Si VWB regresse:
- revenir au dernier commit POC valide connu;
- redeployer le bundle precedent;
- redemarrer uniquement dashboard/VWB backend;
- refaire T1, T4, T5.
Si agent-chat regresse:
- verifier service `rpa-agent-chat`;
- verifier env `5004`/feedback bus;
- redemarrer agent-chat puis streaming si necessaire;
- refaire T2/T3.
Si streaming/fleet regresse:
- verifier service `rpa-streaming`;
- verifier token/auth;
- verifier reenrolement VM;
- refaire T2/T6.
Si reseau clinique s'active par erreur:
- revenir au profil Wi-Fi labo;
- desactiver le profil Ethernet clinique;
- confirmer retour dashboard `192.168.1.45`;
- ne reprendre les tests qu'apres stabilisation.

View File

@@ -0,0 +1,97 @@
# Runbook — Test Lea live grandeur nature
- `Date`: 2026-06-08
- `Auteur`: Qwen (draft)
- `Statut`: DRAFT — en attente preflight vert + GO Dom
- **Interdit** : ❌ Replay autonome
---
## Prérequis
| # | Vérification | Commande / Action | Critère GO |
|---|---|---|---|
| 1 | Windows cible visible | `ping DESKTOP-58D5CAC_windows` | ✅ Répond |
| 2 | httpx OK côté Windows | `python -c "import httpx; print('OK')"` dans venv Windows | ✅ Import OK |
| 3 | Agent V1 en cours | Vérifier processus Windows agent V1 | ✅ Running |
| 4 | DGX Ollama accessible | `curl http://localhost:11434/api/tags` | ✅ Répond |
| 5 | Workflows acquis retrouvés | Dashboard → workflows chargés | ✅ ≥ 1 workflow visible |
| 6 | Dom présent physiquement | Devant le poste Windows | ✅ Confirmé |
---
## Exécution
### Étape 1 : Préflight (NON destructif)
```bash
# Sur le poste dev Linux
curl -X POST http://localhost:5005/api/v1/traces/stream/replay/preflight \
-H "Content-Type: application/json" \
-d '{"machine_id": "DESKTOP-58D5CAC_windows"}'
```
**Attendu** : `workflow_known: true`, aucun blocage G1-G6.
### Étape 2 : Capture Shadow supervisée
1. Ouvrir le dashboard Lea
2. Démarrer un apprentissage supervisé
3. Scénario safe : Notepad → Enregistrer → Explorateur → Easily Assure
4. **Ne pas** déclencher de replay autonome
5. Observer et collecter les preuves en temps réel
### Étape 3 : Collecte des preuves
| Preuve | Où la trouver | Critère |
|---|---|---|
| `live_events.jsonl` | `data/training/live_events.jsonl` | Non vide, timestamps cohérents |
| `learn_*.json` | `agent_chat/state/learn_*.json` | Présent, state non vide |
| Shadow understanding | `agent_chat/state/` | Compréhension non vide |
| Screenshots avant/après | Captures manuelles | Chaque action documentée |
| Rapports preflight | Sortie curl étape 1 | `workflow_known: true` |
### Étape 4 : Arrêt propre
1. Stopper l'apprentissage via le dashboard
2. Vérifier que les fichiers de state sont cohérents
3. Ne pas lancer de replay automatique
---
## Critères GO/NOGO
### GO (tous doivent être verts)
| ID | Critère |
|---|---|
| G1 | Préflight `workflow_known: true` |
| G2 | Windows cible joignable |
| G3 | `httpx` import OK côté Windows |
| G4 | DGX Ollama accessible (`localhost:11434`) |
| G5 | Workflows acquis retrouvés (≥ 1) |
| G6 | Dom confirme présent physiquement |
### NOGO (un seul suffit)
| ID | Critère |
|---|---|
| N1 | Préflight KO |
| N2 | Windows injoignable |
| N3 | `httpx` absent |
| N4 | DGX Ollama inaccessible |
| N5 | Dom absent devant Windows |
| N6 | Replay autonome demandé |
---
## Rollback
1. Arrêter proprement l'apprentissage
2. Ne pas supprimer les fichiers de state
3. Documenter le point d'arrêt
4. Rapporter à Codex/Claude
---
*Ce runbook est un draft. Il sera validé après preflight vert et GO Dom.*

Some files were not shown because too many files have changed in this diff Show More