101 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
Dom
cbd3d40e39 fix(poc-installer): rendre l'installateur Lea embedded fonctionnel
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m47s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
Lea.iss (Inno Setup) n'avait jamais compile. Corrections :
- StringChange utilise en in-place (procedure modifiant la variable, retour
  Integer) au lieu d'imbrique/assigne (l.246, 407-408)
- GetTickCount (absent du Pascal Script Inno) -> GetDateTimeString pour le
  fallback machine_id
- skipifsilent retire du [Run] configure_embed : le runtime python-embed est
  desormais configure aussi en installation silencieuse (cas POC)

.gitignore : artefacts de build installateur non versionnes
(python-3.12-embed/, releases/*.exe, build/).

Valide sur VM Win11 : install per-user sans Python systeme, config DGX
(RPA_SERVER_URL=http://192.168.1.45:5005/api/v1), python-embed 3.12.8 + deps OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:14:08 +02:00
Dom
33c1e2e0d1 fix(grounding): confiance grounding dérivée sémantique (DETTE-019)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m48s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
Le score/confidence figés à 0.85 dans _resolve_by_grounding rendaient le
garde-seuil (_RESOLUTION_MIN_SCORES["grounding"]=0.60) inopérant (0.85>0.60
toujours accepté). Le grounding VLM n'a pas de confiance modèle native (prompt
{"x","y"}, pas de logprob de localisation — confirmé QG Qwen 2026-06-15). On
dérive une confiance SÉMANTIQUE : le texte cible est-il à la position trouvée ?
(_validate_text_at_position). Confirmé→0.90, absent→0.45 (<seuil→rejet),
non vérifiable→0.70. Confiance contextuelle documentée, PAS une proba modèle.

TDD : 5 tests (score varie / présent accepté / absent rejeté / score==confidence
/ sans by_text neutre), RED→GREEN. Non-régression : 24 tests resolve_engine +
câblage qwen3vl + legacy bbox verts. E2E panel inchangé (15/15). Pré-check OCR
non impacté. DETTE-018 (legacy non gardé) reste séparée.

refs DETTE-019

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:17:46 +02:00
Dom
c0e4c382be docs(dette): acte DETTE-018/019 (garde-seuil grounding) + inscrit DETTE-015..017
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m45s
tests / Tests unitaires (sans GPU) (push) Failing after 1m51s
tests / Tests sécurité (critique) (push) Has been skipped
DETTE-018: method="grounding_vlm" legacy non gardé par _RESOLUTION_MIN_SCORES
(seul prefixe memory_ traité ; reste = match exact) → Check-1 seuil jamais appliqué
au chemin legacy. Mode qwen3vl ("grounding", seuil 0.60) correctement gardé.
DETTE-019: confiance figée 0.85 en dur dans _resolve_by_grounding (return) pour les
deux modes → garde-seuil (0.60) reçoit toujours 0.85, filtre inopérant.
Découvertes au câblage qwen3vl (5c5ce747b) + validation E2E 2026-06-13 (15/15, 0 dangereux).
Inscrit aussi DETTE-015/016/017 restées non commitées.

refs DETTE-018 DETTE-019

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:33:58 +02:00
Dom
5c5ce747b0 feat(grounding): câblage Qwen3-VL-4B/vLLM (RPA_GROUNDING_ENGINE, défaut off)
Active via RPA_GROUNDING_ENGINE=qwen3vl_vllm (défaut OFF = legacy Qwen2.5-VL
inchangé, byte-identique). Mode qwen3vl : port 8001/Qwen3-VL-4B, prompt point
0-1, think=false, parse /1000 (dissout DETTE-006), method "grounding" gardée
(seuil 0.60), pas de fallback Ollama (abstention si vLLM down). Grounder validé
au bench Easily réel (0.933, ~1s/cas). TDD : 4 tests (normalisation 0-1000,
think=false, prompt fractions 0-1, gating score bas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:39:29 +02:00
Dom
b20d17882e feat(wp-c): méthode verify_token côté registre (patch 3, inerte)
Ajoute AgentRegistry.verify_token(token) -> machine_id|None : compare le
SHA-256 du token aux token_hash des agents 'active' via hmac.compare_digest
(temps constant). Agent désinstallé/révoqué refusé ; rotation à l'enroll
invalide l'ancien token.

Inerte au runtime : méthode non branchée sur l'auth HTTP (le branchement
derrière flag RPA_FLEET_PER_AGENT_TOKEN sera le Patch 4). api_stream.py
intouché. TDD : 6 tests + non-régression WP-C/WP-B (53 verts). Voir
PLAN-WPC-TDD-EXECUTABLE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:21:04 +02:00
Dom
9fb2c7bfee feat(wp-c): génération token par poste à l'enroll (patch 2, inerte runtime)
Génère un token unique (secrets.token_hex(32)) à chaque (ré)enrôlement,
persiste uniquement son empreinte SHA-256 dans token_hash, renseigne
token_issued_at, retourne le clair une seule fois dans le résultat de
enroll. Le clair n'est jamais journalisé ni persisté.

Inerte au runtime : api_stream.py intouché, l'endpoint /agents/enroll ne
propage ni le clair ni le hash (api_token global inchangé). Auth runtime
non modifiée. Aucun branchement _verify_token. TDD : 8 tests + non-régression
WP-B/WP-C (47 verts). Voir PLAN-WPC-TDD-EXECUTABLE / DETTE-015.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:36:44 +02:00
Dom
f7f6926410 feat(wp-c): migration colonnes token par poste (patch 1, inerte)
Ajoute token_hash + token_issued_at à enrolled_agents via ALTER TABLE
idempotent (_init_db). Colonnes inertes : aucun branchement auth, runtime
inchangé (tests WP-B verts). Base du token par poste (WP-C, cf DETTE-015).

TDD: tests/unit/test_wpc_migration.py (présence, idempotence, préservation
des données d'une base existante). 3 tests + non-régression WP-B = 9 passed.

refs DETTE-015

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:04:18 +02:00
Dom
09f65cecbe fix(security): bind 127.0.0.1 par défaut via RPA_BIND_HOST (plus de host=0.0.0.0 en dur)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m44s
tests / Tests unitaires (sans GPU) (push) Failing after 1m48s
tests / Tests sécurité (critique) (push) Has been skipped
Les 4 entrypoints HTTP (api_stream 5005, api_upload 8000, VWB backend 5002,
dashboard 5001) bindaient host=0.0.0.0 en dur -> exposés sur tout le réseau.
Désormais host=os.environ.get('RPA_BIND_HOST','127.0.0.1') : local-only par
défaut, configurable. Découvert à la mise en service DGX local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:49:58 +02:00
Dom
0ee54157e5 fix(p1g): garde-fou VRAM adapté à la mémoire unifiée (DGX GB10)
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
resolve_device('auto') renvoyait 'cpu' sur le GB10 : le plafond max_total_gb=6
(pensé pour la RTX 12 Go dédiés) voyait used≈99 Go car la mémoire UNIFIÉE compte
la RAM système. Au-dessus de DEFAULT_LARGE_VRAM_GB=24 (grosse carte / mémoire
unifiée), le plafond n'est plus appliqué ; seul free >= min_free_gb décide.
RTX (<=24 Go) inchangée.

Détecté au bench GB10 2026-06-08 (auto->cpu, OCR 10x plus lent). +2 tests (17/17).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:43:12 +02:00
Dom
6d34b3cb68 chore(dgx): snapshot consolidation WIP pour transfert poc DGX
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
Regroupe le WIP non committé requis pour le clone/runtime DGX (Option A) :
- api_stream.py : préflight replay + smoke santé modèles + handler 403 WP-B
- de-hardcode VLM : vlm_config, gpu/*, vram_orchestrator, ollama_manager
- stream_processor, semantic_matcher, agent_chat (app/planner/intent)
- workflows.db (acquis ; le transfert artifacts le mettra à jour + rewrite chemins)
- docs : plans DGX, benchmarks VLM/grounders, recherche SOTA, coordination 8 juin

Snapshot destiné à la branche poc-dgx poussée sur Gitea pour cloner le DGX.
Scan anti-secret : clean. graphify (repo embarqué) exclu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:33:58 +02:00
Dom
f18de016d7 fix(wp-b): verrou d'enrôlement du parc (RPA_FLEET_ENROLL_LOCKED)
Ferme le contournement "poste révoqué + nouveau machine_id + token global" :
quand RPA_FLEET_ENROLL_LOCKED=true, l'enrôlement d'un machine_id INCONNU est refusé
(FleetEnrollLockedError). Les machines déjà connues conservent leur comportement :
active -> AlreadyEnrolled, désinstallé non-revoke -> réactivable, admin_revoke -> Revoked.

- agent_registry.py : _fleet_enroll_locked() + FleetEnrollLockedError + gate avant INSERT
- tests/unit/test_fleet_enroll_lock_wpb.py : 6 tests (verts)

NB : le handler HTTP 403 (api_stream.py /api/v1/agents/enroll) reste dans le WIP de la
branche (api_stream déjà modifié par le préflight non committé) — sera embarqué au commit
de consolidation api_stream. La logique de sécurité (gate) est dans agent_registry, committée.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:43:04 +02:00
Dom
549ea0631b fix(wp-a): dashboard fail-closed sans mot de passe par défaut
Le dashboard refuse de démarrer si DASHBOARD_PASSWORD absent ET auth non
explicitement désactivée (DASHBOARD_AUTH_DISABLED). Supprime le mot de passe
par défaut hardcodé exploitable.

- web_dashboard/app.py : _require_dashboard_password() fail-closed (lève en prod
  sans secret ; mode dev/test = DASHBOARD_AUTH_DISABLED=true)
- tests/unit/conftest.py : DASHBOARD_AUTH_DISABLED=true par défaut pour les tests
- tests/unit/test_dashboard_failclosed_wpa.py : 5 tests (fail-closed, anti-régression défaut)
- tests/unit/test_dashboard_auth_p0a.py : fixture _restore_module restaure un état neutre sûr

48 tests dashboard verts (WP-A + non-régression auth/routes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:27:06 +02:00
Dom
0e215da842 feat(p1g): device policy GPU/CPU paramétrable pour la cascade vision
resolve_device(auto/cuda/cpu) avec garde-fou VRAM et fallback CPU propre.
Bascule EasyOCR/SoM/docTR sur GPU si VRAM libre, rollback env sans toucher au code.

- core/gpu/device_policy.py (nouveau) : resolve_device + garde-fou VRAM (max_total_gb)
- core/detection/som_engine.py, core/llm/ocr_extractor.py,
  agent_v0/server_v1/resolve_engine.py : câblage device auto (35 lignes)
- tests/unit/test_device_policy.py : 15 tests (verts venv réel)

Rollback sans toucher au code : RPA_VISION_DEVICE=cpu (force CPU global) / RPA_EASYOCR_GPU=0.
Bench GPU réel (latence) + activation large après verdict Qwen. QG Qwen deja valide sur le patch.
Mergé depuis worktree agent-a4f390f410e00ad7c (base 5b2afa362), 3 fichiers cibles non modifiés
dans le principal (zéro écrasement), dry-run apply propre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:20:52 +02:00
Dom
d00fe7b00b feat(health): gate vision + détection des modèles aveugles
Détecte les modèles VLM/grounding « aveugles » (capabilities sans vision, ex.
UI-TARS réimporté sans mmproj) pour éviter le HTTP 500 silencieux masqué par
la cascade de grounding.

- core/detection/model_health.py : has_vision_capability() (cache, fail-open)
  + smoke_check_models()
- core/execution/input_handler.py : gate vision dans _grounding_ui_tars
  (skip propre vers niveau 3 si modèle aveugle, plus de 500 silencieux)
- tests/unit/test_model_health.py : 6 tests (vision/aveugle/fail-open/cache/smoke)

Incident 2026-06-08 : UI-TARS sans mmproj -> niveau 2 cascade en 500 silencieux,
non détecté (hors chemin runtime démo + échec avalé par fallback + zéro test).
NB : le smoke non bloquant au démarrage (api_stream.py startup) reste dans le WIP
de la branche, mélangé au préflight non committé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:51:18 +02:00
Dom
5b2afa3629 fix(p1w): make default VLM model DGX-safe (qwen2.5vl:7b-rpa)
Sans env RPA_VLM_MODEL/VLM_MODEL, get_vlm_model() tombait sur le default
gemma4:latest, qui peut etre absent du tunnel DGX (depull) -> 404 Ollama et
echec de tout le pipeline VLM avant un test Lea humain.

- core/detection/vlm_config.py : DEFAULT_VLM_MODEL gemma4:latest -> qwen2.5vl:7b-rpa
  (confirme present DGX, deja default reasoning + fallback bbox grounding).
  + DGX_SAFE_VLM_MODELS allow-list documentee.
- tests/unit/test_vlm_default_dgx_safe.py : 5 tests (default != gemma4:latest,
  default in allow-list, no-env -> DGX-safe, env garde priorite).

Logique de resolution inchangee, pas d'appel reseau a l'import.
gemma4:latest reste accessible via env explicite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:06:10 +02:00
Dom
0f122a512f feat(p1y-alpha): add OpenAI-compatible LeaBench adapter (benchmark only)
Adapter de benchmark isole (hors runtime Lea) ciblant un serveur
/v1/chat/completions a support vision (vLLM/SGLang/TGI), pour comparer
plus tard a Ollama via LeaBench. Ne controle jamais le desktop.

- core/evaluation/openai_compat_lea_bench_adapter.py : payload data-URL
  image_url, parsing choices[0].message.content. Reutilise par import la
  logique prompt/parse/normalisation de ollama_lea_bench_adapter (zero refactor).
- tools/lea_bench_openai_compat.py : wrapper CLI (--base-url defaut :8001).
- tests/unit/test_openai_compat_lea_bench_adapter.py : 6 tests mockes HTTP
  (data URL, pas de fuite expectation/click_region, prediction valide,
  abstain safe sur HTTP!=200 et reponse malformee, JSONL rechargeable).

Aucun runtime Lea modifie. Aucun service lance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:49:53 +02:00
Dom
806cc04b82 feat(p1z): centralize V4 reasoning model resolution (DGX-safe)
Remplace le default runtime dangereux `qwen2.5vl:7b` (absent du tunnel DGX
-> 404) des chemins V4/reasoning par un helper central get_reasoning_model().

- core/detection/vlm_config.py : + get_reasoning_model() + DEFAULT_REASONING_MODEL
  (qwen2.5vl:7b-rpa). Ordre : RPA_REASONING_MODEL -> RPA_VLM_MODEL/VLM_MODEL ->
  default DGX-safe. Pas d'appel reseau (lazy, safe a l'import).
- core/execution/input_handler.py, observe_reason_act.py (x3),
  core/cognition/vram_orchestrator.py : migration des 5 call-sites.
- tests/unit/test_reasoning_model.py : 8 tests (default DGX-safe, ordre de
  resolution, non-regression wiring des 3 modules V4).

Hors scope (signale lot P1.w) : DEFAULT_VLM_MODEL=gemma4:latest reste fallback
de get_vlm_model(). Client gele non touche.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:23:10 +02:00
Dom
4dc7d840d6 feat(p1x): de-hardcode VLM models/endpoints to vlm_config (DGX-ready)
Migre les call-sites VLM serveur vers la configuration centrale pour
fonctionner sur DGX (tunnel Ollama 11434), où gemma4:* est absent et le
port Docker 11435 est mort.

- task_planner, replay_verifier, domain_context, ir_builder, resolve_engine
  (popup): modele -> vlm_config.get_vlm_model(), defaut 11435 -> 11434
  (override GEMMA4_PORT legacy conserve)
- resolve_engine (grounding bbox x2): nouvel helper
  vlm_config.get_bbox_grounding_model() (var dediee RPA_BBOX_GROUNDING_MODEL,
  fallback RPA_GROUNDING_MODEL puis qwen2.5vl:7b-rpa) -> desambiguise le
  conflit D5-v3b, bbox_2d + num_ctx 4096 preserves
- safety_checks_provider: defaut -> get_vlm_model(), override
  RPA_SAFETY_CHECKS_LLM_MODEL preserve
- ui_detector: default_factory + resolution lazy (corrige aussi un gel a
  l'import), pas d'appel reseau a l'import
- field_extractor: property lazy via vlm_config

TDD strict (RED->GREEN), 305 tests verts, tests mockes HTTP (zero dependance
DGX reel), aucun alias Ollama.

Hors perimetre (arbitrage Dom): client Lea agent_v1/executor.py (gele),
chemin V4 observe_reason_act (RPA_REASONING_MODEL), core/config.py defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:06:03 +02:00
Dom
4e7c2a7628 docs(coordination): dispatch dgx vlm model cleanup 2026-06-02 18:16:55 +02:00
435 changed files with 45648 additions and 475 deletions

40
.gitignore vendored
View File

@@ -121,8 +121,46 @@ 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/
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*

24
AGENTS.md Normal file
View File

@@ -0,0 +1,24 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- 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

@@ -38,6 +38,7 @@ from werkzeug.utils import secure_filename
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.workflow import SemanticMatcher, VariableManager
from core.detection.vlm_config import get_reasoning_model
# Import des composants conversationnels
from .intent_parser import IntentParser, IntentType, get_intent_parser
@@ -237,6 +238,7 @@ def init_system():
global matcher, gpu_manager
global intent_parser, confirmation_loop, response_generator, conversation_manager
global autonomous_planner
reasoning_model = get_reasoning_model()
# 1. SemanticMatcher — multi-répertoires (P0-6) + matching LLM (P0-7)
# Scan data/workflows/ + data/training/workflows/ + data/training/live_sessions/workflows/
@@ -244,7 +246,7 @@ def init_system():
matcher = SemanticMatcher(
workflows_dir=None, # None = scan tous les répertoires par défaut
use_llm=True, # Matching sémantique via Ollama (P0-7)
llm_model="qwen2.5:7b",
llm_model=reasoning_model,
)
dirs_info = matcher.get_directories()
dirs_summary = ", ".join(
@@ -269,7 +271,10 @@ def init_system():
# 3. Composants conversationnels
try:
intent_parser = get_intent_parser(use_llm=True) # LLM activé (Ollama)
intent_parser = get_intent_parser(
use_llm=True,
llm_model=reasoning_model,
) # LLM activé (Ollama)
confirmation_loop = get_confirmation_loop()
response_generator = get_response_generator()
conversation_manager = get_conversation_manager()
@@ -350,7 +355,7 @@ def init_system():
# 5. Autonomous Planner (Agent Libre)
try:
autonomous_planner = get_autonomous_planner(llm_model="qwen2.5:7b")
autonomous_planner = get_autonomous_planner(llm_model=reasoning_model)
# Configurer les callbacks pour l'exécution
if screen_capturer:
@@ -726,7 +731,7 @@ def api_history():
# =============================================================================
# Modèle texte pour les réponses conversationnelles (pas besoin de vision)
_LEA_LLM_MODEL = os.environ.get("LEA_LLM_MODEL", "qwen3:8b")
_LEA_LLM_MODEL = os.environ.get("LEA_LLM_MODEL") or get_reasoning_model()
_LEA_SYSTEM_PROMPT = """Tu es Léa, une assistante professionnelle chaleureuse et bienveillante.

View File

@@ -27,6 +27,8 @@ import requests
# Ajouter le chemin du projet pour les imports core
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
# Essayer d'importer les composants de détection visuelle
@@ -113,11 +115,11 @@ class AutonomousPlanner:
def __init__(
self,
llm_endpoint: str = "http://localhost:11434/api/generate",
llm_model: str = "qwen2.5:7b",
llm_model: Optional[str] = None,
timeout: int = 60
):
self.llm_endpoint = llm_endpoint
self.llm_model = llm_model
self.llm_model = llm_model or get_reasoning_model()
self.timeout = timeout
self.llm_available = self._check_llm()
@@ -1028,12 +1030,12 @@ _planner_instance: Optional[AutonomousPlanner] = None
def get_autonomous_planner(
llm_model: str = "qwen2.5:7b"
llm_model: Optional[str] = None
) -> AutonomousPlanner:
"""Retourne l'instance singleton du planner."""
global _planner_instance
if _planner_instance is None:
_planner_instance = AutonomousPlanner(llm_model=llm_model)
_planner_instance = AutonomousPlanner(llm_model=llm_model or get_reasoning_model())
return _planner_instance

View File

@@ -19,6 +19,8 @@ from enum import Enum
from typing import Dict, Any, List, Optional, Tuple
from pathlib import Path
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
@@ -280,7 +282,7 @@ class IntentParser:
self,
use_llm: bool = False,
llm_endpoint: str = "http://localhost:11434",
llm_model: str = "qwen2.5:7b"
llm_model: Optional[str] = None
):
"""
Initialiser le parseur d'intentions.
@@ -292,7 +294,7 @@ class IntentParser:
"""
self.use_llm = use_llm
self.llm_endpoint = llm_endpoint
self.llm_model = llm_model
self.llm_model = llm_model or get_reasoning_model()
self.llm_available = False
self._workflows_cache: List[Dict[str, Any]] = []
@@ -687,7 +689,7 @@ _intent_parser: Optional[IntentParser] = None
def get_intent_parser(
use_llm: bool = False,
llm_model: str = "qwen2.5:7b",
llm_model: Optional[str] = None,
llm_endpoint: str = "http://localhost:11434"
) -> IntentParser:
"""
@@ -695,20 +697,21 @@ def get_intent_parser(
Args:
use_llm: Activer le LLM (Ollama)
llm_model: Modèle à utiliser (qwen2.5:7b par défaut)
llm_model: Modèle à utiliser (défaut: modèle reasoning central)
llm_endpoint: URL de l'endpoint Ollama
"""
global _intent_parser
resolved_model = llm_model or get_reasoning_model()
if _intent_parser is None:
_intent_parser = IntentParser(
use_llm=use_llm,
llm_endpoint=llm_endpoint,
llm_model=llm_model
llm_model=resolved_model
)
elif use_llm and not _intent_parser.use_llm:
# Réactiver le LLM si demandé
_intent_parser.use_llm = True
_intent_parser.llm_model = llm_model
_intent_parser.llm_model = resolved_model
_intent_parser._check_llm_availability()
return _intent_parser

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

@@ -28,12 +28,16 @@ Schema de la table `enrolled_agents` :
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -47,6 +51,30 @@ def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _new_token() -> Tuple[str, str]:
"""WP-C : genere un token poste (clair) et son empreinte SHA-256.
Le clair est retourne UNE seule fois a l'appelant (resultat de enroll) ; seul
le hash est persiste dans `token_hash`. Le clair n'est jamais journalise ni
stocke. L'auth runtime reste inchangee (aucun branchement ici sur la
verification de token cote api_stream).
"""
clear = secrets.token_hex(32)
token_hash = hashlib.sha256(clear.encode("utf-8")).hexdigest()
return clear, token_hash
def _fleet_enroll_locked() -> bool:
"""WP-B : parc verrouille -> aucun NOUVEAU machine_id ne peut s'enroler.
Pilote par l'env `RPA_FLEET_ENROLL_LOCKED` (true/1/yes), reversible (relu a
chaque appel). Ferme le contournement « poste revoque + nouveau machine_id +
token global » : les machines deja connues gardent leur comportement, seul
l'enrolement d'un machine_id inconnu est refuse quand le parc est verrouille.
"""
return os.getenv("RPA_FLEET_ENROLL_LOCKED", "").strip().lower() in ("1", "true", "yes")
class AgentRegistry:
"""Gestion CRUD des agents enrolles (SQLite)."""
@@ -99,6 +127,20 @@ class AgentRegistry:
"CREATE INDEX IF NOT EXISTS idx_enrolled_agents_machine "
"ON enrolled_agents(machine_id)"
)
# WP-C Patch 1 : colonnes « token par poste », migration additive
# idempotente. Inertes tant que l'auth par poste n'est pas branchée
# (patchs WP-C ultérieurs). Voir DETTE-015.
existing_cols = {
row[1]
for row in conn.execute(
"PRAGMA table_info(enrolled_agents)"
).fetchall()
}
for col in ("token_hash", "token_issued_at"):
if col not in existing_cols:
conn.execute(
f"ALTER TABLE enrolled_agents ADD COLUMN {col} TEXT"
)
# ------------------------------------------------------------------
# Lecture
@@ -131,6 +173,31 @@ class AgentRegistry:
).fetchone()
return int(row["n"]) if row else 0
def verify_token(self, token: str | None) -> Optional[str]:
"""WP-C : verifie un token poste, retourne le machine_id actif ou None.
Compare le SHA-256 du token presente aux `token_hash` des agents
`status='active'` via `hmac.compare_digest` (comparaison a temps
constant, evite les fuites par timing). Un agent desinstalle/revoque
n'est pas 'active' donc refuse ; la rotation a l'enrolement invalide
l'ancien token.
INERTE : non branchee sur l'auth runtime (le branchement derriere flag
sera le Patch 4). Aucun appelant runtime a ce stade.
"""
if not token:
return None
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
with _DB_LOCK, self._connect() as conn:
rows = conn.execute(
"SELECT machine_id, token_hash FROM enrolled_agents "
"WHERE status = 'active' AND token_hash IS NOT NULL"
).fetchall()
for row in rows:
if hmac.compare_digest(str(row["token_hash"]), token_hash):
return str(row["machine_id"])
return None
# ------------------------------------------------------------------
# Ecriture
# ------------------------------------------------------------------
@@ -180,6 +247,8 @@ class AgentRegistry:
if not allow_reactivate:
raise AgentAlreadyEnrolledError(dict(existing))
# WP-C : rotation du token a chaque (re)enrolement.
token, token_hash = _new_token()
conn.execute(
"""
UPDATE enrolled_agents
@@ -193,13 +262,17 @@ class AgentRegistry:
enrolled_at = ?,
last_seen_at = ?,
uninstalled_at = NULL,
uninstall_reason = NULL
uninstall_reason = NULL,
token_hash = ?,
token_issued_at = ?
WHERE machine_id = ?
""",
(
user_name, user_email, user_id,
hostname, os_info, version,
now, now, machine_id,
now, now,
token_hash, now,
machine_id,
),
)
conn.commit()
@@ -207,21 +280,32 @@ class AgentRegistry:
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return {"created": False, "reactivated": True, "agent": dict(row)}
return {
"created": False,
"reactivated": True,
"agent": dict(row),
"token": token,
}
# Nouvelle inscription
# Nouvelle inscription — WP-B : refusee si le parc est verrouille
if _fleet_enroll_locked():
raise FleetEnrollLockedError(machine_id)
# WP-C : token poste genere a la creation.
token, token_hash = _new_token()
conn.execute(
"""
INSERT INTO enrolled_agents (
machine_id, user_name, user_email, user_id,
hostname, os_info, version,
status, enrolled_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
status, enrolled_at, last_seen_at,
token_hash, token_issued_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
""",
(
machine_id, user_name, user_email, user_id,
hostname, os_info, version,
now, now,
token_hash, now,
),
)
conn.commit()
@@ -229,7 +313,12 @@ class AgentRegistry:
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return {"created": True, "reactivated": False, "agent": dict(row)}
return {
"created": True,
"reactivated": False,
"agent": dict(row),
"token": token,
}
def uninstall(
self,
@@ -310,3 +399,15 @@ class AgentRevokedError(Exception):
f"machine_id={existing_row.get('machine_id')} revoque "
f"(reason={existing_row.get('uninstall_reason')})"
)
class FleetEnrollLockedError(Exception):
"""Levee si le parc est verrouille (RPA_FLEET_ENROLL_LOCKED) et qu'on tente
d'enroler un nouveau machine_id inconnu (WP-B)."""
def __init__(self, machine_id: str):
self.machine_id = machine_id
super().__init__(
f"enrolement refuse : parc verrouille (RPA_FLEET_ENROLL_LOCKED), "
f"machine_id={machine_id} inconnu"
)

View File

@@ -27,11 +27,17 @@ 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
from .audit_trail import AuditTrail, AuditEntry
from .agent_registry import AgentRegistry, AgentAlreadyEnrolledError, AgentRevokedError
from .agent_registry import (
AgentRegistry,
AgentAlreadyEnrolledError,
AgentRevokedError,
FleetEnrollLockedError,
)
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
from .worker_stream import StreamWorker
from .monitor_router import resolve_target_monitor # QW1 — résolution écran cible
@@ -414,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,
@@ -429,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.
@@ -550,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 —
@@ -577,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:
@@ -802,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:
@@ -814,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)
# =========================================================================
@@ -1464,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
@@ -1595,13 +1720,28 @@ async def startup():
logger.info("VLM model: %s", _vlm_model_name)
print(f"\n VLM model: {_vlm_model_name}")
# Afficher le token API au démarrage pour que l'utilisateur puisse configurer l'agent
# Smoke-test santé des modèles VLM/grounding (NON bloquant, thread daemon) :
# détecte les modèles « aveugles » (sans capacité vision) au démarrage plutôt qu'en
# échec silencieux runtime (incident 2026-06-08, UI-TARS réimporté sans mmproj → 500 masqué).
def _smoke_model_health():
try:
from core.detection.model_health import smoke_check_models
from core.detection import vlm_config
_models = [vlm_config.get_vlm_model()] + list(getattr(vlm_config, "FALLBACK_VLM_MODELS", []))
smoke_check_models(sorted({m for m in _models if m}))
except Exception as _e: # ne jamais bloquer le démarrage
logger.debug("smoke santé modèles ignoré: %s", _e)
threading.Thread(target=_smoke_model_health, name="model-health-smoke", daemon=True).start()
# 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)
@@ -1648,7 +1788,15 @@ async def startup():
)
def _load_existing_workflows():
def _iter_workflow_json_files(wf_dir: Path):
"""Iterate workflow JSON files root-first, including machine subdirectories."""
return sorted(
wf_dir.rglob("*.json"),
key=lambda p: (len(p.relative_to(wf_dir).parts), str(p.relative_to(wf_dir))),
)
def _load_existing_workflows(clear: bool = False) -> int:
"""Charger les workflows JSON existants dans processor._workflows.
Supporte deux formats :
@@ -1657,6 +1805,10 @@ def _load_existing_workflows():
"""
from core.models.workflow_graph import Workflow
if clear:
with processor._data_lock:
processor._workflows.clear()
workflow_dirs = [
ROOT_DIR / "data" / "workflows",
ROOT_DIR / "data" / "training" / "workflows",
@@ -1667,7 +1819,7 @@ def _load_existing_workflows():
for wf_dir in workflow_dirs:
if not wf_dir.exists():
continue
for wf_file in wf_dir.glob("*.json"):
for wf_file in _iter_workflow_json_files(wf_dir):
try:
wf = Workflow.load_from_file(str(wf_file))
if wf and hasattr(wf, 'workflow_id'):
@@ -1689,7 +1841,10 @@ def _load_existing_workflows():
except Exception as e:
logger.debug(f"Skip workflow {wf_file.name}: {e}")
logger.info(f"Workflows chargés depuis disque: {loaded}")
with processor._data_lock:
total = len(processor._workflows)
logger.info(f"Workflows chargés depuis disque: {loaded} fichier(s), {total} en mémoire")
return total
@app.on_event("shutdown")
@@ -1773,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
@@ -1781,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:
@@ -1813,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é)
@@ -2209,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)
@@ -2858,7 +3029,7 @@ async def reload_workflows():
Appelé par le VWB après un export-for-lea pour que le streaming server
voie immédiatement les nouveaux workflows sans redémarrage.
"""
count = processor.reload_workflows()
count = _load_existing_workflows(clear=True)
return {"success": True, "workflows_count": count}
@@ -2901,6 +3072,129 @@ async def get_session(session_id: str):
# =========================================================================
# Marqueurs de dialogues/popups connus, détectables statiquement dans un workflow.
_DIALOG_MARKERS = (
"enregistrer sous",
"confirmer l'enregistrement",
"overwrite",
"remplacer",
"unsaved",
"modifications non enregistrées",
"save as",
)
def _iter_workflow_nodes(workflow: Any):
"""Itère les nodes d'un workflow (objet Workflow OU dict), de façon tolérante."""
if isinstance(workflow, dict):
yield from workflow.get("nodes", [])
return
nodes = getattr(workflow, "nodes", None)
if nodes is None:
return
# nodes peut être un dict {id: node} ou une liste
yield from (nodes.values() if isinstance(nodes, dict) else nodes)
def _node_text_blob(node: Any) -> str:
"""Concatène les champs texte pertinents d'un node pour la détection de dialogue."""
parts: List[str] = []
if isinstance(node, dict):
parts.append(str(node.get("label", "")))
tmpl = node.get("template", {}) or {}
window = tmpl.get("window", {}) if isinstance(tmpl, dict) else {}
if isinstance(window, dict):
parts.append(str(window.get("title_contains", "")))
parts.append(str(window.get("title_pattern", "")))
parts.append(str(node.get("expected_window_title", "")))
else:
parts.append(str(getattr(node, "label", "")))
tmpl = getattr(node, "template", None)
window = getattr(tmpl, "window", None) if tmpl is not None else None
if window is not None:
parts.append(str(getattr(window, "title_contains", "") or ""))
return " ".join(p for p in parts if p).lower()
def _detect_dialogs_static(workflow: Any) -> List[str]:
"""Détecte statiquement les dialogues/popups attendus d'un workflow.
Analyse les nodes (titres de fenêtre, labels) sans aucune exécution ni session.
Retourne la liste dédupliquée des marqueurs de dialogue trouvés.
"""
found: List[str] = []
for node in _iter_workflow_nodes(workflow):
blob = _node_text_blob(node)
for marker in _DIALOG_MARKERS:
if marker in blob and marker not in found:
found.append(marker)
return found
def _sanitize_action(action: Dict[str, Any]) -> Dict[str, Any]:
"""Réduit une action à des champs non sensibles pour l'aperçu préflight."""
return {
"type": action.get("type") or action.get("action"),
"target": (str(action.get("by_text") or action.get("target_spec") or "")[:60]) or None,
"has_coords": action.get("x_pct") is not None,
}
def _build_preflight_report(
workflow: Any, workflow_id: str, actions: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""Construit le rapport de préflight (analyse pure, AUCUN effet de bord).
Ne touche NI `_replay_queues`, NI `_replay_states`, NI aucun lock.
"""
from collections import Counter
action_types = dict(Counter(
(a.get("type") or a.get("action") or "unknown") for a in actions
))
name = workflow.get("name") if isinstance(workflow, dict) else getattr(workflow, "name", "")
return {
"workflow_known": True,
"workflow_id": workflow_id,
"workflow_name": name or "",
"n_actions": len(actions),
"action_types": action_types,
"dialogs_detected": _detect_dialogs_static(workflow),
"sample_actions": [_sanitize_action(a) for a in actions[:3]],
"non_destructive": True,
}
class PreflightRequest(BaseModel):
"""Requête de préflight replay (inspection non destructive d'un workflow)."""
workflow_id: str
params: Optional[Dict[str, Any]] = None
@app.post("/api/v1/traces/stream/replay/preflight")
async def preflight_replay(request: PreflightRequest):
"""Préflight non destructif d'un workflow de replay.
Prouve `commande → workflow connu → actions non vides → dialogues détectables`
SANS injecter d'action, sans modifier `_replay_queues`/`_replay_states`, sans lock.
"""
workflow_id = request.workflow_id
params = request.params or {}
with processor._data_lock:
workflow = processor._workflows.get(workflow_id)
if not workflow:
raise HTTPException(
status_code=404,
detail=f"Workflow '{workflow_id}' non trouvé. "
f"Workflows disponibles : {list(processor._workflows.keys())[:20]}"
)
# Conversion en actions (fonction pure, sans effet de bord sur les queues)
actions = _workflow_to_actions(workflow, params)
return _build_preflight_report(workflow, workflow_id, actions)
@app.post("/api/v1/traces/stream/replay")
@@ -4041,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,
@@ -4154,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(
@@ -6859,6 +7174,18 @@ async def agents_enroll(request: AgentEnrollRequest):
"existing": existing,
},
)
except FleetEnrollLockedError:
logger.warning(
f"[FLEET] Enrolement refuse machine_id={machine_id} : parc verrouille "
f"(RPA_FLEET_ENROLL_LOCKED)"
)
raise HTTPException(
status_code=403,
detail={
"error": "fleet_enroll_locked",
"message": "enrolement de nouveaux postes desactive (parc verrouille)",
},
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@@ -6937,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.
@@ -7473,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
@@ -7480,4 +7938,5 @@ if __name__ == "__main__":
level=logging.INFO,
format="%(asctime)s [API-STREAM] %(message)s",
)
uvicorn.run(app, host="0.0.0.0", port=5005)
import os as _os
uvicorn.run(app, host=_os.environ.get("RPA_BIND_HOST", "127.0.0.1"), port=5005)

View File

@@ -51,6 +51,8 @@ import unicodedata
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional
from core.detection import vlm_config
logger = logging.getLogger(__name__)
@@ -399,7 +401,10 @@ class DomainContext:
except Exception:
return ""
port = os.environ.get("GEMMA4_PORT", "11435")
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
port = os.environ.get("GEMMA4_PORT", _default_port)
url = f"http://localhost:{port}/api/chat"
base = ""
@@ -427,7 +432,7 @@ class DomainContext:
resp = _requests.post(
url,
json={
"model": "gemma4:e4b",
"model": vlm_config.get_vlm_model(),
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"temperature": 0.3, "num_predict": 200},

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

@@ -20,6 +20,8 @@ import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from core.detection import vlm_config
logger = logging.getLogger(__name__)
# Seuils de détection configurables
@@ -434,7 +436,7 @@ class ReplayVerifier:
) -> Optional[Dict[str, Any]]:
"""Appeler le VLM pour évaluer sémantiquement le résultat de l'action.
Utilise gemma4 en mode texte+images (Docker port 11435) pour analyser
Utilise le VLM (résolu via vlm_config) en mode texte+images pour analyser
les screenshots avant/après et dire si le résultat attendu est atteint.
Sur Citrix (image plate), c'est la SEULE façon de vérifier intelligemment
@@ -449,7 +451,10 @@ class ReplayVerifier:
if not screenshot_after:
return None
gemma4_port = os.environ.get("GEMMA4_PORT", "11435")
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
gemma4_port = os.environ.get("GEMMA4_PORT", _default_port)
gemma4_url = f"http://localhost:{gemma4_port}/api/chat"
# Construire le prompt Critic
@@ -497,7 +502,7 @@ class ReplayVerifier:
resp = _requests.post(
gemma4_url,
json={
"model": "gemma4:e4b",
"model": vlm_config.get_vlm_model(),
"messages": messages,
"stream": False,
"think": True,

View File

@@ -27,6 +27,7 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from core.grounding.bbox_parser import parse_bbox_to_norm, parse_bbox_to_norm_validated
from core.detection import vlm_config
logger = logging.getLogger("api_stream")
@@ -869,6 +870,50 @@ def _vlm_quick_find(
# Résolution par VLM Grounding Direct (configurable via RPA_VLM_MODEL)
# ---------------------------------------------------------------------------
# DETTE-019 — confiance grounding DÉRIVÉE (et NON une confiance modèle native).
# Le grounding VLM ne fournit aucune confiance exploitable : le prompt demande
# {"x","y"} et aucun logprob de localisation n'est extrait (confirmé QG Qwen
# 2026-06-15). Le seul signal de confiance RÉEL est sémantique : le texte cible
# est-il bien à la position trouvée ? On le dérive via la même vérif OCR que le
# pré-check aval (`_validate_text_at_position`). Approche validée par Dom.
# ⚠ Confiance CONTEXTUELLE, pas une probabilité du modèle : ne pas l'afficher
# comme « confiance du VLM » côté dashboard.
_GROUNDING_CONF_TEXT_CONFIRMED = 0.90 # texte cible retrouvé à la position
_GROUNDING_CONF_UNVERIFIABLE = 0.70 # pas de texte vérifiable → neutre (> seuil 0.60)
_GROUNDING_CONF_TEXT_ABSENT = 0.45 # texte cible absent → < seuil 0.60 → rejeté
def _grounding_semantic_confidence(
screenshot_path: str,
x_pct: float,
y_pct: float,
by_text: str,
screen_width: int,
screen_height: int,
) -> float:
"""Confiance DÉRIVÉE (sémantique) d'un grounding — DETTE-019.
Mesure contextuelle, PAS une confiance du modèle : le texte cible `by_text`
est-il présent à la position (x_pct, y_pct) ? Réutilise la garde OCR du
pré-check aval (`_validate_text_at_position`).
- texte confirmé → CONFIRMED (accepté)
- texte absent → ABSENT (< seuil → rejeté par
`_validate_resolution_quality`)
- pas de by_text / OCR KO → UNVERIFIABLE (neutre, > seuil : pas de faux rejet)
"""
by_text = (by_text or "").strip()
if not by_text:
return _GROUNDING_CONF_UNVERIFIABLE
try:
is_valid, _observed, _ms = _validate_text_at_position(
screenshot_path, x_pct, y_pct, by_text, screen_width, screen_height,
)
except Exception as e: # OCR indisponible : dégradation gracieuse, pas de pénalité
logger.debug("Grounding confidence : vérif sémantique indisponible (%s) → neutre", e)
return _GROUNDING_CONF_UNVERIFIABLE
return _GROUNDING_CONF_TEXT_CONFIRMED if is_valid else _GROUNDING_CONF_TEXT_ABSENT
def _resolve_by_grounding(
screenshot_path: str,
@@ -878,8 +923,8 @@ def _resolve_by_grounding(
) -> Optional[Dict[str, Any]]:
"""Résoudre une cible via grounding VLM direct.
Le modèle VLM (gemma4:e4b par défaut, configurable via RPA_VLM_MODEL)
reçoit le screenshot + une description textuelle et retourne
Le modèle de grounding bbox (résolu via vlm_config.get_bbox_grounding_model,
défaut qwen2.5vl:7b-rpa) reçoit le screenshot + une description et retourne
directement les coordonnées de l'élément. Pas de SomEngine,
pas de numérotation — le VLM fait du grounding UI natif.
@@ -944,32 +989,66 @@ def _resolve_by_grounding(
# Le grounding nécessite un modèle entraîné pour les coordonnées (bbox_2d).
# Qwen2.5-VL est le seul qui retourne des positions précises.
# gemma4 comprend les images mais ne sait pas localiser en coordonnées.
_grounding_model = os.environ.get("RPA_GROUNDING_MODEL", "qwen2.5vl:7b")
# D5-v3b : résolution via helper dédié (var RPA_BBOX_GROUNDING_MODEL,
# défaut qwen2.5vl:7b-rpa présent sur DGX) — désambiguïse RPA_GROUNDING_MODEL.
_grounding_model = vlm_config.get_bbox_grounding_model()
# Appel VLM — vLLM (GPU, rapide) en priorité, Ollama en fallback
import requests as _requests
content = ""
# Port vLLM configurable via env
_vllm_port = os.environ.get("VLLM_PORT", "8100")
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct-AWQ")
# Grounder POC validé (bench Easily réel 12→13/06, 0.933) : Qwen3-VL-4B/vLLM.
# Activé via RPA_GROUNDING_ENGINE=qwen3vl_vllm (défaut OFF = legacy Qwen2.5-VL
# inchangé, byte-identique). Le 0.933 est une propriété de
# (modèle+moteur+prompt+parser+think) → ce mode reproduit le tuple validé :
# prompt point 0-1, think=false, parse /1000 (dissout DETTE-006), method gardée.
# Réf design : inbox_codex/2026-06-13_0210_..._DESIGN-CABLAGE-RESOLVE-ENGINE-QWEN3VL.md
_grounding_engine = os.environ.get("RPA_GROUNDING_ENGINE", "").strip().lower()
_use_qwen3vl = _grounding_engine == "qwen3vl_vllm"
if _use_qwen3vl:
_vllm_port = os.environ.get("VLLM_PORT", "8001")
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
_sys_prompt = (
"Tu localises une cible sur une capture d'écran d'interface. "
"Si la cible n'est pas clairement visible, réponds par une abstention."
)
_user_text = (
f"Cible : « {description} ». Donne le point de clic en FRACTIONS de "
"l'image : x et y entre 0.0 et 1.0 (0,0 = coin haut-gauche, "
'1,1 = coin bas-droite). Réponds UNIQUEMENT par un JSON '
'{"x":0.xx,"y":0.xx} ou {"abstain":true} si la cible n\'est pas '
"clairement visible."
)
else:
_vllm_port = os.environ.get("VLLM_PORT", "8100")
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct-AWQ")
_sys_prompt = "You locate UI elements on screenshots. Return coordinates."
_user_text = prompt
# Essai 1 : vLLM (API OpenAI-compatible, GPU)
try:
_vllm_payload = {
"model": _vllm_model,
"messages": [
{"role": "system", "content": _sys_prompt},
{"role": "user", "content": [
{"type": "text", "text": _user_text},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{shot_b64}"}},
]},
],
"temperature": 0.1,
"max_tokens": 80,
}
if _use_qwen3vl:
# think=false obligatoire (Qwen3-VL/vLLM) : sinon raisonnement →
# grounding inutilisable (observé au bench).
_vllm_payload["chat_template_kwargs"] = {"enable_thinking": False}
_vllm_payload["temperature"] = 0.0
_vllm_payload["max_tokens"] = 256
vllm_resp = _requests.post(
f"http://localhost:{_vllm_port}/v1/chat/completions",
json={
"model": _vllm_model,
"messages": [
{"role": "system", "content": "You locate UI elements on screenshots. Return coordinates."},
{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{shot_b64}"}},
]},
],
"temperature": 0.1,
"max_tokens": 80,
},
json=_vllm_payload,
timeout=30,
)
if vllm_resp.ok:
@@ -979,8 +1058,11 @@ def _resolve_by_grounding(
except Exception as e:
logger.debug("vLLM non disponible (%s), fallback Ollama", e)
# Essai 2 : Ollama (qwen2.5vl:7b pour le grounding — format bbox_2d natif)
if not content:
# Essai 2 : Ollama (qwen2.5vl:7b pour le grounding — format bbox_2d natif).
# En mode qwen3vl_vllm, PAS de fallback Ollama (modèle non-viable/dangereux
# prouvé au bench) : si vLLM échoue, on abstient (None) et la cascade externe
# (OCR/template/SoM) prend le relais.
if not content and not _use_qwen3vl:
try:
resp = _requests.post("http://localhost:11434/api/chat", json={
"model": _grounding_model,
@@ -1000,12 +1082,19 @@ def _resolve_by_grounding(
elapsed = time.time() - t0
# Parser la réponse — délégué à core.grounding.bbox_parser
x_pct, y_pct = parse_bbox_to_norm(content, small_w, small_h)
if _use_qwen3vl:
# Qwen3-VL : 0-1 (consigne respectée) OU 0-1000 natif. divisor=1000 gère
# les DEUX (xy_json ≤1 pris tel quel ; bbox_2d / valeurs >1 → ÷1000).
# Résolution-indépendant → dissout le bug d'échelle DETTE-006.
x_pct, y_pct = parse_bbox_to_norm(content, 1000, 1000)
else:
x_pct, y_pct = parse_bbox_to_norm(content, small_w, small_h)
if x_pct is None or y_pct is None:
# Fallback multi-image : screenshot + crop → grounding sans description
# Fallback multi-image : screenshot + crop → grounding sans description.
# Skippé en mode qwen3vl_vllm (le fallback s'appuie sur Ollama qwen2.5vl).
anchor_b64 = target_spec.get("anchor_image_base64", "")
if anchor_b64:
if anchor_b64 and not _use_qwen3vl:
try:
prompt_mi = (
"Image 1 is a screenshot. Image 2 shows a UI element.\n"
@@ -1068,18 +1157,28 @@ def _resolve_by_grounding(
_grounding_model, description[:50], x_pct, y_pct, elapsed,
)
# DETTE-019 : confiance DÉRIVÉE sémantique (le texte cible est-il à la
# position ?), plus de score figé. Cohérence score == confidence.
_conf = _grounding_semantic_confidence(
screenshot_path, round(x_pct, 6), round(y_pct, 6),
by_text, screen_width, screen_height,
)
return {
"resolved": True,
"method": "grounding_vlm",
# method gardée par _RESOLUTION_MIN_SCORES : en mode qwen3vl, "grounding"
# (clé exacte, seuil 0.60) → Check-1 du validateur s'applique. Le legacy
# garde "grounding_vlm" (non gardé aujourd'hui — bug latent, DETTE séparée).
"method": "grounding" if _use_qwen3vl else "grounding_vlm",
"x_pct": round(x_pct, 6),
"y_pct": round(y_pct, 6),
"matched_element": {
"label": description[:60],
"type": "grounding",
"role": "grounding_vlm",
"confidence": 0.85,
"confidence": _conf,
},
"score": 0.85,
"score": _conf,
}
@@ -1645,6 +1744,15 @@ def _resolve_by_ocr_text(
reco_arch='crnn_vgg16_bn',
pretrained=True,
)
# Device paramétrable avec garde-fou VRAM (VLM sur DGX distant).
# cuda si VRAM locale libre, cpu sinon — jamais de hardcode cuda.
try:
from core.gpu.device_policy import resolve_device
if resolve_device("auto") == "cuda":
_V4_OCR_PREDICTOR = _V4_OCR_PREDICTOR.cuda()
logger.info("docTR V4 OCR chargé sur cuda")
except Exception as e:
logger.debug("docTR V4 OCR reste sur CPU (%s)", e)
doc = DocumentFile.from_images([screenshot_path])
result = _V4_OCR_PREDICTOR(doc)
@@ -2909,7 +3017,7 @@ def _pre_analyze_screen_sync(
) -> Dict[str, Any]:
"""Pré-analyse synchrone de l'écran via VLM.
Utilise gemma4 (Docker port 11435) pour détecter :
Utilise le VLM (résolu via vlm_config, endpoint Ollama) pour détecter :
1. Popups/dialogues modaux (avec coordonnées du bouton à cliquer)
2. États incohérents avec l'attendu
@@ -2917,7 +3025,10 @@ def _pre_analyze_screen_sync(
"""
import requests as _requests
gemma4_port = os.environ.get("GEMMA4_PORT", "11435")
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
gemma4_port = os.environ.get("GEMMA4_PORT", _default_port)
gemma4_url = f"http://localhost:{gemma4_port}/api/chat"
# Charger le contexte métier pour l'Observer
@@ -2945,7 +3056,7 @@ def _pre_analyze_screen_sync(
resp = _requests.post(
gemma4_url,
json={
"model": "gemma4:e4b",
"model": vlm_config.get_vlm_model(),
"messages": messages,
"stream": False,
"think": True,
@@ -3030,7 +3141,7 @@ def _locate_popup_button(
resp = _requests.post(
ollama_url,
json={
"model": "qwen2.5vl:7b",
"model": vlm_config.get_bbox_grounding_model(),
"messages": [{"role": "user", "content": prompt, "images": [screenshot_b64]}],
"stream": False,
# D5-v3a (2026-05-25) num_ctx=4096 explicite : éviter fuite 8192

View File

@@ -18,6 +18,8 @@ import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from core.detection import vlm_config
logger = logging.getLogger(__name__)
try:
@@ -184,10 +186,11 @@ def _call_llm_for_contextual_checks(
"""
import requests
# Défaut gemma4:latest : meilleur compromis détection/latence sur bench
# 2026-05-06 (cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md). medgemma:4b
# retournait systématiquement [] (refus de signaler).
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "gemma4:latest")
# Modèle : override explicite RPA_SAFETY_CHECKS_LLM_MODEL prioritaire ; sinon
# résolution centralisée vlm_config (gemma4:latest si dispo — meilleur bench
# 2026-05-06 cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md — sinon fallback DGX).
# Pas de fallback silencieux vers un modèle absent : get_vlm_model vérifie /api/tags.
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "") or vlm_config.get_vlm_model()
# Timeout 7s : warm avg gemma4 = 2.9s + marge 4s. Cold start ~10s couvert
# si le modèle reste résident (OLLAMA_KEEP_ALIVE=24h recommandé prod).
timeout_s = _env_int("RPA_SAFETY_CHECKS_LLM_TIMEOUT_S", 7)

View File

@@ -2486,30 +2486,25 @@ class StreamProcessor:
from core.models.workflow_graph import Workflow
count = 0
# Charger les workflows du dossier racine (rétrocompatibilité)
for wf_file in sorted(workflows_dir.glob("*.json")):
workflow_files = sorted(
workflows_dir.rglob("*.json"),
key=lambda p: (
len(p.relative_to(workflows_dir).parts),
str(p.relative_to(workflows_dir)),
),
)
for wf_file in workflow_files:
try:
wf = Workflow.load_from_file(wf_file)
rel_parts = wf_file.relative_to(workflows_dir).parts
if len(rel_parts) > 1 and not hasattr(wf, '_machine_id'):
wf._machine_id = rel_parts[0]
self._workflows[wf.workflow_id] = wf
count += 1
except Exception as e:
logger.warning(f"Impossible de charger {wf_file.name}: {e}")
# Charger les workflows des sous-dossiers par machine
for machine_dir in sorted(workflows_dir.iterdir()):
if not machine_dir.is_dir():
continue
for wf_file in sorted(machine_dir.glob("*.json")):
try:
wf = Workflow.load_from_file(wf_file)
# Stocker le machine_id dans les métadonnées du workflow
if not hasattr(wf, '_machine_id'):
wf._machine_id = machine_dir.name
self._workflows[wf.workflow_id] = wf
count += 1
except Exception as e:
logger.warning(f"Impossible de charger {wf_file.name}: {e}")
if count:
logger.info(f"{count} workflow(s) chargé(s) depuis {workflows_dir}")
except ImportError:
@@ -3071,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)
@@ -4449,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

@@ -26,6 +26,8 @@ import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from core.detection import vlm_config
logger = logging.getLogger(__name__)
@@ -94,7 +96,10 @@ class TaskPlanner:
"""
def __init__(self, gemma4_port: str = "", domain_id: str = ""):
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", "11435")
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", _default_port)
self._gemma4_url = f"http://localhost:{self._gemma4_port}/api/chat"
self._domain_id = domain_id or os.environ.get("RPA_DOMAIN", "generic")
@@ -176,7 +181,7 @@ class TaskPlanner:
resp = _requests.post(
self._gemma4_url,
json={
"model": "gemma4:e4b",
"model": vlm_config.get_vlm_model(),
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"think": True,
@@ -499,7 +504,7 @@ class TaskPlanner:
resp = _requests.post(
self._gemma4_url,
json={
"model": "gemma4:e4b",
"model": vlm_config.get_vlm_model(),
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"think": True,

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

@@ -3,9 +3,19 @@ Orchestrateur VRAM — gère le chargement/déchargement des modèles selon le m
Deux modes :
- SHADOW : streaming server + agent_chat actifs, VLM raisonnement déchargé
- REPLAY : VLM raisonnement (qwen2.5vl:7b) chargé, services non-essentiels stoppés
- REPLAY : VLM raisonnement (cf. get_reasoning_model) chargé, services non-essentiels stoppés
Bascule automatique ou manuelle selon le contexte.
⚠️ LIMITE POST-DGX (2026-06-05) — DETTE CONNUE :
Cet orchestrateur a été conçu pour un Ollama **local** : le `sudo systemctl
restart ollama` (switch_to_replay / switch_to_shadow) et `nvidia-smi`
(get_free_vram_gb / get_used_vram_gb) ne ciblent que la machine locale.
Or Ollama tourne désormais sur le **DGX via tunnel SSH** (OLLAMA_URL pointe
le tunnel). Dans ce cas le restart local est **inopérant** : il ne purge PAS
la VRAM des VLM distants et nvidia-smi mesure le GPU local, pas celui du DGX.
À rendre conditionnel (tunnel distant vs Ollama local) avant tout usage en
mode DGX — logique runtime inchangée ici (correction = décision Dom).
"""
import logging
@@ -15,10 +25,12 @@ import time
from enum import Enum
from typing import Optional
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
REASONING_MODEL = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
REASONING_MODEL = get_reasoning_model()
MIN_VRAM_FOR_REASONING = 5.0 # Go minimum pour charger le modèle de raisonnement

View File

@@ -0,0 +1,97 @@
"""Santé des modèles VLM/grounding — détection des modèles « aveugles ».
Motivation (incident 2026-06-08) : un modèle de grounding réimporté sans son projecteur
vision (`mmproj`) déclare des `capabilities` sans `vision` et renvoie HTTP 500 sur toute
requête image. Dans la cascade `find_element_on_screen`, l'échec était avalé (`return None`)
et masqué par le fallback VLM → panne invisible malgré les tests.
Ce module permet de :
- **gater** un appel image : vérifier que le modèle a `vision` avant de lui envoyer une image
(évite le 500, skip propre vers le niveau suivant) ;
- **smoke-tester** les modèles de grounding/VLM au démarrage : rendre une panne visible
immédiatement plutôt que noyée dans un `warning` runtime.
Volontairement sans dépendance lourde : un simple appel `/api/show` Ollama.
"""
from __future__ import annotations
import logging
import os
from typing import Dict, List
import requests
logger = logging.getLogger(__name__)
DEFAULT_ENDPOINT = os.environ.get("OLLAMA_URL", "http://localhost:11434")
# Cache (endpoint::model) -> bool. Un modèle ne change pas de capacité en cours de session.
_VISION_CACHE: Dict[str, bool] = {}
def has_vision_capability(
model: str,
endpoint: str = DEFAULT_ENDPOINT,
*,
use_cache: bool = True,
timeout: float = 5.0,
) -> bool:
"""Retourne True si le modèle Ollama déclare la capacité ``vision``.
Interroge ``/api/show`` et lit ``capabilities``. Résultat mis en cache par
``(endpoint, model)``.
**Fail-open** : en cas d'erreur réseau/HTTP sur ``/api/show`` (indisponibilité
transitoire), retourne ``True`` — on ne bloque pas le grounding sur un doute ;
l'appel image en aval gérera l'échec. Seule une réponse explicite **sans** ``vision``
retourne ``False`` (modèle réellement aveugle).
"""
key = f"{endpoint}::{model}"
if use_cache and key in _VISION_CACHE:
return _VISION_CACHE[key]
try:
resp = requests.post(f"{endpoint}/api/show", json={"name": model}, timeout=timeout)
if resp.status_code != 200:
logger.debug("model_health: /api/show %s → HTTP %s (fail-open)", model, resp.status_code)
return True
caps = resp.json().get("capabilities", []) or []
has_vision = "vision" in caps
_VISION_CACHE[key] = has_vision
if not has_vision:
logger.warning(
"model_health: modèle '%s' SANS capacité 'vision' (capabilities=%s) — "
"modèle aveugle, les requêtes image échoueront",
model,
caps,
)
return has_vision
except Exception as e: # réseau, JSON, timeout
logger.debug("model_health: échec vérification vision %s: %s (fail-open)", model, e)
return True
def smoke_check_models(models: List[str], endpoint: str = DEFAULT_ENDPOINT) -> Dict[str, bool]:
"""Vérifie la capacité ``vision`` d'une liste de modèles (au démarrage/healthcheck).
Non bloquant : logue ``info`` par modèle sain, ``error`` par modèle aveugle.
Retourne ``{model: has_vision}``.
"""
results: Dict[str, bool] = {}
for m in models:
if not m:
continue
ok = has_vision_capability(m, endpoint, use_cache=False)
results[m] = ok
if ok:
logger.info("model_health[smoke]: %s → vision OK", m)
else:
logger.error(
"model_health[smoke]: %s → AVEUGLE (pas de vision) — grounding image KO sur ce modèle",
m,
)
return results
def reset_cache() -> None:
"""Vide le cache de capacités (tests, ou après réimport d'un modèle)."""
_VISION_CACHE.clear()

View File

@@ -89,8 +89,11 @@ class SomResult:
class SomEngine:
"""Moteur Set-of-Mark : YOLO + docTR + annotation."""
def __init__(self, device: str = "cuda"):
self._device = device
def __init__(self, device: str = "auto"):
# Résolution paramétrable avec garde-fou VRAM (cf. core/gpu/device_policy).
# "auto" → cuda si VRAM libre suffisante (VLM sur DGX distant), sinon cpu.
from core.gpu.device_policy import resolve_device
self._device = resolve_device(device)
self._yolo = None
self._ocr = None
self._loaded = False
@@ -300,8 +303,12 @@ _shared_engine: Optional[SomEngine] = None
_shared_lock = __import__("threading").Lock()
def get_shared_engine(device: str = "cpu") -> Optional[SomEngine]:
"""Singleton SomEngine partagé entre tous les modules."""
def get_shared_engine(device: str = "auto") -> Optional[SomEngine]:
"""Singleton SomEngine partagé entre tous les modules.
device="auto" (défaut) délègue à core.gpu.device_policy.resolve_device :
cuda si la VRAM locale est libre, cpu sinon. Passer "cpu" force le CPU.
"""
global _shared_engine
if _shared_engine is None:
with _shared_lock:

View File

@@ -11,7 +11,7 @@ Basée sur l'architecture éprouvée de la V2.
from typing import List, Dict, Optional, Any, Tuple
from pathlib import Path
from dataclasses import dataclass
from dataclasses import dataclass, field
import logging
import os
import time
@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
from ..models.ui_element import UIElement, UIElementEmbeddings, VisualFeatures
from .ollama_client import OllamaClient, check_ollama_available
from . import vlm_config
# Import OWL-v2 (optionnel)
try:
@@ -71,10 +72,13 @@ class BoundingBox:
@dataclass
class DetectionConfig:
"""Configuration de la détection UI hybride"""
# VLM — modèle configurable via variable d'environnement RPA_VLM_MODEL
# Par défaut : gemma4:e4b (meilleur grounding + contextualisation)
# Fallback : qwen3-vl:8b si gemma4 non disponible
vlm_model: str = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
# VLM — modèle configurable via RPA_VLM_MODEL / VLM_MODEL.
# default_factory : lu à l'instanciation (pas figé à l'import) ; None si non
# défini → résolution lazy via vlm_config.get_vlm_model() dans _initialize_vlm
# (pas de hardcode, pas d'appel réseau à l'import).
vlm_model: Optional[str] = field(
default_factory=lambda: os.environ.get("RPA_VLM_MODEL") or os.environ.get("VLM_MODEL")
)
vlm_endpoint: str = "http://localhost:11434"
use_vlm_classification: bool = True # Utiliser VLM pour classifier
@@ -136,11 +140,16 @@ class UIDetector:
"""Initialiser le client VLM"""
try:
if check_ollama_available(self.config.vlm_endpoint):
# Résolution lazy : si aucun modèle explicite, vlm_config résout
# (avec fallback) en interrogeant /api/tags. On normalise la config
# pour que les métadonnées de sortie reflètent le modèle réel.
model = self.config.vlm_model or vlm_config.get_vlm_model(self.config.vlm_endpoint)
self.config.vlm_model = model
self.vlm_client = OllamaClient(
endpoint=self.config.vlm_endpoint,
model=self.config.vlm_model
model=model
)
logger.info(f"✓ VLM initialized: {self.config.vlm_model}")
logger.info(f"✓ VLM initialized: {model}")
else:
logger.warning("Ollama not available, VLM classification disabled")
self.vlm_client = None

View File

@@ -23,13 +23,19 @@ import requests
logger = logging.getLogger(__name__)
# Modèle VLM par défaut — Gemma 4 latest (8B dense, Q4_K_M)
# Nécessite think=false dans le payload (sinon tokens vides sur Ollama >=0.20)
# Bench 2026-05-16 : tentatives qwen2.5vl:7b et :3b écartées (runtime Ollama
# avec context = 10-13 GB → débordent toutes en 100% CPU sur RTX 5070 12 GB).
# qwen3-vl:8b écarté : think:false ignoré → tout en thinking field, pas de réponse.
# gemma4:latest reste le seul stable malgré son cold start ~20s (1 fois par run).
DEFAULT_VLM_MODEL = "gemma4:latest"
# Modèle VLM par défaut — DGX-safe (P1.w, 2026-06-05).
# Historiquement `gemma4:latest`, mais ce modèle peut être absent du tunnel DGX
# (dépull) : sans env `RPA_VLM_MODEL`/`VLM_MODEL`, le fallback tombait alors en
# 404 Ollama et tout le pipeline VLM échouait avant un test Lea humain.
# `qwen2.5vl:7b-rpa` est confirmé présent sur DGX et déjà utilisé par les chemins
# reasoning (cf. get_reasoning_model) et bbox grounding (DEFAULT_GROUNDING_FALLBACK)
# → default cohérent et sûr. `gemma4:latest` reste accessible via env explicite.
DEFAULT_VLM_MODEL = "qwen2.5vl:7b-rpa"
# Allow-list des modèles VLM généralistes confirmés présents sur le DGX et donc
# utilisables comme default sans risque de 404. `gemma4:31b-cloud` est réservé au
# benchmark P1.y (≈20 Go VRAM, latence élevée), pas au default runtime.
DGX_SAFE_VLM_MODELS = ("qwen2.5vl:7b-rpa", "qwen2.5vl:7b")
# Modèles de fallback, testés dans l'ordre si le modèle principal n'est pas dispo
FALLBACK_VLM_MODELS = ["qwen3-vl:8b", "0000/ui-tars-1.5-7b-q8_0:7b"]
@@ -155,6 +161,10 @@ def is_thinking_model(model_name: str) -> bool:
# Profil grounding par défaut — qwen3.5:9b avec ctx 4096 et prefill JSON.
# Cohérent avec décision Codex après revue Gemini : empêcher rechauffe
# qwen2.5vl en ctx 8192 et garantir un chemin grounding reproductible.
# ⚠️ DETTE (2026-06-05) : qwen3.5:9b est ABSENT du endpoint Ollama/DGX → le
# chemin grounding JSON retombe en pratique sur DEFAULT_GROUNDING_FALLBACK
# (qwen2.5vl:7b-rpa). Ce chemin JSON est donc peu/pas exercé au runtime DGX.
# À pull sur le DGX OU nettoyer (aligner sur le fallback) — décision Dom.
DEFAULT_GROUNDING_MODEL = "qwen3.5:9b"
DEFAULT_GROUNDING_CTX = 4096
DEFAULT_GROUNDING_PREFILL = '{"x_pct":'
@@ -234,6 +244,69 @@ def get_grounding_profile(endpoint: str = DEFAULT_OLLAMA_ENDPOINT) -> dict:
}
def get_bbox_grounding_model() -> str:
"""Retourne le modèle pour le grounding **format bbox_2d natif** (qwen2.5vl).
Distinct de get_grounding_profile() (format JSON {x_pct,y_pct} via prefill,
défaut qwen3.5:9b). Les chemins bbox_2d de resolve_engine
(`parse_bbox_to_norm` / `parse_bbox_to_norm_validated`) exigent un modèle
de la famille qwen2.5vl qui émet des coordonnées en pixels.
D5-v3b (2026-06-03) : désambiguïse l'env var. Historiquement le site bbox
lisait `RPA_GROUNDING_MODEL`, partagé avec get_grounding_profile() qui
attend un modèle JSON → conflit documenté. On introduit une var dédiée.
Ordre de résolution :
1. RPA_BBOX_GROUNDING_MODEL (dédié, prioritaire)
2. RPA_GROUNDING_MODEL (rétrocompat — ancien comportement)
3. DEFAULT_GROUNDING_FALLBACK (qwen2.5vl:7b-rpa, présent sur DGX)
Returns:
Nom du modèle bbox_2d (ex: "qwen2.5vl:7b-rpa")
"""
return (
os.environ.get("RPA_BBOX_GROUNDING_MODEL")
or os.environ.get("RPA_GROUNDING_MODEL")
or DEFAULT_GROUNDING_FALLBACK
)
# ────────────────────────────────────────────────────────────────────────────
# P1.z (2026-06-04) : résolution centralisée du modèle V4/reasoning, DGX-safe
# ────────────────────────────────────────────────────────────────────────────
# Modèle de raisonnement V4/ORA par défaut — DGX-safe.
# Les chemins reasoning (ORALoop, détection dialogue/popup, vram_orchestrator)
# font du VLM généraliste sur screenshot (JSON action/decision), pas du grounding
# bbox. Le default est aligné sur le modèle présent sur le tunnel DGX
# (qwen2.5vl:7b-rpa), PAS sur `qwen2.5vl:7b` brut qui est absent du DGX → 404.
DEFAULT_REASONING_MODEL = "qwen2.5vl:7b-rpa"
def get_reasoning_model() -> str:
"""Retourne le modèle pour les chemins V4/reasoning (ORALoop, détection
dialogue/popup, orchestration VRAM).
Distinct du grounding (get_grounding_profile / get_bbox_grounding_model) :
ici on raisonne en langage naturel + JSON sur un screenshot, pas de
coordonnées. Pas d'appel réseau (résolution lazy, safe à l'import).
Ordre de résolution :
1. RPA_REASONING_MODEL (dédié, prioritaire)
2. RPA_VLM_MODEL / VLM_MODEL (hérite de la config VLM existante)
3. DEFAULT_REASONING_MODEL (qwen2.5vl:7b-rpa, présent sur DGX)
Returns:
Nom du modèle de raisonnement (ex: "qwen2.5vl:7b-rpa").
"""
return (
os.environ.get("RPA_REASONING_MODEL")
or os.environ.get("RPA_VLM_MODEL")
or os.environ.get("VLM_MODEL")
or DEFAULT_REASONING_MODEL
)
def needs_think_false(model_name: str) -> bool:
"""Détermine si un modèle nécessite think=false dans le payload.

View File

@@ -0,0 +1,191 @@
"""OpenAI-compatible adapter that writes LeaBench-compatible prediction JSONL.
Benchmark only — strictly outside Lea runtime. It targets any server exposing
`POST /v1/chat/completions` with vision support (vLLM, SGLang, TGI, ...) and
never controls the desktop.
Réutilise la logique de prompt/parsing/normalisation de l'adapter Ollama
(`ollama_lea_bench_adapter`) pour garantir un comportement strictement aligné ;
seuls le format du payload (data URL `image_url`) et le parsing de la réponse
(`choices[0].message.content`) diffèrent.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any, Callable
import requests
from core.evaluation.computer_use_bench import BenchCase, load_cases
from core.evaluation.ollama_lea_bench_adapter import (
OLLAMA_SYSTEM_PROMPT,
build_ollama_user_prompt,
encode_screenshot_base64,
extract_json_object,
normalize_prediction,
_safe_abstain,
)
DEFAULT_MODEL = "qwen3-vl:8b"
DEFAULT_BASE_URL = "http://localhost:8001"
HttpPost = Callable[..., Any]
ImageEncoder = Callable[[Path], str]
def build_openai_compat_payload(
case: BenchCase,
*,
model: str,
image_b64: str,
temperature: float = 0.1,
max_tokens: int = 200,
json_response_format: bool = True,
) -> dict[str, Any]:
"""Construit un payload `/v1/chat/completions` compatible vision.
L'image est passée en data URL JPEG (`data:image/jpeg;base64,...`), format
`image_url` standard OpenAI/vLLM/SGLang. Le prompt système et utilisateur
sont ceux de l'adapter Ollama (provider-neutral).
"""
payload: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": OLLAMA_SYSTEM_PROMPT.strip()},
{
"role": "user",
"content": [
{"type": "text", "text": build_ollama_user_prompt(case)},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
},
],
},
],
"stream": False,
"temperature": temperature,
"max_tokens": max_tokens,
}
if json_response_format:
# Supporté par OpenAI, vLLM (>=0.4) et SGLang ; ignoré silencieusement
# par les serveurs qui ne le connaissent pas.
payload["response_format"] = {"type": "json_object"}
return payload
def _extract_content(response_json: Any) -> str | None:
"""Extrait `choices[0].message.content` d'une réponse OpenAI-compatible."""
if not isinstance(response_json, dict):
return None
choices = response_json.get("choices")
if not isinstance(choices, list) or not choices:
return None
message = choices[0].get("message") if isinstance(choices[0], dict) else None
if not isinstance(message, dict):
return None
content = message.get("content")
return content if isinstance(content, str) else None
def run_openai_compat_case(
case: BenchCase,
*,
model: str = DEFAULT_MODEL,
base_url: str = DEFAULT_BASE_URL,
timeout: int = 45,
post: HttpPost = requests.post,
image_encoder: ImageEncoder = encode_screenshot_base64,
retries: int = 1,
) -> dict[str, Any]:
image_b64 = image_encoder(case.screenshot_path)
payload = build_openai_compat_payload(case, model=model, image_b64=image_b64)
url = f"{base_url.rstrip('/')}/v1/chat/completions"
last_error = ""
for attempt in range(retries + 1):
try:
response = post(url, json=payload, timeout=timeout)
if getattr(response, "status_code", 0) != 200:
last_error = f"HTTP {getattr(response, 'status_code', 'unknown')}"
else:
text = _extract_content(response.json())
if text is None:
last_error = "missing_choices_content"
else:
parsed = extract_json_object(text)
if parsed is None and attempt < retries:
# On relance une fois en rappelant le contrat JSON.
text_msg = payload["messages"][1]["content"][0]
text_msg["text"] += (
"\nYour previous answer was not valid JSON. Output JSON only."
)
continue
return normalize_prediction(case, parsed, model=model, raw_text=text)
except Exception as exc: # pragma: no cover - exercised via fake response paths
last_error = str(exc)
if attempt < retries:
time.sleep(2)
return _safe_abstain(case, model, f"openai_compat_error: {last_error[:80]}")
def write_openai_compat_predictions(
cases: list[BenchCase],
output_path: str | Path,
*,
model: str = DEFAULT_MODEL,
base_url: str = DEFAULT_BASE_URL,
timeout: int = 45,
post: HttpPost = requests.post,
image_encoder: ImageEncoder = encode_screenshot_base64,
) -> None:
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", encoding="utf-8") as f:
for case in cases:
prediction = run_openai_compat_case(
case,
model=model,
base_url=base_url,
timeout=timeout,
post=post,
image_encoder=image_encoder,
)
f.write(json.dumps(prediction, ensure_ascii=False) + "\n")
f.flush()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Run an OpenAI-compatible vision server on LeaBench cases."
)
parser.add_argument("--cases", required=True, help="Path to LeaBench cases JSONL.")
parser.add_argument("--output", required=True, help="Output predictions JSONL.")
parser.add_argument("--repo-root", default=".", help="Repository root for relative screenshot paths.")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="OpenAI-compatible base URL.")
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name served by the endpoint.")
parser.add_argument("--timeout", type=int, default=45, help="Per-case timeout in seconds.")
args = parser.parse_args(argv)
cases = load_cases(args.cases, repo_root=args.repo_root)
write_openai_compat_predictions(
cases,
args.output,
model=args.model,
base_url=args.base_url,
timeout=args.timeout,
)
print(f"Wrote OpenAI-compatible predictions: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -14,6 +14,8 @@ import shutil
import time
from typing import Any, Dict, List, Optional
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
try:
@@ -291,7 +293,7 @@ Si l'écran est normal sans action nécessaire, réponds action="nothing".
Réponds UNIQUEMENT le JSON, pas d'explication."""
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
model = get_reasoning_model()
response = requests.post(
f"{ollama_url}/api/generate",
@@ -588,6 +590,16 @@ def _grounding_ui_tars(target_text: str, target_description: str = "", monitor_i
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = "0000/ui-tars-1.5-7b-q8_0:7b"
# Gate santé : ne pas envoyer d'image à un modèle « aveugle » (sans capacité vision).
# Évite le HTTP 500 silencieux qui masquait la panne (incident 2026-06-08, UI-TARS sans mmproj).
from core.detection.model_health import has_vision_capability
if not has_vision_capability(model, ollama_url):
logger.warning(
"[Grounding/UI-TARS] modèle '%s' sans capacité 'vision' — skip propre vers niveau 3",
model,
)
return None
logger.info(f"[Grounding/UI-TARS] Envoi à {model}: '{prompt}'")
response = requests.post(

View File

@@ -21,6 +21,8 @@ import re
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
# Import du contexte cognitif (mémoire de travail)
@@ -407,7 +409,7 @@ Règles:
# --- Appel VLM (Ollama) ---
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
model = get_reasoning_model()
print(f"🧠 [ORA/reason_instruction] Appel VLM {model}...")
@@ -1207,7 +1209,7 @@ Règles:
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
model = get_reasoning_model()
resp = requests.post(f"{ollama_url}/api/generate", json={
"model": model,
@@ -1963,7 +1965,7 @@ Règles:
)
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
model = get_reasoning_model()
response = requests.post(
f"{ollama_url}/api/generate",

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

@@ -16,13 +16,13 @@ from typing import Any, Dict, List, Optional
import requests
from core.detection import vlm_config
from .schema import ExtractionField, ExtractionSchema
logger = logging.getLogger(__name__)
# Configuration Ollama (coherente avec le reste du projet)
OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_DEFAULT_MODEL = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
class FieldExtractor:
@@ -38,19 +38,34 @@ class FieldExtractor:
def __init__(
self,
ollama_url: str = OLLAMA_DEFAULT_URL,
ollama_model: str = OLLAMA_DEFAULT_MODEL,
ollama_model: Optional[str] = None,
timeout: int = 60,
):
"""
Args:
ollama_url: URL du serveur Ollama
ollama_model: Modele VLM a utiliser
ollama_model: Modele VLM a utiliser (None = resolution lazy via vlm_config)
timeout: Timeout en secondes pour les appels VLM
"""
self.ollama_url = ollama_url.rstrip("/")
self.ollama_model = ollama_model
self._ollama_model = ollama_model # None → resolu paresseusement
self.timeout = timeout
@property
def ollama_model(self) -> str:
"""Modele VLM, resolu paresseusement via vlm_config si non fourni.
Resolution differee au premier acces (pas a l'import ni a la
construction) : evite tout hardcode gemma4 et tout appel reseau a froid.
"""
if not self._ollama_model:
self._ollama_model = vlm_config.get_vlm_model(self.ollama_url)
return self._ollama_model
@ollama_model.setter
def ollama_model(self, value: Optional[str]) -> None:
self._ollama_model = value
# ------------------------------------------------------------------
# API publique
# ------------------------------------------------------------------

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

@@ -2,7 +2,7 @@
GPU Resource Management Module for RPA Vision V3
This module provides dynamic GPU resource allocation between ML models:
- Ollama VLM (gemma4:e4b par défaut, configurable via RPA_VLM_MODEL) for UI classification
- Ollama VLM (modèle central configurable via RPA_VLM_MODEL) for UI classification
- CLIP (ViT-B-32) for embedding matching
The GPUResourceManager optimizes VRAM usage by:

164
core/gpu/device_policy.py Normal file
View File

@@ -0,0 +1,164 @@
"""Résolution de device paramétrable (auto/cuda/cpu) avec garde-fou VRAM.
Permet de basculer les étages CPU-par-défaut de la cascade vision (OCR docTR,
EasyOCR, YOLO/SoM) vers le GPU local **quand la VRAM est libre**, SANS jamais
hardcoder cuda. La politique anti-concurrence VRAM (tout sur CPU) datait d'une
époque où les VLM tournaient sur la RTX 5070 locale ; ils tournent désormais
sur un DGX distant (tunnel SSH `:11434`), libérant ~9 Go localement.
Logique de garde-fou inspirée de `core/embedding/clip_embedder.py` (lignes
~65-82) : `torch.cuda.is_available()` + `torch.cuda.mem_get_info()`.
Contraintes :
- JAMAIS de hardcode cuda ;
- aucun appel réseau ;
- import-safe : aucun chargement de modèle, aucune allocation GPU à l'import ;
- fallback CPU propre partout (jamais de crash si pas de GPU).
Override global : variable d'environnement `RPA_VISION_DEVICE` ∈ {cpu, cuda, auto}.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import torch
logger = logging.getLogger(__name__)
_GB = 1024 ** 3
# Valeurs reconnues pour l'argument `requested` et l'override env.
_VALID = {"cpu", "cuda", "auto"}
# Garde-fous par défaut (Go).
DEFAULT_MIN_FREE_GB = 2.0 # VRAM libre minimale pour autoriser cuda
DEFAULT_MAX_TOTAL_GB = 6.0 # plafond d'usage VRAM total après bascule
# Au-delà de ce total VRAM, on considère une grosse carte (data-center) ou une
# mémoire UNIFIÉE (DGX GB10 : ~121 Go partagés CPU+GPU). Dans ce cas `used`
# (= total - free) inclut la RAM système → le plafond fixe `max_total_gb` (pensé
# pour la RTX 12 Go dédiés) devient un faux positif qui force CPU à tort. On ne
# l'applique donc QUE sous ce seuil ; au-dessus, seul `free ≥ min_free_gb` décide.
DEFAULT_LARGE_VRAM_GB = 24.0
def _env_override() -> Optional[str]:
"""Lit l'override `RPA_VISION_DEVICE` s'il est présent et valide.
Retourne None si absent ou invalide (on retombe alors sur `requested`).
"""
raw = os.getenv("RPA_VISION_DEVICE", "").strip().lower()
if not raw:
return None
if raw in _VALID:
return raw
logger.warning(
"RPA_VISION_DEVICE='%s' invalide (attendu cpu/cuda/auto) — ignoré",
raw,
)
return None
def _cuda_available() -> bool:
"""`torch.cuda.is_available()` protégé contre toute exception driver."""
try:
return bool(torch.cuda.is_available())
except Exception as e: # pragma: no cover - dépend du driver
logger.debug("torch.cuda.is_available a levé : %s — CPU", e)
return False
def _free_total_gb() -> Optional[tuple[float, float]]:
"""VRAM (libre, totale) en Go via mem_get_info, ou None si indisponible."""
try:
free_bytes, total_bytes = torch.cuda.mem_get_info()
return free_bytes / _GB, total_bytes / _GB
except Exception as e: # pragma: no cover - dépend du driver
logger.debug("torch.cuda.mem_get_info a levé : %s", e)
return None
def resolve_device(
requested: str = "auto",
min_free_gb: float = DEFAULT_MIN_FREE_GB,
max_total_gb: float = DEFAULT_MAX_TOTAL_GB,
) -> str:
"""Résout le device effectif ("cuda" ou "cpu") selon la politique VRAM.
Args:
requested: "cpu", "cuda" ou "auto" (défaut). L'override env
`RPA_VISION_DEVICE` prime sur cet argument s'il est présent/valide.
min_free_gb: VRAM libre minimale (Go) pour autoriser cuda en mode auto.
max_total_gb: plafond d'usage VRAM total (Go). Si basculer cuda ferait
dépasser ce plafond (used = total - free), on reste CPU. Garde-fou
contre la saturation quand d'autres process occupent déjà le GPU.
Returns:
"cuda" ou "cpu". Toujours "cpu" en cas de doute (fallback propre).
Politique :
- "cpu""cpu" ;
- "cuda""cuda" si cuda dispo, sinon "cpu" (fallback loggé) ;
- "auto""cuda" si cuda dispo ET free ≥ min_free_gb ET
used ≤ max_total_gb, sinon "cpu".
"""
effective = _env_override() or (requested or "auto").strip().lower()
if effective not in _VALID:
logger.warning(
"device demandé '%s' invalide (attendu cpu/cuda/auto) — auto",
effective,
)
effective = "auto"
if effective == "cpu":
return "cpu"
if not _cuda_available():
if effective == "cuda":
logger.info("device=cuda demandé mais CUDA indisponible — fallback CPU")
return "cpu"
if effective == "cuda":
# Demande explicite : on respecte sans appliquer le garde-fou VRAM
# (l'appelant assume). CUDA est dispo → cuda.
return "cuda"
# effective == "auto" : garde-fou VRAM.
mem = _free_total_gb()
if mem is None:
logger.info("auto: mem_get_info indisponible — CPU par prudence")
return "cpu"
free_gb, total_gb = mem
used_gb = total_gb - free_gb
if free_gb < min_free_gb:
logger.info(
"auto: VRAM libre %.1f Go < seuil %.1f Go — CPU",
free_gb, min_free_gb,
)
return "cpu"
# Plafond d'usage : seulement sur carte dédiée "petite" (type RTX). Sur grosse
# mémoire / mémoire unifiée (GB10), `used` inclut la RAM système → non pertinent.
if total_gb <= DEFAULT_LARGE_VRAM_GB and used_gb > max_total_gb:
logger.info(
"auto: usage VRAM %.1f Go > plafond %.1f Go (carte %.1f Go) — CPU",
used_gb, max_total_gb, total_gb,
)
return "cpu"
if total_gb > DEFAULT_LARGE_VRAM_GB:
logger.info(
"auto: grosse mémoire/unifiée %.1f Go, libre %.1f Go — CUDA (plafond ignoré)",
total_gb, free_gb,
)
return "cuda"
logger.info(
"auto: VRAM libre %.1f Go (usage %.1f/%.1f Go) — CUDA",
free_gb, used_gb, total_gb,
)
return "cuda"

View File

@@ -2,7 +2,7 @@
GPU Resource Manager - Central orchestrator for GPU resource allocation
Manages dynamic allocation of GPU resources between:
- Ollama VLM (gemma4:e4b par défaut) - ~10 GB VRAM for UI classification
- Ollama VLM (modèle reasoning/VLM central) - ~10 GB VRAM for UI classification
- CLIP (ViT-B-32) - ~500 MB VRAM for embedding matching
Optimizes VRAM usage based on execution mode:
@@ -21,6 +21,8 @@ from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, Iterator, List, Optional
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
@@ -54,7 +56,7 @@ class VRAMInfo:
class GPUResourceConfig:
"""Configuration for GPU resource management."""
ollama_endpoint: str = "http://localhost:11434"
vlm_model: str = "gemma4:e4b"
vlm_model: str = field(default_factory=get_reasoning_model)
clip_model: str = "ViT-B-32"
idle_timeout_seconds: int = 300 # 5 minutes
vram_threshold_for_clip_gpu_mb: int = 1024 # 1 GB

View File

@@ -13,6 +13,8 @@ from typing import List, Optional
import aiohttp
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
@@ -32,7 +34,7 @@ class OllamaManager:
def __init__(
self,
endpoint: str = "http://localhost:11434",
model: str = "gemma4:e4b",
model: Optional[str] = None,
default_keep_alive: str = "5m"
):
"""
@@ -44,7 +46,7 @@ class OllamaManager:
default_keep_alive: Default keep-alive duration
"""
self._endpoint = endpoint.rstrip("/")
self._model = model
self._model = model or get_reasoning_model()
self._default_keep_alive = default_keep_alive
self._session: Optional[aiohttp.ClientSession] = None

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

@@ -25,14 +25,24 @@ _easyocr_reader = None
def easyocr_gpu_enabled(default: bool = False) -> bool:
"""Return whether EasyOCR may allocate GPU memory.
The replay server shares the GPU with Ollama. Defaulting EasyOCR to CPU
keeps VRAM available for the VLM; set RPA_EASYOCR_GPU=1 only for a measured
OCR benchmark or a runtime that has spare VRAM.
Priorité :
1. RPA_EASYOCR_GPU explicite (1/0) → décision forcée, compat héritée.
2. Sinon, délègue à core.gpu.device_policy.resolve_device("auto") :
GPU autorisé uniquement si la VRAM locale est libre (les VLM tournent
désormais sur DGX distant, ~9 Go libres localement). Garde-fou VRAM
intégré ; fallback CPU propre si pas de GPU.
`default` n'est utilisé que si la résolution échoue (sécurité).
"""
raw = os.getenv("RPA_EASYOCR_GPU", "")
if not raw:
if raw:
return raw.strip().lower() in {"1", "true", "yes", "on"}
try:
from core.gpu.device_policy import resolve_device
return resolve_device("auto") == "cuda"
except Exception as e: # pragma: no cover - fallback prudent
logger.debug("easyocr_gpu_enabled: resolve_device a échoué (%s)", e)
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _get_reader():
@@ -233,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,
)

View File

@@ -23,6 +23,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from .workflow_ir import WorkflowIR, Step, Action, Variable
from core.detection import vlm_config
logger = logging.getLogger(__name__)
@@ -41,7 +42,10 @@ class IRBuilder:
"""
def __init__(self, gemma4_port: str = ""):
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", "11435")
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", _default_port)
self._gemma4_url = f"http://localhost:{self._gemma4_port}/api/chat"
def build(
@@ -563,7 +567,7 @@ class IRBuilder:
resp = _requests.post(
self._gemma4_url,
json={
"model": "gemma4:e4b",
"model": vlm_config.get_vlm_model(),
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"think": True,

View File

@@ -20,6 +20,8 @@ from dataclasses import dataclass
from pathlib import Path
import json
from core.detection.vlm_config import get_reasoning_model
logger = logging.getLogger(__name__)
# Répertoires par défaut à scanner pour les workflows
@@ -31,10 +33,72 @@ DEFAULT_WORKFLOW_DIRS = [
# Configuration Ollama par défaut
DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434"
DEFAULT_OLLAMA_MODEL = "qwen2.5:7b"
DEFAULT_LLM_TIMEOUT = 10 # secondes
def _default_ollama_model() -> str:
return get_reasoning_model()
DEFAULT_OLLAMA_MODEL = _default_ollama_model()
_WORKFLOW_TEXT_KEYS = {
"action_type",
"description",
"expected_window_title",
"label",
"name",
"required_texts",
"required_window_title",
"tags",
"target_text",
"text",
"title_contains",
"title_pattern",
"type",
"value",
"vlm_description",
"window_title",
}
_WORKFLOW_TEXT_SKIP_KEYS = {
"_prototype_vector",
"bbox",
"bounding_box",
"embedding",
"position",
"position_x",
"position_y",
"vector",
}
_TOKEN_SYNONYMS = {
"blocnotes": ("bloc", "notes"),
"blocnote": ("bloc", "notes"),
"notepad": ("bloc", "notes"),
"sauvegarde": ("enregistrer",),
"sauvegarder": ("enregistrer",),
"sauvegardes": ("enregistrer",),
"save": ("enregistrer",),
"saved": ("enregistrer",),
"saving": ("enregistrer",),
"enregistre": ("enregistrer",),
"enregistres": ("enregistrer",),
"enregistrez": ("enregistrer",),
}
_IMPORTANT_ACTION_TOKENS = {
"annuler",
"dialogue",
"ecraser",
"enregistrer",
"fichier",
"ouvrir",
"popup",
"remplacer",
}
@dataclass
class WorkflowMatch:
"""Résultat d'un matching de workflow."""
@@ -88,7 +152,7 @@ class SemanticMatcher:
workflows_dir: Union[str, List[str], None] = None,
use_embeddings: bool = True,
use_llm: bool = True,
llm_model: str = DEFAULT_OLLAMA_MODEL,
llm_model: Optional[str] = None,
llm_endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
llm_timeout: int = DEFAULT_LLM_TIMEOUT,
auto_reload_interval: int = 60,
@@ -101,7 +165,7 @@ class SemanticMatcher:
Peut être un str (un seul répertoire) ou une liste.
use_embeddings: Utiliser les embeddings pour le matching (compatibilité)
use_llm: Activer le matching sémantique via Ollama LLM
llm_model: Modèle Ollama à utiliser (défaut: qwen2.5:7b)
llm_model: Modèle Ollama à utiliser (défaut: modèle reasoning central)
llm_endpoint: Endpoint Ollama (défaut: http://localhost:11434)
llm_timeout: Timeout pour les appels LLM en secondes
auto_reload_interval: Intervalle en secondes pour vérifier les nouveaux workflows (0 = désactivé)
@@ -121,7 +185,7 @@ class SemanticMatcher:
self.use_embeddings = use_embeddings
self.use_llm = use_llm
self.llm_model = llm_model
self.llm_model = llm_model or _default_ollama_model()
self.llm_endpoint = llm_endpoint
self.llm_timeout = llm_timeout
@@ -181,7 +245,11 @@ class SemanticMatcher:
Nombre de workflows chargés
"""
count = 0
for workflow_path in workflows_dir.glob("*.json"):
workflow_paths = sorted(
workflows_dir.rglob("*.json"),
key=lambda p: (len(p.relative_to(workflows_dir).parts), str(p)),
)
for workflow_path in workflow_paths:
try:
with open(workflow_path, 'r', encoding='utf-8') as f:
data = json.load(f)
@@ -298,8 +366,46 @@ class SemanticMatcher:
action_type = action.get("type", "")
keywords.add(action_type)
# Workflows appris: les signaux utiles vivent souvent dans les nodes,
# conditions et templates, pas seulement dans les tags/edges.
for value in self._iter_workflow_text_values(workflow_data):
keywords.update(self._tokenize(value))
return list(keywords)
def _iter_workflow_text_values(
self,
value: Any,
parent_key: str = "",
) -> List[str]:
"""Extraire les textes courts utiles au matching depuis un workflow.
On évite les champs volumineux ou numériques (embeddings, bbox), mais on
garde les titres de fenêtres, labels, valeurs et descriptions d'actions.
"""
texts: List[str] = []
if isinstance(value, dict):
for key, child in value.items():
key_lower = str(key).lower()
if key_lower in _WORKFLOW_TEXT_SKIP_KEYS:
continue
if key_lower in _WORKFLOW_TEXT_KEYS and isinstance(child, str):
texts.append(child)
elif isinstance(child, (dict, list)):
texts.extend(self._iter_workflow_text_values(child, key_lower))
return texts
if isinstance(value, list):
for item in value:
if isinstance(item, str):
if parent_key in _WORKFLOW_TEXT_KEYS:
texts.append(item)
elif isinstance(item, (dict, list)):
texts.extend(self._iter_workflow_text_values(item, parent_key))
return texts
def _tokenize(self, text: str) -> List[str]:
"""Tokeniser un texte en mots-clés."""
# Normaliser
@@ -319,7 +425,17 @@ class SemanticMatcher:
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been'
}
return [w for w in words if len(w) > 2 and w not in stop_words]
tokens: List[str] = []
for word in words:
if len(word) <= 2 or word in stop_words:
continue
replacement = _TOKEN_SYNONYMS.get(word)
if replacement:
tokens.extend(replacement)
else:
tokens.append(word)
return tokens
# =========================================================================
# Matching LLM (Ollama)
@@ -654,6 +770,11 @@ Réponds UNIQUEMENT au format JSON, sans texte avant ni après:
if intersection:
reasons.append(f"keywords:{','.join(intersection)}")
important = intersection & _IMPORTANT_ACTION_TOKENS
if important:
score += 0.2
reasons.append(f"action_tokens:{','.join(sorted(important))}")
# 4. Matching de la description
if metadata.description:
desc_tokens = set(self._tokenize(metadata.description))

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 skipifsilent; \
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
@@ -243,9 +243,14 @@ begin
// Essaye d'utiliser le GUID genere par Windows (via PowerShell)
Guid := '';
if CreateGUIDString(Guid) then
Result := LowerCase(StringChange(StringChange(StringChange(Guid, '{', ''), '}', ''), '-', ''))
begin
StringChange(Guid, '{', '');
StringChange(Guid, '}', '');
StringChange(Guid, '-', '');
Result := LowerCase(Guid);
end
else
Result := IntToStr(GetTickCount);
Result := GetDateTimeString('yyyymmddhhnnss', #0, #0);
// Ajoute un hash du hostname pour stabilite
Hostname := GetComputerNameString();
@@ -263,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
// --------------------------------------------------------------------
@@ -297,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;
@@ -404,8 +479,8 @@ begin
// Derive ServerHost depuis ServerUrl : https://host/api/v1 -> host
ServerHost := ServerUrl;
ServerHost := StringChange(ServerHost, 'https://', '');
ServerHost := StringChange(ServerHost, 'http://', '');
StringChange(ServerHost, 'https://', '');
StringChange(ServerHost, 'http://', '');
SlashPos := Pos('/', ServerHost);
if SlashPos > 0 then
ServerHost := Copy(ServerHost, 1, SlashPos - 1);
@@ -504,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)
// --------------------------------------------------------------------
@@ -511,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,112 @@
# Architecture IA & GPU/VRAM — état au 2026-06-05
> Rapport consolidé (2 agents d'analyse + vérifications runtime directes). But : vue
> unique « quel composant IA, quel modèle/lib, à quoi il sert, GPU ou CPU, quelle VRAM »,
> et signaler les anomalies vitesse/précision à trancher.
> Source de vérité : code réel + runtime (`nvidia-smi`, `ss`, `ollama ps`), pas suppositions.
## 0. Fait majeur — Ollama tourne sur le DGX, pas en local
`127.0.0.1:11434` est un **tunnel SSH** (vérifié `ss -tlnp`) :
```
ssh -N -T -L 127.0.0.1:11434:127.0.0.1:11434 aivanov@192.168.1.45 (pid 1883636)
```
**Tous les VLM/LLM (grounding, reasoning, t2a) s'exécutent sur le DGX `192.168.1.45`.**
Le RTX 5070 local ne porte plus que CLIP + contextes torch.
**État VRAM RTX 5070 (instant t) :** `2534 / 12227 MiB utilisés → 9231 MiB libres`.
## 1. Tableau maître des composants IA
| Composant / Rôle | Modèle / lib | Device actuel | VRAM | Où | Statut |
|---|---|---|---|---|---|
| VLM grounding bbox (clic) | `qwen2.5vl:7b-rpa` (Ollama) | **DGX** | ~5.5 Go (DGX) | `vlm_config.get_bbox_grounding_model`, `resolve_engine` | actif |
| VLM reasoning V4 / ORA | `qwen2.5vl:7b-rpa` | DGX | partagé | `vlm_config.get_reasoning_model` | actif |
| VLM généraliste (default) | `qwen2.5vl:7b-rpa` (fallbacks `qwen3-vl:8b`, `ui-tars`) | DGX | ~5.5 Go | `vlm_config.get_vlm_model` | actif |
| VLM critic | `qwen2.5vl:7b-rpa` (hardcodé) | DGX | partagé | `stream_processor._CRITIC_MODEL` | actif |
| VLM recording VWB | `gemma4:e4b` (env) | DGX | — | `catalog_routes_v2_vlm.py` | actif (recording) ⚠ default ≠ runtime |
| t2a_decision | `qwen2.5:7b` (texte) | DGX | — | `core/llm/t2a_decision.py` | actif |
| OCR cascade | **docTR** `ocr_predictor` | **CPU** | 0 | `resolve_engine._resolve_by_ocr_text` | actif |
| OCR extraction/validation | **EasyOCR** fr+en (`gpu=False` défaut, flag `easyocr_gpu_enabled`) + tesseract | **CPU** | 0 | `core/llm/ocr_extractor.py`, `resolve_engine:2480` | actif |
| Détection UI icônes (SoM) | **YOLOv8** (OmniParser weights, ultralytics) | **CPU** (`get_shared_engine` défaut cpu ; engine supporte cuda) | 0 | `core/detection/som_engine.py`, `resolve_engine._resolve_by_yolo` | actif |
| Embeddings / vérif état | **CLIP open_clip ViT-B-32** | **GPU local (auto-cuda si VRAM libre)** | ~1 Go | `core/embedding/clip_embedder.py` | actif |
| Index similarité | **FAISS** | **CPU** | 0 | `core/embedding/faiss_manager.py` | actif |
| Template matching | `cv2.matchTemplate` | CPU | 0 | `resolve_engine`, `core/grounding/template_matcher.py` | actif |
| pHash | `imagehash.phash` | CPU | 0 | `core/analytics/screen_change_detector.py` | actif |
| UI-DETR-1 (overlays numérotés) | `rfdetr RFDETRMedium` (model.pth 535 Mo) | **CUDA si dispo** | ~12 Go | `visual_workflow_builder/.../ui_detection_service.py` | actif **recording VWB only** |
| OmniParser / Florence2 | YOLOv8 + Florence2 | GPU (lazy) | ~2 Go si chargé | `resolve_engine.py:419 _get_omniparser`, `core/detection/omniparser_adapter.py` | **WIRED** dans la cascade serveur (lazy-load) ; désactivé **uniquement au recording VWB** par choix (UI-DETR-1) |
| UI-TARS (grounder GUI) | `ui-tars-1.5-7b` (Ollama) | DGX | — | `core/execution/input_handler.py:390/568 _grounding_ui_tars`, appelé par `observe_reason_act` | **WIRED** — Niveau 2 de la cascade grounding (~3s) |
| InfiGUI | infigui_server | — | — | `core/grounding/` | statut à confirmer (audit P1.g-hygiene) |
| `qwen3.5:9b` (grounding JSON) | profil `get_grounding_profile` | DGX | — | `vlm_config.get_grounding_profile` | **absent DGX** → retombe sur qwen2.5vl ; chemin peu/pas exercé |
| ONNX | — | — | — | — | **inexistant** (mentionné CLAUDE.md mais pas dans le code) |
## 2. Cascade de résolution UI (ordre réel implémenté)
`OCR docTR (CPU)``template cv2 (CPU)``YOLO/SoM (CPU)``VLM (DGX)`,
+ vérification de sortie **CLIP** (sim ≥ 0.75, GPU local) + EasyOCR title-check (CPU).
**Tout est CPU sauf le VLM final (DGX) et CLIP (GPU local).** Conforme au contrat « 100% vision ».
Premier essai vLLM `:8100` pour le VLM, **actuellement down** → fallback Ollama DGX.
## 3. ⚠ Anomalies à trancher (vitesse / précision / qualité)
### 3.1 CPU alors que 9 Go de VRAM libres en local — sous-optimal
La politique « OCR/YOLO sur CPU » était justifiée **quand Ollama tournait en local** (éviter
de concurrencer la VRAM des VLM 7B sur 12 Go). **Depuis le passage Ollama → DGX, la RTX a
9 Go libres** : faire tourner OCR (docTR/EasyOCR) et YOLO/SoM en CPU est désormais un frein
à la vitesse, sans raison VRAM. Les leviers existent déjà : flag `easyocr_gpu_enabled`,
paramètre `device` de `SomEngine`/`get_shared_engine`, docTR `.cuda()`. → **Changement de
config, pas réécriture.** CLIP s'auto-adapte déjà (cuda si VRAM libre).
**À noter** : tout devra être réinstallé/validé sur le DGX ensuite — donc faire le travail
GPU proprement (paramétrable par device) plutôt que de hardcoder cuda.
### 3.2 Statut des technos précision/qualité — CORRECTION (2026-06-05, suite QG Qwen)
**Rectification d'une première version erronée.** Une analyse initiale (agent, scope
limité à `agent_v0/server_v1/` imports directs) avait classé OmniParser et UI-TARS comme
« orphelins ». **C'est FAUX** — vérifié dans le code :
- **OmniParser/Florence2 : WIRED.** `resolve_engine.py:419 _get_omniparser()` (lazy-load GPU
singleton) dans la section « YOLO/OmniParser » de la cascade serveur. Le `False` hardcodé
vu par l'agent était dans le **VWB recording** (`ui_detection_service.py`), désactivé là
**par choix** (UI-DETR-1) — pas dans le runtime serveur.
- **UI-TARS : WIRED.** `input_handler.py:390` l'appelle comme « Niveau 2 — UI-TARS grounding
(~3s) » dans `_ground_text()` ; importé aussi par `observe_reason_act`. Niveau actif de la
cascade de grounding V4.
- **InfiGUI** : statut non confirmé → audit P1.g-hygiene.
- **`qwen3.5:9b`** : default du profil grounding JSON, **absent du DGX** → à pull si on veut
ce chemin, sinon nettoyer le code mort (seul vrai « débranché » du lot).
- **ONNX** : référencé CLAUDE.md mais inexistant → corriger la doc.
**Conclusion** : les technos de précision (OmniParser, UI-TARS, Florence2) **ne sont pas
débranchées**. Le seul levier réellement ouvert ici est `qwen3.5:9b` (à pull ou nettoyer).
Tout rebranchage/réévaluation doit s'appuyer sur un **bench précision**, pas par principe.
### 3.3 `vram_orchestrator` semi-inopérant
Conçu pour Ollama-local (il fait `systemctl restart ollama` pour purger la VRAM). Avec
Ollama sur DGX, ce restart local n'a plus d'effet sur la VRAM des VLM → à revoir / clarifier
(utile seulement si plan B retour RTX-local).
## 4. Directive Dom (2026-06-05)
> « Pas normal de tourner sur CPU alors qu'on a du GPU/VRAM suffisant en local sur la RTX
> pour le moment ; tout devra être installé sur le DGX par la suite. Pourquoi ces technos
> (OmniParser/Florence2, UI-TARS/InfiGUI, qwen3.5) ne sont plus branchées ? On cherche
> vitesse, précision, qualité. »
**Pistes d'action** (à cadrer avec Codex/Qwen) :
1. Basculer OCR (docTR/EasyOCR) + YOLO/SoM sur **GPU local** (paramétrable par device, pas
hardcodé), tant qu'Ollama est sur DGX et la RTX libre — gain de vitesse immédiat, zéro
risque VRAM. Prévoir le portage propre sur DGX.
2. Investiguer le statut réel (dette vs choix) de **UI-TARS/InfiGUI** et **OmniParser** :
bench précision avant de rebrancher, ne pas rebrancher aveuglément.
3. Décider de **`qwen3.5:9b`** : pull sur DGX (réactiver profil grounding JSON) ou retirer le
code mort.
4. Corriger CLAUDE.md (ONNX inexistant, préciser docTR/EasyOCR).
## 5. Synthèse (5 lignes)
1. Un seul VLM actif (`qwen2.5vl:7b-rpa`) pour grounding+reasoning+généraliste+critic, sur le **DGX** via tunnel SSH.
2. Toute la cascade vision (docTR, EasyOCR, YOLO, cv2, pHash, FAISS) tourne en **CPU local** ; seul **CLIP** utilise le GPU RTX (~1 Go).
3. La RTX a **9 Go libres** → opportunité immédiate de basculer OCR/YOLO sur GPU pour la vitesse.
4. **OmniParser, UI-TARS, Florence2 sont WIRED** dans la cascade serveur/V4 (correction post-QG Qwen) ; **UI-DETR-1** ne sert qu'au recording VWB ; seuls **qwen3.5:9b** (absent DGX) et **ONNX** (inexistant) sont réellement à traiter.
5. **`vram_orchestrator`** est semi-mort depuis le passage Ollama-DGX.

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.

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