57 Commits

Author SHA1 Message Date
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
103 changed files with 13760 additions and 194 deletions

12
.gitignore vendored
View File

@@ -121,12 +121,22 @@ results_vlm_bench.json
# Scripts locaux one-shot d'intervention/bench, non réutilisables tels quels.
tools/bench_qwen35_evidence.py
tools/codex_windows_correction_rapport.py
tools/diagnostic_lea_chat_win11.ps1
tools/poc_lecture_ecran.py
tools/watch_emilie_agent.py
test_sanitizer_live.py
# Verbatims clients (sensibles, à valider avant push)
docs/clients/
.qw-baseline.log
# Coordination ephemeral — inbox messages, active decisions, loop state
docs/coordination/.loop_state/
docs/coordination/.inbox_baseline.txt
docs/coordination/.loop_log.txt
docs/coordination/inbox_qwen/
docs/coordination/inbox_codex/
docs/coordination/inbox_claude/
docs/coordination/active/
# Runtime Python embedded pour l'installateur Inno Setup (local, ~11M, non versionné)
deploy/installer/python-3.12-embed/

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ if platform.system() == "Windows":
except Exception:
pass
AGENT_VERSION = "1.0.1"
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ from fastapi import BackgroundTasks, Depends, FastAPI, File, HTTPException, Requ
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from .pii_sanitizer import sanitize_event, sanitize_log_entries
from .replay_failure_logger import log_replay_failure
from .replay_verifier import ReplayVerifier, VerificationResult
from .replay_learner import ReplayLearner
@@ -422,6 +423,7 @@ from .replay_engine import (
_SERVER_SIDE_ACTION_TYPES,
_handle_extract_text_action,
_handle_extract_table_action,
_handle_extract_dossier_action,
_handle_t2a_decision_action,
_handle_llm_generate_action,
_handle_concat_text_vars_action,
@@ -434,6 +436,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.
@@ -583,6 +588,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:
@@ -1562,6 +1578,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
@@ -1901,6 +1927,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
@@ -1909,21 +1940,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:
@@ -1941,6 +1977,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é)
@@ -2337,9 +2376,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)
@@ -4405,6 +4447,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(
@@ -7200,6 +7260,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.
@@ -7736,6 +7852,81 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request):
return payload_out
# =========================================================================
# DETTE-022 v2 — GET /api/v1/agents/update/check (MAJ silencieuse client Léa)
# Flag OFF par défaut (RPA_AUTO_UPDATE_SERVER_ENABLED). Best-effort, additif :
# expose la DÉCISION d'update (logique PURE dans update_check.py, testée hors
# serveur — DETTE-013). NE FAIT PAS le swap (réservé révision humaine côté
# client + Lea.bat).
# =========================================================================
from .update_check import decide_update as _decide_update # noqa: E402
from .update_policy import ( # noqa: E402
resolve_target_version_from_env as _resolve_target_version_from_env,
)
def _auto_update_server_enabled() -> bool:
"""Flag d'activation serveur — lu à chaque appel (faciliter les tests)."""
return os.environ.get("RPA_AUTO_UPDATE_SERVER_ENABLED", "").lower() in (
"1", "true", "yes", "on",
)
def _latest_agent_version(machine_id: Optional[str] = None) -> str:
"""Version d'agent cible POUR CE POSTE (canary-aware, DETTE-022 v2).
⭐ SÉCURITÉ flotte ⭐ — la version servie est résolue PAR MACHINE via la
politique canary (`update_policy.resolve_target_version_from_env`) : un
poste canary (Émilie `lea-4zbgwxty`) reçoit la nouvelle version en premier ;
tous les autres restent sur le floor stable. Piloté 100 % par env, sans
rebuild :
RPA_AGENT_STABLE_VERSION (défaut 1.0.1) — servi à toute la flotte.
RPA_AGENT_CANARY_VERSION — servi AUX SEULS postes canary.
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
Rétrocompat : si `RPA_AGENT_LATEST_VERSION` (ancienne var globale) est
positionnée, elle prime — évite toute régression d'un déploiement existant.
"""
legacy = os.environ.get("RPA_AGENT_LATEST_VERSION")
if legacy:
return legacy
return _resolve_target_version_from_env(machine_id)
@app.get("/api/v1/agents/update/check")
async def check_agent_update(
current_version: str,
machine_id: Optional[str] = None,
update_type: Optional[str] = None,
):
"""Indiquer au client Léa si une MAJ est disponible (DETTE-022 v2).
Réponse : {update_available, latest_version, update_type, url}.
La version cible est résolue PAR MACHINE (canary) : voir
`_latest_agent_version`. Un poste hors canary ne se voit JAMAIS proposer la
version canary (blast radius borné à la liste canary).
GATED : si RPA_AUTO_UPDATE_SERVER_ENABLED n'est pas positionné → 503
(aucun effet sur le pipeline existant — anti-régression). Auth Bearer
requise (dépendance globale `_verify_token`).
"""
if not _auto_update_server_enabled():
raise HTTPException(
status_code=503,
detail=(
"MAJ auto désactivée (flag RPA_AUTO_UPDATE_SERVER_ENABLED). "
"DETTE-022 : endpoint exposé mais OFF par défaut."
),
)
return _decide_update(
current_version=current_version,
latest_version=_latest_agent_version(machine_id),
update_type=update_type,
machine_id=machine_id,
)
if __name__ == "__main__":
import uvicorn

View File

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

View File

@@ -40,6 +40,8 @@ _ALLOWED_ACTION_TYPES = {
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
"extract_table", # OCR serveur + filtre regex → liste structurée (boucle)
"extract_dossier", # OCR grille structurée → dossier patient persisté (brique 3)
"navigate", # Navigation visuelle → coords login/recherche (brique navigation)
"extract_text_scroll", # Marker côté graphe — expansé en sous-actions par _edge_to_normalized_actions
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
@@ -53,6 +55,8 @@ _ALLOWED_ACTION_TYPES = {
_SERVER_SIDE_ACTION_TYPES = {
"extract_text",
"extract_table",
"extract_dossier",
"navigate",
"t2a_decision",
"llm_generate",
"_concat_text_vars",
@@ -2216,6 +2220,146 @@ def _handle_extract_table_action(
return bool(rows)
def _resolve_screenshot_path(replay_state: Dict[str, Any]) -> Optional[str]:
"""Résout le chemin du dernier screenshot (path disque ou base64 → temp).
Calque la source utilisée par extract_text/extract_table : priorité au
``last_screenshot`` (path ou data-URI base64). Retourne None si absent.
"""
raw_screenshot = replay_state.get("last_screenshot") or ""
if not raw_screenshot:
return None
if raw_screenshot.startswith("data:"):
try:
import base64 as _b64, tempfile
header, b64data = raw_screenshot.split(",", 1)
suffix = ".jpg" if "jpeg" in header else ".png"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.write(_b64.b64decode(b64data))
tmp.close()
return tmp.name
except Exception as e:
logger.warning("extract_dossier: décodage base64 screenshot échoué: %s", e)
return None
if os.path.isfile(raw_screenshot):
return raw_screenshot
return None
def _gate_dossier_quality(
grid: List[List[Dict[str, Any]]],
*,
min_confidence: float,
expected_cols: Optional[int],
) -> str:
"""Gate qualité simple → 'complete' ou 'needs_review'.
'complete' SSI : grille non vide ET confiance médiane ≥ seuil ET (si
expected_cols fourni) au moins une ligne avec ce nombre de colonnes.
Sinon 'needs_review'. Volontairement conservatrice (default-review).
"""
confs = [
cell.get("confidence")
for row in grid for cell in row
if isinstance(cell.get("confidence"), (int, float))
]
if not confs:
return "needs_review"
confs.sort()
median = confs[len(confs) // 2]
if median < min_confidence:
return "needs_review"
if expected_cols is not None:
if not any(len(row) == expected_cols for row in grid):
return "needs_review"
return "complete"
def _handle_extract_dossier_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
session_id: str,
) -> bool:
"""Traite une action extract_dossier côté serveur (brique 3).
Lit le dernier screenshot, extrait une grille structurée via
``extract_grid_from_image``, applique une gate qualité, puis PERSISTE un
« dossier patient extrait » (Job/Table/Field) dans la DB VWB avec preuve
(screenshot_ref + screen_bbox + confidences). Le job_id est stocké dans
``replay_state["variables"][output_var]``.
Paramètres reconnus (action.parameters) :
output_var : nom de variable runtime (default "extracted_dossier")
patient_ref : référence patient EN CLAIR (volontaire) — non tokenisée
region : (x, y, w, h) px pour cropper avant OCR (None = plein)
min_confidence : seuil de confiance médiane pour 'complete' (default 0.6)
expected_cols : nb de colonnes attendu (optionnel) pour la gate
N'ÉCHOUE JAMAIS le replay : toute erreur → log + needs_review.
Retourne True SSI le dossier est persisté avec statut 'complete'.
"""
params = action.get("parameters") or {}
output_var = (params.get("output_var") or params.get("variable_name") or "extracted_dossier").strip()
patient_ref = params.get("patient_ref")
region = params.get("region") or None
try:
min_confidence = float(params.get("min_confidence", 0.6))
except (TypeError, ValueError):
min_confidence = 0.6
expected_cols = params.get("expected_cols")
if isinstance(expected_cols, str):
try:
expected_cols = int(expected_cols)
except ValueError:
expected_cols = None
job_id = ""
status = "needs_review"
try:
path = _resolve_screenshot_path(replay_state)
grid: List[List[Dict[str, Any]]] = []
if path:
from core.llm import extract_grid_from_image
grid = extract_grid_from_image(
path, region=tuple(region) if region else None
)
else:
logger.warning(
"extract_dossier : pas de screenshot pour session %s — needs_review",
session_id,
)
status = _gate_dossier_quality(
grid, min_confidence=min_confidence, expected_cols=expected_cols
)
from . import vwb_db
with vwb_db.vwb_app_context():
job_id = vwb_db.persist_extracted_dossier(
grid,
patient_ref=patient_ref,
source_session_id=session_id,
screenshot_ref=path,
screen_bbox=({"x": region[0], "y": region[1], "width": region[2], "height": region[3]}
if region and len(region) == 4 else None),
status=status,
)
except Exception as e:
# Ne JAMAIS échouer le replay : on log, on marque needs_review.
logger.warning(
"extract_dossier : échec persistance (%s) — needs_review, replay %s",
e, replay_state.get("replay_id", "?"),
)
status = "needs_review"
replay_state.setdefault("variables", {})[output_var] = job_id
logger.info(
"extract_dossier → variable '%s' job=%s statut=%s replay %s",
output_var, job_id or "?", status, replay_state.get("replay_id", "?"),
)
return status == "complete"
def _handle_t2a_decision_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -208,7 +208,11 @@ REQUIRED=(
"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")

View File

@@ -23,7 +23,7 @@
; ============================================================
#define MyAppName "Lea"
#define MyAppVersion "1.0.1"
#define MyAppVersion "1.0.2"
#define MyAppPublisher "AIVANOV"
#define MyAppURL "https://lea.labs.laurinebazin.design"
#define MyAppExeName "Lea.bat"
@@ -182,6 +182,7 @@ var
TokenPage: TInputQueryWizardPage;
MachineIdValue: string;
ConfigFilePath: string;
ExistingMachineId: string;
// --------------------------------------------------------------------
// Helper : ajoute des guillemets autour d'une chaine
@@ -267,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
// --------------------------------------------------------------------
@@ -301,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;
@@ -508,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)
// --------------------------------------------------------------------
@@ -515,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

@@ -154,6 +154,8 @@ REQUIRED_EMBED=(
"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

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

@@ -44,7 +44,7 @@ if ($PthFile) {
# L'embed DOIT contenir toutes les dependances runtime.
# AUCUN pip, AUCUN reseau : si une dependance manque -> echec explicite.
# ---------------------------------------------------------------
$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','PIL','win32api')
$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','httpx','PIL','win32api')
$Missing = @()
foreach ($m in $RequiredModules) {
& $PythonExe -c "import $m" 2>$null
@@ -76,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,6 +5,7 @@ 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

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,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

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

View File

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

View File

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

View File

@@ -1,54 +1,592 @@
#!/bin/bash
# Coordination inbox loop v3 — compare par nom de fichiers
#!/usr/bin/env bash
# Coordination inbox loop v4.
#
# One-shot by default:
# docs/coordination/coordination_loop.sh once
#
# Long-running foreground loop:
# docs/coordination/coordination_loop.sh watch 15
#
# Background loop:
# docs/coordination/coordination_loop.sh start 15
#
# Trigger files:
# docs/coordination/.loop_state/unread_messages.tsv
# docs/coordination/.loop_state/unread_digest.md
# docs/coordination/.loop_state/latest_message.trigger
# docs/coordination/.loop_state/message_events.tsv
COORD_DIR="/home/dom/ai/rpa_vision_v3/docs/coordination"
LOG="/home/dom/ai/rpa_vision_v3/docs/coordination/.loop_log.txt"
TMP="/tmp/coord_loop"
mkdir -p "$TMP"
set -euo pipefail
NEW_FOUND=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_PATH="${SCRIPT_DIR}/$(basename "${BASH_SOURCE[0]}")"
check_inbox() {
local inbox_name="$1"
local baseline_file="$TMP/baseline_${inbox_name}.txt"
local inbox_path="${COORD_DIR}/${inbox_name}"
local current_file="$TMP/current_${inbox_name}.txt"
COORD_DIR="${COORD_DIR:-$SCRIPT_DIR}"
LOG="${COORD_LOOP_LOG:-$COORD_DIR/.loop_log.txt}"
SUMMARY="${COORD_LOOP_BASELINE:-$COORD_DIR/.inbox_baseline.txt}"
STATE_DIR="${COORD_LOOP_STATE_DIR:-$COORD_DIR/.loop_state}"
PID_FILE="${COORD_LOOP_PID_FILE:-$STATE_DIR/coordination_loop.pid}"
OUT_FILE="${COORD_LOOP_OUT:-$STATE_DIR/coordination_loop.out}"
DEFAULT_INTERVAL="${COORD_LOOP_INTERVAL:-15}"
EVENTS_FILE="${COORD_LOOP_EVENTS_FILE:-$STATE_DIR/message_events.tsv}"
PENDING_FILE="${COORD_LOOP_PENDING_FILE:-$STATE_DIR/unread_messages.tsv}"
DIGEST_FILE="${COORD_LOOP_DIGEST_FILE:-$STATE_DIR/unread_digest.md}"
LATEST_TRIGGER="${COORD_LOOP_LATEST_TRIGGER:-$STATE_DIR/latest_message.trigger}"
TRIGGER_DIR="${COORD_LOOP_TRIGGER_DIR:-$STATE_DIR/triggers}"
TRIGGER_CMD="${COORD_LOOP_TRIGGER_CMD:-}"
DESKTOP_NOTIFY="${COORD_LOOP_DESKTOP_NOTIFY:-1}"
SYSTEMD_UNIT_NAME="${COORD_LOOP_SYSTEMD_UNIT:-rpa-coordination-watcher.service}"
ls "$inbox_path" 2>/dev/null | sort > "$current_file"
if [[ -n "${COORD_LOOP_DIRS:-}" ]]; then
# shellcheck disable=SC2206
WATCH_DIRS=($COORD_LOOP_DIRS)
else
WATCH_DIRS=(inbox_qwen inbox_codex inbox_claude active)
fi
if [ ! -f "$baseline_file" ]; then
cp "$current_file" "$baseline_file"
DRY_RUN=0
ARGS=()
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
-h|--help) ARGS+=("help") ;;
*) ARGS+=("$arg") ;;
esac
done
set -- "${ARGS[@]}"
usage() {
cat <<EOF
Usage: $(basename "$0") [command] [interval_seconds] [--dry-run]
Commands:
once Scan once and update the persistent baseline (default).
watch Scan forever in the foreground.
start Start watch mode in the background.
ensure Start if needed, scan once, then show pending messages.
stop Stop the background loop.
status Show background loop status and current counters.
pending Show unread coordination messages detected by the loop.
ack Mark detected coordination messages as read locally.
events Show recent message trigger events.
service-install Install/update and restart the user systemd watcher service.
service-stop Stop and disable the user systemd watcher service.
service-status Show the user systemd watcher service status.
baseline Reset the persistent baseline to the current files.
tail Tail the loop log.
Environment:
COORD_LOOP_DIRS="inbox_qwen inbox_codex inbox_claude active"
COORD_LOOP_INTERVAL=15
COORD_LOOP_TRIGGER_CMD='command to run for each new message'
COORD_LOOP_DESKTOP_NOTIFY=1
EOF
}
ensure_state_dir() {
if [[ "$DRY_RUN" -eq 0 ]]; then
mkdir -p "$STATE_DIR"
fi
}
timestamp_human() {
date '+%Y-%m-%d %H:%M'
}
timestamp_file() {
date '+%Y-%m-%d_%H%M'
}
state_file_for() {
local dir_name="$1"
printf '%s/%s.files' "$STATE_DIR" "$dir_name"
}
current_file_for() {
local dir_name="$1"
printf '%s/%s.current' "$STATE_DIR" "$dir_name"
}
list_files() {
local dir_name="$1"
local dir_path="$COORD_DIR/$dir_name"
if [[ ! -d "$dir_path" ]]; then
return 0
fi
find "$dir_path" -maxdepth 1 -type f ! -name '.*' -printf '%f\n' | LC_ALL=C sort -u
}
summary_epoch() {
if [[ ! -f "$SUMMARY" ]]; then
return 1
fi
local ts
ts="$(awk -F: '$1 == "timestamp" {print $2}' "$SUMMARY" | tail -n 1)"
if [[ -z "$ts" ]]; then
return 1
fi
date -d "${ts/_/ }" '+%s' 2>/dev/null
}
bootstrap_baseline_from_summary() {
local dir_name="$1"
local baseline_file="$2"
local dir_path="$COORD_DIR/$dir_name"
local epoch
epoch="$(summary_epoch)" || return 1
[[ -d "$dir_path" ]] || return 1
find "$dir_path" -maxdepth 1 -type f ! -name '.*' -printf '%T@ %f\n' \
| awk -v cutoff="$epoch" '$1 <= cutoff {sub(/^[^ ]+ /, ""); print}' \
| LC_ALL=C sort -u > "$baseline_file"
}
count_files() {
local dir_name="$1"
list_files "$dir_name" | wc -l | tr -d ' '
}
extract_status() {
local file_path="$1"
grep -m1 -E '(^[[:space:]-]*`?Statut`?[[:space:]]*:|^\*\*Statut[^*]*\*\*[[:space:]]*:)' "$file_path" 2>/dev/null \
| sed 's/[[:space:]]*$//' || true
}
pending_count() {
if [[ -f "$PENDING_FILE" ]]; then
wc -l < "$PENDING_FILE" | tr -d ' '
else
printf '0'
fi
}
write_pending_digest() {
[[ "$DRY_RUN" -eq 1 ]] && return 0
ensure_state_dir
local count
count="$(pending_count)"
{
printf '# Coordination unread digest\n\n'
printf -- '- `Updated`: %s\n' "$(date --iso-8601=seconds)"
printf -- '- `Pending`: %s\n\n' "$count"
if [[ "$count" == "0" || ! -s "$PENDING_FILE" ]]; then
printf 'No pending coordination messages.\n'
return 0
fi
printf '## Pending messages\n\n'
while IFS=$'\t' read -r ts dir_name file_name file_path _rest; do
[[ -z "${file_path:-}" ]] && continue
printf -- '- `%s` `%s` `%s`\n' "$ts" "$dir_name" "$file_name"
printf ' - path: `%s`\n' "$file_path"
if [[ -f "$file_path" ]]; then
local title
local status_line
title="$(sed -n '1p' "$file_path" | sed 's/[[:space:]]*$//')"
status_line="$(extract_status "$file_path")"
[[ -n "$title" ]] && printf ' - title: %s\n' "$title"
[[ -n "$status_line" ]] && printf ' - status: %s\n' "$status_line"
fi
done < "$PENDING_FILE"
printf '\n## Commands\n\n'
printf -- '- Read pending: `docs/coordination/coordination_loop.sh pending`\n'
printf -- '- Ack after processing: `docs/coordination/coordination_loop.sh ack`\n'
} > "$DIGEST_FILE"
}
safe_fragment() {
printf '%s' "$1" | tr -c 'A-Za-z0-9._=-' '_' | cut -c 1-180
}
record_message_event() {
local dir_name="$1"
local dir_path="$2"
local file_name="$3"
local status_line="$4"
[[ "$DRY_RUN" -eq 1 ]] && return 0
mkdir -p "$TRIGGER_DIR"
local ts_iso
local ts_file
local safe_file
local file_path
local trigger_file
local status_clean
ts_iso="$(date --iso-8601=seconds)"
ts_file="$(date '+%Y%m%dT%H%M%S')"
safe_file="$(safe_fragment "$file_name")"
file_path="$dir_path/$file_name"
trigger_file="$TRIGGER_DIR/${ts_file}_${dir_name}_${safe_file}.trigger"
status_clean="${status_line//$'\t'/ }"
status_clean="${status_clean//$'\n'/ }"
{
printf 'timestamp=%s\n' "$ts_iso"
printf 'dir=%s\n' "$dir_name"
printf 'file=%s\n' "$file_name"
printf 'path=%s\n' "$file_path"
printf 'status=%s\n' "$status_clean"
} > "$trigger_file"
cp "$trigger_file" "$LATEST_TRIGGER"
printf '%s\t%s\t%s\t%s\t%s\n' "$ts_iso" "$dir_name" "$file_name" "$file_path" "$status_clean" >> "$EVENTS_FILE"
printf '%s\t%s\t%s\t%s\n' "$ts_iso" "$dir_name" "$file_name" "$file_path" >> "$PENDING_FILE"
write_pending_digest
if [[ "$DESKTOP_NOTIFY" == "1" ]] && command -v notify-send >/dev/null 2>&1; then
notify-send "Coordination: nouveau message" "${dir_name}/${file_name}" >/dev/null 2>&1 || true
fi
if [[ -n "$TRIGGER_CMD" ]]; then
(
export COORD_MESSAGE_TIMESTAMP="$ts_iso"
export COORD_MESSAGE_DIR="$dir_name"
export COORD_MESSAGE_FILE="$file_name"
export COORD_MESSAGE_PATH="$file_path"
export COORD_MESSAGE_STATUS="$status_clean"
export COORD_TRIGGER_FILE="$trigger_file"
bash -lc "$TRIGGER_CMD"
) >> "$OUT_FILE" 2>&1 || true &
fi
}
write_summary() {
local tmp_summary="$STATE_DIR/inbox_baseline.tmp"
if [[ "$DRY_RUN" -eq 1 ]]; then
for dir_name in "${WATCH_DIRS[@]}"; do
printf '%s:%s\n' "$dir_name" "$(count_files "$dir_name")"
done
printf 'timestamp:%s\n' "$(timestamp_file)"
return
fi
local new_files
new_files=$(grep -Fxvf "$baseline_file" "$current_file" 2>/dev/null)
if [ -n "$new_files" ]; then
NEW_FOUND=1
local count
count=$(echo "$new_files" | wc -l)
echo "[$(date '+%Y-%m-%d %H:%M')] 📥 ${inbox_name}: +${count} nouveau(x) message(s)" >> "$LOG"
echo "$new_files" | while read -r f; do
echo "$f" >> "$LOG"
local statut
statut=$(grep -m1 'Statut' "${inbox_path}/${f}" 2>/dev/null || echo "")
if [ -n "$statut" ]; then
echo " ${statut}" >> "$LOG"
fi
done
echo "" >> "$LOG"
fi
cp "$current_file" "$baseline_file"
: > "$tmp_summary"
for dir_name in "${WATCH_DIRS[@]}"; do
printf '%s:%s\n' "$dir_name" "$(count_files "$dir_name")" >> "$tmp_summary"
done
printf 'timestamp:%s\n' "$(timestamp_file)" >> "$tmp_summary"
mv "$tmp_summary" "$SUMMARY"
}
check_inbox "inbox_qwen"
check_inbox "inbox_codex"
check_inbox "inbox_claude"
log_line() {
local line="$1"
if [[ "$DRY_RUN" -eq 1 ]]; then
printf '%s\n' "$line"
else
printf '%s\n' "$line" >> "$LOG"
fi
}
if [ "$NEW_FOUND" -eq 1 ]; then
echo "📥 Nouveau message coordination détecté — voir $LOG"
else
echo "❤️ loop OK $(date '+%H:%M')"
fi
reset_baseline() {
ensure_state_dir
local scan_lock_fd
exec {scan_lock_fd}>"$STATE_DIR/scan.lock"
flock "$scan_lock_fd"
for dir_name in "${WATCH_DIRS[@]}"; do
list_files "$dir_name" > "$(state_file_for "$dir_name")"
done
write_summary
write_pending_digest
log_line "[$(timestamp_human)] coordination loop baseline reset"
flock -u "$scan_lock_fd"
exec {scan_lock_fd}>&-
printf 'Baseline coordination initialisee: %s\n' "$SUMMARY"
}
scan_once() {
ensure_state_dir
local scan_lock_fd
exec {scan_lock_fd}>"$STATE_DIR/scan.lock"
flock "$scan_lock_fd"
local new_found=0
local initialized=0
for dir_name in "${WATCH_DIRS[@]}"; do
local dir_path="$COORD_DIR/$dir_name"
local baseline_file
local current_file
local temp_baseline=0
baseline_file="$(state_file_for "$dir_name")"
current_file="$(current_file_for "$dir_name")"
if [[ ! -d "$dir_path" ]]; then
continue
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
current_file="$(mktemp)"
list_files "$dir_name" > "$current_file"
else
list_files "$dir_name" > "$current_file"
fi
if [[ ! -f "$baseline_file" ]]; then
if [[ "$DRY_RUN" -eq 1 ]]; then
baseline_file="$(mktemp)"
temp_baseline=1
if ! bootstrap_baseline_from_summary "$dir_name" "$baseline_file"; then
initialized=1
cp "$current_file" "$baseline_file"
fi
else
if ! bootstrap_baseline_from_summary "$dir_name" "$baseline_file"; then
initialized=1
cp "$current_file" "$baseline_file"
fi
fi
fi
LC_ALL=C sort -u "$baseline_file" -o "$baseline_file"
local new_files
new_files="$(LC_ALL=C comm -13 "$baseline_file" "$current_file" || true)"
if [[ -n "$new_files" ]]; then
new_found=1
local count
count="$(printf '%s\n' "$new_files" | wc -l | tr -d ' ')"
log_line "[$(timestamp_human)] 📥 ${dir_name}: +${count} nouveau(x) message(s)"
while IFS= read -r file_name; do
[[ -z "$file_name" ]] && continue
log_line "$file_name"
local status_line
status_line="$(extract_status "$dir_path/$file_name")"
if [[ -n "$status_line" ]]; then
log_line " ${status_line}"
fi
record_message_event "$dir_name" "$dir_path" "$file_name" "$status_line"
done <<< "$new_files"
log_line ""
fi
if [[ "$DRY_RUN" -eq 0 ]]; then
cp "$current_file" "$baseline_file"
else
rm -f "$current_file"
fi
[[ "$temp_baseline" -eq 1 ]] && rm -f "$baseline_file"
done
write_summary
local rc=0
if [[ "$new_found" -eq 1 ]]; then
printf 'Nouveau message coordination detecte - voir %s\n' "$LOG"
rc=2
elif [[ "$initialized" -eq 1 ]]; then
printf 'Baseline coordination initialisee - aucun ancien message rejoue\n'
else
printf 'loop OK %s\n' "$(date '+%H:%M')"
fi
flock -u "$scan_lock_fd"
exec {scan_lock_fd}>&-
return "$rc"
}
watch_loop() {
local interval="${1:-$DEFAULT_INTERVAL}"
ensure_state_dir
printf '%s\n' "$$" > "$PID_FILE"
trap 'if [[ -f "'"$PID_FILE"'" ]] && [[ "$(cat "'"$PID_FILE"'")" == "'"$$"'" ]]; then rm -f "'"$PID_FILE"'"; fi' EXIT INT TERM
log_line "=== Coordination loop started $(timestamp_human), interval=${interval}s ==="
while true; do
scan_once || true
sleep "$interval"
done
}
is_running() {
[[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
}
start_loop() {
local interval="${1:-$DEFAULT_INTERVAL}"
ensure_state_dir
if is_running; then
printf 'Coordination loop deja actif: pid=%s\n' "$(cat "$PID_FILE")"
return 0
fi
rm -f "$PID_FILE"
if command -v setsid >/dev/null 2>&1; then
setsid bash -c '
pid_file="$1"
script_path="$2"
interval="$3"
out_file="$4"
printf "%s\n" "$$" > "$pid_file"
exec "$script_path" watch "$interval" >> "$out_file" 2>&1 < /dev/null
' _ "$PID_FILE" "$SCRIPT_PATH" "$interval" "$OUT_FILE" &
else
nohup bash -c '
pid_file="$1"
script_path="$2"
interval="$3"
out_file="$4"
printf "%s\n" "$$" > "$pid_file"
exec "$script_path" watch "$interval" >> "$out_file" 2>&1 < /dev/null
' _ "$PID_FILE" "$SCRIPT_PATH" "$interval" "$OUT_FILE" >/dev/null 2>&1 &
fi
local launcher_pid=$!
local pid=""
for _ in 1 2 3 4 5; do
if [[ -f "$PID_FILE" ]]; then
pid="$(cat "$PID_FILE")"
break
fi
sleep 0.1
done
if [[ -z "$pid" ]]; then
pid="$launcher_pid"
printf '%s\n' "$pid" > "$PID_FILE"
fi
printf 'Coordination loop demarre: pid=%s interval=%ss\n' "$pid" "$interval"
printf 'Log: %s\n' "$LOG"
}
ensure_loop() {
local interval="${1:-$DEFAULT_INTERVAL}"
if ! is_running; then
start_loop "$interval"
fi
scan_once || true
show_status
show_pending
}
stop_loop() {
if command -v systemctl >/dev/null 2>&1 \
&& systemctl --user is-active --quiet "$SYSTEMD_UNIT_NAME" 2>/dev/null; then
systemctl --user stop "$SYSTEMD_UNIT_NAME" || true
rm -f "$PID_FILE"
printf 'Service watcher arrete: %s\n' "$SYSTEMD_UNIT_NAME"
return 0
fi
if ! is_running; then
printf 'Coordination loop inactif\n'
rm -f "$PID_FILE"
return 0
fi
local pid
pid="$(cat "$PID_FILE")"
kill "$pid"
rm -f "$PID_FILE"
printf 'Coordination loop arrete: pid=%s\n' "$pid"
}
show_status() {
if is_running; then
printf 'Coordination loop: actif pid=%s\n' "$(cat "$PID_FILE")"
else
printf 'Coordination loop: inactif\n'
fi
printf 'Dirs: %s\n' "${WATCH_DIRS[*]}"
for dir_name in "${WATCH_DIRS[@]}"; do
printf '%s:%s\n' "$dir_name" "$(count_files "$dir_name")"
done
[[ -f "$SUMMARY" ]] && printf 'Baseline: %s\n' "$SUMMARY"
[[ -f "$LOG" ]] && printf 'Log: %s\n' "$LOG"
printf 'Unread trigger queue: %s (%s pending)\n' "$PENDING_FILE" "$(pending_count)"
printf 'Unread digest: %s\n' "$DIGEST_FILE"
[[ -f "$LATEST_TRIGGER" ]] && printf 'Latest trigger: %s\n' "$LATEST_TRIGGER"
[[ -n "$TRIGGER_CMD" ]] && printf 'Trigger cmd: configured\n'
return 0
}
show_pending() {
if [[ ! -s "$PENDING_FILE" ]]; then
printf 'Aucun message coordination en attente dans %s\n' "$PENDING_FILE"
return 0
fi
cat "$PENDING_FILE"
}
ack_pending() {
ensure_state_dir
local scan_lock_fd
exec {scan_lock_fd}>"$STATE_DIR/scan.lock"
flock "$scan_lock_fd"
: > "$PENDING_FILE"
write_pending_digest
log_line "[$(timestamp_human)] unread coordination trigger queue acked"
flock -u "$scan_lock_fd"
exec {scan_lock_fd}>&-
printf 'Messages coordination marques lus localement: %s\n' "$PENDING_FILE"
}
show_events() {
if [[ ! -s "$EVENTS_FILE" ]]; then
printf 'Aucun evenement coordination dans %s\n' "$EVENTS_FILE"
return 0
fi
tail -n "${1:-40}" "$EVENTS_FILE"
}
install_user_service() {
local user_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
local unit_path="$user_dir/$SYSTEMD_UNIT_NAME"
local template_path="$COORD_DIR/systemd/$SYSTEMD_UNIT_NAME"
if [[ ! -f "$template_path" ]]; then
printf 'Template systemd introuvable: %s\n' "$template_path" >&2
return 1
fi
mkdir -p "$user_dir"
install -m 0644 "$template_path" "$unit_path"
systemctl --user daemon-reload
systemctl --user enable "$SYSTEMD_UNIT_NAME"
systemctl --user restart "$SYSTEMD_UNIT_NAME"
printf 'Service watcher installe/mis a jour et redemarre: %s\n' "$unit_path"
systemctl --user --no-pager --full status "$SYSTEMD_UNIT_NAME" || true
}
stop_user_service() {
systemctl --user disable --now "$SYSTEMD_UNIT_NAME" || true
rm -f "$PID_FILE"
printf 'Service watcher desactive et arrete: %s\n' "$SYSTEMD_UNIT_NAME"
}
show_service_status() {
systemctl --user --no-pager --full status "$SYSTEMD_UNIT_NAME" || true
}
cmd="${1:-once}"
case "$cmd" in
once) scan_once ;;
watch) watch_loop "${2:-$DEFAULT_INTERVAL}" ;;
start) start_loop "${2:-$DEFAULT_INTERVAL}" ;;
ensure) ensure_loop "${2:-$DEFAULT_INTERVAL}" ;;
stop) stop_loop ;;
status) show_status ;;
pending) show_pending ;;
ack) ack_pending ;;
events) show_events "${2:-40}" ;;
service-install) install_user_service ;;
service-stop) stop_user_service ;;
service-status) show_service_status ;;
baseline) reset_baseline ;;
tail) tail -n "${2:-80}" "$LOG" ;;
help) usage ;;
*)
usage >&2
exit 64
;;
esac

View File

@@ -0,0 +1,192 @@
"""Tests d'intégration de l'endpoint POST /api/v1/agents/logs (push-log-DGX).
Le client Léa pousse ses logs (batch JSON) vers le DGX ; le serveur les range
par machine_id (AgentLogsStore) pour consultation au dashboard — diagnostic des
postes sans AnyDesk. Mêmes garde-fous fleet que stream/poll (agent actif).
Branche feat/push-log-dgx — DETTE-020/021.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
_TEST_API_TOKEN = "test_token_logs_endpoint_0123456789abcdef"
@pytest.fixture
def logs_client(monkeypatch, tmp_path):
"""Client FastAPI de test avec registre ET store de logs isolés sur disque."""
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
monkeypatch.setenv("RPA_AGENTS_DB_PATH", str(tmp_path / "test_agents.db"))
from fastapi.testclient import TestClient
from agent_v0.server_v1 import api_stream
from agent_v0.server_v1.agent_registry import AgentRegistry
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db"))
monkeypatch.setattr(api_stream, "agent_registry", test_registry)
test_store = AgentLogsStore(base_dir=tmp_path / "agent_logs")
monkeypatch.setattr(api_stream, "agent_logs_store", test_store, raising=False)
client = TestClient(api_stream.app, raise_server_exceptions=False)
yield client, _TEST_API_TOKEN, test_store
def _auth_headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _enroll(client, token, machine_id):
return client.post(
"/api/v1/agents/enroll",
json={"machine_id": machine_id, "user_name": machine_id},
headers=_auth_headers(token),
)
def test_post_logs_persists_for_active_agent(logs_client):
client, token, store = logs_client
_enroll(client, token, "lea-emilie-001")
payload = {
"machine_id": "lea-emilie-001",
"logs": [
{"ts": "2026-06-26T16:00:00", "level": "WARNING",
"logger": "agent_v1.core.executor", "message": "popup detectee"},
],
}
resp = client.post(
"/api/v1/agents/logs", json=payload, headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
assert resp.json()["received"] == 1
stored = store.read("lea-emilie-001")
assert len(stored) == 1
assert stored[0]["message"] == "popup detectee"
assert stored[0]["level"] == "WARNING"
def test_post_logs_without_token_returns_401(logs_client):
client, _, _ = logs_client
resp = client.post(
"/api/v1/agents/logs", json={"machine_id": "lea-001", "logs": []}
)
assert resp.status_code == 401
def test_post_logs_rejected_for_revoked_agent(logs_client):
"""Un poste révoqué ne peut plus pousser de logs (même garde-fou que stream/poll)."""
client, token, store = logs_client
_enroll(client, token, "lea-revoked")
client.post(
"/api/v1/agents/uninstall",
json={"machine_id": "lea-revoked", "reason": "admin_revoke"},
headers=_auth_headers(token),
)
resp = client.post(
"/api/v1/agents/logs",
json={"machine_id": "lea-revoked", "logs": [{"message": "x"}]},
headers=_auth_headers(token),
)
assert resp.status_code == 403, resp.text
assert resp.json()["detail"]["error"] == "agent_not_active"
assert store.read("lea-revoked") == [] # rien persisté
def test_post_logs_rejects_oversized_batch(logs_client):
"""Anti-flood (G3) : un batch trop volumineux est rejeté (413), rien persisté."""
client, token, store = logs_client
_enroll(client, token, "lea-flood")
big = [{"level": "INFO", "message": f"l{i}"} for i in range(1001)]
resp = client.post(
"/api/v1/agents/logs",
json={"machine_id": "lea-flood", "logs": big},
headers=_auth_headers(token),
)
assert resp.status_code == 413, resp.text
assert store.read("lea-flood") == []
# ---------------------------------------------------------------------------
# Brique 3 — lecture des logs par machine_id (route dashboard, read-only).
# Lecture admin/diagnostic : PAS de garde fleet (on veut justement pouvoir
# consulter un poste révoqué ou en panne) ; seul le Bearer protège.
# ---------------------------------------------------------------------------
def test_get_logs_returns_persisted_for_machine(logs_client):
"""GET /agents/logs/{machine_id} restitue les logs stockés, dans l'ordre."""
client, token, store = logs_client
store.append(
"lea-emilie-001",
[
{"ts": "2026-06-26T16:00:00", "level": "INFO", "message": "demarrage"},
{"ts": "2026-06-26T16:00:01", "level": "WARNING", "message": "popup"},
],
)
resp = client.get(
"/api/v1/agents/logs/lea-emilie-001", headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["machine_id"] == "lea-emilie-001"
assert body["count"] == 2
assert body["total"] == 2
assert body["logs"][0]["message"] == "demarrage"
assert body["logs"][1]["level"] == "WARNING"
def test_get_logs_without_token_returns_401(logs_client):
client, _, _ = logs_client
resp = client.get("/api/v1/agents/logs/lea-emilie-001")
assert resp.status_code == 401
def test_get_logs_empty_for_unknown_machine(logs_client):
"""Un poste sans log remonte une liste vide (200), pas une erreur."""
client, token, _ = logs_client
resp = client.get(
"/api/v1/agents/logs/lea-inconnu", headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["count"] == 0
assert body["total"] == 0
assert body["logs"] == []
def test_get_logs_limit_returns_tail(logs_client):
"""`limit` borne la réponse aux N entrées les plus récentes (tail)."""
client, token, store = logs_client
store.append(
"lea-tail",
[{"level": "INFO", "message": f"m{i}"} for i in range(5)],
)
resp = client.get(
"/api/v1/agents/logs/lea-tail?limit=2", headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total"] == 5
assert body["count"] == 2
assert [e["message"] for e in body["logs"]] == ["m3", "m4"]

View File

@@ -0,0 +1,125 @@
"""Tests intégration HTTP de GET /api/v1/agents/update/check — DETTE-022 v2.
Endpoint GATED (flag RPA_AUTO_UPDATE_SERVER_ENABLED), best-effort :
- flag OFF par défaut → 503 (anti-régression : aucun effet sur le pipeline).
- flag ON → 200 + payload {update_available, latest_version, update_type, url}.
- auth Bearer requise (dépendance globale _verify_token).
La logique PURE est testée sans serveur dans tests/unit/test_update_check_server.py
(DETTE-013). Ici on vérifie le branchement HTTP minimal.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
pytestmark = pytest.mark.integration
_TEST_API_TOKEN = "test_update_check_endpoint_token"
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
from fastapi.testclient import TestClient
from agent_v0.server_v1 import api_stream
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
return TestClient(api_stream.app, raise_server_exceptions=False)
def _auth_headers():
return {"Authorization": f"Bearer {_TEST_API_TOKEN}"}
class TestUpdateCheckEndpointFlag:
def test_disabled_by_default_returns_503(self, client, monkeypatch):
monkeypatch.delenv("RPA_AUTO_UPDATE_SERVER_ENABLED", raising=False)
resp = client.get(
"/api/v1/agents/update/check?current_version=1.0.1",
headers=_auth_headers(),
)
assert resp.status_code == 503
assert "RPA_AUTO_UPDATE_SERVER_ENABLED" in resp.text
class TestUpdateCheckEndpointEnabled:
@pytest.fixture(autouse=True)
def _enable_flag(self, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_SERVER_ENABLED", "true")
# Version cible explicite pour rendre le test déterministe.
monkeypatch.setenv("RPA_AGENT_LATEST_VERSION", "1.0.2")
def test_update_available(self, client):
resp = client.get(
"/api/v1/agents/update/check?current_version=1.0.1&machine_id=pc-1",
headers=_auth_headers(),
)
assert resp.status_code == 200
body = resp.json()
assert body["update_available"] is True
assert body["latest_version"] == "1.0.2"
assert body["update_type"] == "code-only"
assert "1.0.2" in body["url"]
def test_up_to_date(self, client):
resp = client.get(
"/api/v1/agents/update/check?current_version=1.0.2&machine_id=pc-1",
headers=_auth_headers(),
)
assert resp.status_code == 200
body = resp.json()
assert body["update_available"] is False
def test_requires_auth(self, client):
resp = client.get(
"/api/v1/agents/update/check?current_version=1.0.1",
)
assert resp.status_code == 401
class TestUpdateCheckCanary:
"""Canary : seul le poste canary se voit proposer la nouvelle version.
On n'utilise PAS RPA_AGENT_LATEST_VERSION (var legacy globale) : on pilote
la version cible via la politique canary (stable + canary + allow-list).
"""
@pytest.fixture(autouse=True)
def _enable_canary(self, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_SERVER_ENABLED", "true")
# Legacy OFF pour que la politique canary pilote la décision.
monkeypatch.delenv("RPA_AGENT_LATEST_VERSION", raising=False)
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1")
monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2")
monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty")
def test_poste_canary_recoit_la_nouvelle_version(self, client):
resp = client.get(
"/api/v1/agents/update/check"
"?current_version=1.0.1&machine_id=lea-4zbgwxty",
headers=_auth_headers(),
)
assert resp.status_code == 200
body = resp.json()
assert body["update_available"] is True
assert body["latest_version"] == "1.0.2"
def test_poste_hors_canary_reste_a_jour_sur_stable(self, client):
# Poste NON canary, déjà en 1.0.1 = stable → pas de MAJ (blast radius
# borné : la 1.0.2 ne fuite pas hors de la liste canary).
resp = client.get(
"/api/v1/agents/update/check"
"?current_version=1.0.1&machine_id=un-autre-poste",
headers=_auth_headers(),
)
assert resp.status_code == 200
body = resp.json()
assert body["update_available"] is False

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Test RED — Maillon A (R1) : câblage worker → DB VWB rejouable.
Invariant ciblé (le VRAI trou du chantier apprentissage) :
quand le worker `finalize_session` produit un workflow appris, ce workflow
doit devenir **rejouable** en atterrissant dans la DB VWB, **sans geste
manuel** — et un 2e passage de la MÊME trajectoire ne crée PAS de doublon.
État vérifié au moment d'écrire ce test :
- le pont `import_core_workflow_to_db` (services.learned_workflow_bridge) EXISTE
et est vert en isolation (idempotence par signature de trajectoire) ;
- MAIS le worker (`agent_v0/server_v1/stream_processor.py`) ne l'appelle JAMAIS :
`_persist_workflow` écrit le JSON sur disque, puis rien ne l'importe en DB VWB.
→ les deux mondes (JSON appris ↔ DB VWB rejouable) restent disjoints.
Ce test cible le **seam de câblage** manquant côté worker, sans exécuter le
chemin lourd de `finalize_session` (GraphBuilder / CLIP) : il appelle la méthode
de pont attendue `StreamProcessor._import_workflow_to_vwb(workflow, session_id,
machine_id)`. Cette méthode N'EXISTE PAS encore → le test échoue (RED) pour la
bonne raison : le câblage worker→VWB est absent.
Câblage minimal proposé (NON appliqué ici) :
dans `finalize_session`, juste après `_persist_workflow` (≈ ligne 3066), ajouter
self._import_workflow_to_vwb(workflow, session_id, machine_id)
où `_import_workflow_to_vwb` :
1. sérialise `workflow.to_dict()` ;
2. ouvre un app-context VWB (db.session) ;
3. délègue à `import_core_workflow_to_db(core_dict, machine_id=...,
source_session_id=..., db_session=db.session)`.
"""
import sys
from pathlib import Path
import pytest
from flask import Flask
# --- Chemins : racine projet (core.*, agent_v0.*) + backend VWB (db.models, services.*) ---
_ROOT = Path(__file__).resolve().parents[2] # .../rpa_vision_v3
_BACKEND = _ROOT / "visual_workflow_builder" / "backend"
for _p in (str(_ROOT), str(_BACKEND)):
if _p not in sys.path:
sys.path.insert(0, _p)
from db.models import db, Workflow # noqa: E402 (modèles ORM VWB)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def vwb_db_app():
"""App Flask minimale liée à une SQLite VWB en mémoire (schéma créé)."""
app = Flask("test_worker_import_to_vwb")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
class _FakeCoreWorkflow:
"""Stub léger d'un workflow core produit par le worker.
Seul le **contrat** importe ici : le worker détient un objet exposant
`workflow_id` et `to_dict()` (cf. `core.models.workflow_graph.Workflow`,
déjà sérialisé par `_persist_workflow` via `save_to_file`). On reproduit ce
contrat sans dépendre du constructeur dataclass core (constraints/
post_conditions obligatoires) — la cible du test est le câblage, pas la
construction d'objet. Le dict renvoyé est exactement la forme que le pont
`convert_learned_to_vwb_steps` consomme (validé en isolation).
"""
def __init__(self):
self.workflow_id = "wf_sess_bloc_notes_worker"
def to_dict(self):
return {
"workflow_id": self.workflow_id,
# Nom porteur de PII clinique : l'import en DB VWB doit l'assainir
# (logiciel métier réel en préfixe, nom clinique structuré ensuite).
"name": "Gxd5diag - VIOLA (VIOLA) Liliane",
"entry_nodes": ["n1"],
"nodes": [
{"node_id": "n1", "name": "Bureau"},
{"node_id": "n2", "name": "Bloc-notes ouvert"},
],
"edges": [
{
"edge_id": "e1",
"from_node": "n1",
"to_node": "n2",
"action": {
"type": "mouse_click",
"target": {"by_text": "Bloc-notes", "by_role": "ocr"},
"parameters": {"button": "left"},
},
},
],
}
def _build_core_workflow():
"""Workflow core tel que vu par le worker (contrat `workflow_id` + `to_dict`)."""
return _FakeCoreWorkflow()
def _make_processor():
"""Instancie un StreamProcessor sans déclencher l'init lourde (CLIP/FAISS).
On crée l'objet via __new__ : le test n'exerce QUE la méthode de câblage,
pas le pipeline complet.
"""
from agent_v0.server_v1.stream_processor import StreamProcessor
return StreamProcessor.__new__(StreamProcessor)
# ---------------------------------------------------------------------------
# Test RED — le câblage worker→VWB
# ---------------------------------------------------------------------------
def test_finalized_workflow_becomes_replayable_in_vwb_db(vwb_db_app):
"""Un workflow appris par le worker devient rejouable en DB VWB,
et un 2e import de la même trajectoire ne crée pas de doublon (idempotence)."""
processor = _make_processor()
workflow = _build_core_workflow()
# --- Seam de câblage attendu (à implémenter côté worker) ---
# _import_workflow_to_vwb(workflow, session_id, machine_id) doit :
# - sérialiser workflow.to_dict()
# - importer en DB VWB via import_core_workflow_to_db (idempotent)
assert hasattr(processor, "_import_workflow_to_vwb"), (
"Câblage R1 absent : StreamProcessor n'expose pas de pont vers la DB VWB. "
"Le workflow appris reste sur disque (JSON) et n'est jamais rejouable."
)
with vwb_db_app.app_context():
first = processor._import_workflow_to_vwb(
workflow,
session_id="sess_bloc_notes_worker",
machine_id="DESKTOP-TEST_windows",
)
# 1er import → workflow rejouable créé en DB VWB
assert Workflow.query.count() == 1
created = Workflow.query.first()
assert created.source == "learned_import"
assert created.review_status == "pending_review"
assert (first or {}).get("created") is True
# PII : le nom patient ne doit jamais atterrir en clair dans la DB VWB
assert "VIOLA" not in created.name, created.name
# 2e import de la MÊME trajectoire → pas de doublon (idempotence)
second = processor._import_workflow_to_vwb(
workflow,
session_id="sess_bloc_notes_worker_rerun",
machine_id="DESKTOP-TEST_windows",
)
assert Workflow.query.count() == 1, "ré-import du même parcours = pas de doublon"
assert (second or {}).get("created") is False
assert (first or {}).get("workflow_id") == (second or {}).get("workflow_id")
# ---------------------------------------------------------------------------
# Activation prod (couplage worker→DB VWB) : gating par feature-flag
# ---------------------------------------------------------------------------
def test_maybe_import_gated_off_par_defaut(monkeypatch):
"""Sans RPA_R1_AUTO_IMPORT, l'import auto NE doit PAS se déclencher
(R1 reste inactif tant que le sanitizer n'est pas validé / GO Dom)."""
monkeypatch.delenv("RPA_R1_AUTO_IMPORT", raising=False)
processor = _make_processor()
appels = []
monkeypatch.setattr(processor, "_import_workflow_to_vwb",
lambda *a, **k: appels.append(a), raising=False)
processor._maybe_import_to_vwb(_build_core_workflow(), "sess", "machine")
assert appels == [] # gated OFF : aucun import
def test_maybe_import_actif_si_flag(monkeypatch):
"""Avec RPA_R1_AUTO_IMPORT=true, l'import est appelé dans l'app-context VWB."""
import contextlib
monkeypatch.setenv("RPA_R1_AUTO_IMPORT", "true")
processor = _make_processor()
appels = []
monkeypatch.setattr(processor, "_import_workflow_to_vwb",
lambda w, s, m: appels.append((s, m)), raising=False)
# neutralise la création réelle de l'app-context (testée au runtime)
monkeypatch.setattr(processor, "_vwb_app_context",
lambda: contextlib.nullcontext(), raising=False)
processor._maybe_import_to_vwb(_build_core_workflow(), "sess-x", "machine-y")
assert appels == [("sess-x", "machine-y")]
def test_maybe_import_ne_casse_pas_la_finalisation(monkeypatch):
"""Un échec d'import VWB ne doit JAMAIS faire échouer la finalisation worker."""
import contextlib
monkeypatch.setenv("RPA_R1_AUTO_IMPORT", "true")
processor = _make_processor()
monkeypatch.setattr(processor, "_vwb_app_context",
lambda: contextlib.nullcontext(), raising=False)
def _boom(*a, **k):
raise RuntimeError("DB VWB indisponible")
monkeypatch.setattr(processor, "_import_workflow_to_vwb", _boom, raising=False)
# ne doit pas lever
processor._maybe_import_to_vwb(_build_core_workflow(), "sess", "machine")

View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""
Chat interactif en ligne de commande avec gemma4:26b via Ollama.
Usage interactif :
python tests/test_image_chat_cli.py
# puis taper des questions sur l'image fournie
Usage one-shot :
python tests/test_image_chat_cli.py /chemin/vers/image.png "Que vois-tu ?"
Usage avec modèle différent :
python tests/test_image_chat_cli.py --model qwen3-vl:8b image.png
Le script utilise l'API Ollama directement (via la lib `ollama` du projet,
`ollama==0.6.1` dans requirements.txt).
"""
import argparse
import base64
import sys
from pathlib import Path
try:
import ollama
except ImportError:
print("ERREUR : la librairie 'ollama' n'est pas installée.")
print("Installez-la avec : pip install ollama")
sys.exit(1)
DEFAULT_MODEL = "gemma4:26b"
def encode_image(image_path: str) -> str:
"""Encode une image en base64 pour l'API Ollama."""
path = Path(image_path)
if not path.exists():
print(f"ERREUR : le fichier '{image_path}' n'existe pas.")
sys.exit(1)
if not path.is_file():
print(f"ERREUR : '{image_path}' n'est pas un fichier.")
sys.exit(1)
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def get_client(host: str):
"""Renvoie un client Ollama configuré pour l'hôte donné."""
return ollama.Client(host=host)
def check_ollama_running(host: str = "http://localhost:11434") -> bool:
"""Vérifie que le serveur Ollama est accessible."""
try:
client = get_client(host)
client.list()
return True
except Exception as e:
print(f"ERREUR : impossible de joindre Ollama sur {host}")
print(f"Détail : {e}")
print()
print("Assurez-vous qu'Ollama est lancé :")
print(" ollama serve")
return False
def check_model_available(model: str, host: str = "http://localhost:11434") -> bool:
"""Vérifie que le modèle est disponible dans Ollama."""
try:
client = get_client(host)
tags = client.list()
# ollama.list() retourne un ListResponse avec un attribut 'models'
models = getattr(tags, "models", [])
model_names = []
for m in models:
if isinstance(m, dict):
model_names.append(m.get("name", ""))
else:
model_names.append(getattr(m, "name", str(m)))
# Correspondance exacte ou préfixe
matched = [name for name in model_names if model in name]
if matched:
return True
else:
print(f"AVERTISSEMENT : modèle '{model}' non trouvé dans Ollama.")
print(f"Modèles disponibles : {', '.join(model_names) or '(aucun)'}")
print()
print(f"Pour le télécharger :")
print(f" ollama pull {model}")
return False
except Exception as e:
print(f"ERREUR : impossible de lister les modèles : {e}")
return False
def chat_with_image(image_path: str, model: str, host: str = "http://localhost:11434") -> None:
"""Mode interactif : charge l'image une fois, puis pose des questions."""
client = get_client(host)
image_b64 = encode_image(image_path)
print(f"🖼️ Image chargée : {image_path}")
print(f"🤖 Modèle : {model}")
print(f"🔗 Ollama : {host}")
print()
print("Mode interactif — tapez vos questions (ou 'exit'/'quit' pour sortir)")
print("Tapez '/image /chemin/nouvelle.png' pour changer d'image")
print("-" * 60)
# Historique de conversation (sans l'image à chaque fois pour économiser la mémoire)
messages = []
while True:
try:
question = input("\nVous > ").strip()
except (EOFError, KeyboardInterrupt):
print("\n👋 Au revoir !")
break
if not question:
continue
if question.lower() in ("exit", "quit", "q"):
print("👋 Au revoir !")
break
# Changement d'image
if question.startswith("/image "):
new_path = question[len("/image "):].strip()
try:
image_b64 = encode_image(new_path)
image_path = new_path
# Réinitialiser l'historique car image différente
messages = []
print(f"🖼️ Nouvelle image : {new_path}")
except SystemExit:
pass
continue
# Construire le message user avec l'image au premier tour
# Ensuite, l'image n'est ré-envoyée que si l'historique est vide
has_image_in_context = any(
isinstance(m.get("images"), list) and len(m["images"]) > 0
for m in messages
)
user_msg = {"role": "user", "content": question}
if not has_image_in_context:
# Première question ou image changée — inclure l'image
user_msg["images"] = [image_b64]
messages.append(user_msg)
print(f"🤖 Réponse ({model})...", end=" ", flush=True)
try:
response = client.chat(
model=model,
messages=messages,
stream=True,
options={
"temperature": 0.2,
"num_predict": 2048,
},
)
full_response = ""
print() # nouvelle ligne après le "..."
for chunk in response:
content = chunk.get("message", {}).get("content", "")
if content:
print(content, end="", flush=True)
full_response += content
print() # retour à la ligne après la réponse
messages.append({"role": "assistant", "content": full_response})
except Exception as e:
print(f"\n❌ Erreur : {e}")
# Retirer le dernier message user en cas d'erreur
messages.pop()
def one_shot(image_path: str, question: str, model: str, host: str = "http://localhost:11434") -> None:
"""Mode one-shot : une question, une réponse."""
client = get_client(host)
image_b64 = encode_image(image_path)
messages = [
{"role": "user", "content": question, "images": [image_b64]},
]
try:
response = client.chat(
model=model,
messages=messages,
stream=True,
options={
"temperature": 0.2,
"num_predict": 2048,
},
)
print(f"🤖 {model}'{question}'\n")
for chunk in response:
content = chunk.get("message", {}).get("content", "")
if content:
print(content, end="", flush=True)
print()
except Exception as e:
print(f"❌ Erreur : {e}")
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description="Chat interactif avec une image via Ollama (gemma4:26b par défaut)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Exemples :
# Mode interactif avec une image
python tests/test_image_chat_cli.py screenshot.png
# Mode one-shot (question directe)
python tests/test_image_chat_cli.py screenshot.png "Quels boutons vois-tu ?"
# Avec un autre modèle
python tests/test_image_chat_cli.py --model qwen3-vl:8b screenshot.png
# Ollama sur une machine distante
python tests/test_image_chat_cli.py --host http://dgx:11434 screenshot.png
""",
)
parser.add_argument(
"image",
nargs="?",
help="Chemin vers l'image à analyser",
)
parser.add_argument(
"question",
nargs="?",
default=None,
help="Question one-shot (si absent → mode interactif)",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=f"Modèle Ollama à utiliser (défaut: {DEFAULT_MODEL})",
)
parser.add_argument(
"--host",
default="http://localhost:11434",
help="URL du serveur Ollama (défaut: http://localhost:11434)",
)
args = parser.parse_args()
# Vérifications préalables
if not check_ollama_running(args.host):
sys.exit(1)
if not check_model_available(args.model, args.host):
sys.exit(1)
if not args.image:
print("Utilisation interactive — veuillez fournir le chemin d'une image.")
print()
print("Usage :")
print(f" python {sys.argv[0]} /chemin/vers/image.png")
print(f" python {sys.argv[0]} /chemin/vers/image.png \"Votre question\"")
print()
parser.print_help()
sys.exit(1)
if args.question:
# Mode one-shot
one_shot(args.image, args.question, args.model, args.host)
else:
# Mode interactif
chat_with_image(args.image, args.model, args.host)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,205 @@
"""Tests for core/navigation/action_resolver.py — coordinate conversion + OCR adapters."""
import json
import pytest
from core.navigation.action_resolver import (
NavigateCoords,
NavigateResult,
grounded_to_coords,
make_ocr_simple_from_detailed,
navigate_login,
)
from core.navigation.grounding import (
CoordsCache,
GroundedElement,
OcrTokenInfo,
OcrDetailedClient,
)
from core.navigation.visual_verifier import VlmClient
# ── Mock factories ─────────────────────────────────────────────────────
def mock_ocr_detailed_client_factory(tokens: list):
def client(image_path: str) -> list:
return tokens
return client
def mock_vlm_client_factory(response_json: dict):
def client(image_path: str, prompt: str) -> str:
return json.dumps(response_json)
return client
# ── grounded_to_coords tests ───────────────────────────────────────────
class TestGroundedToCoords:
def test_basic_conversion(self):
el = GroundedElement(
role="bouton", text="Connexion",
bbox=(200, 50, 400, 100), center=(300, 75),
confidence=0.9, method="ocr_anchor",
)
coords = grounded_to_coords(el, 1920, 1080)
assert coords.x_pct == pytest.approx(300 / 1920, abs=0.01)
assert coords.y_pct == pytest.approx(75 / 1080, abs=0.01)
assert coords.method == "ocr_anchor"
assert coords.bbox_pct is not None
def test_to_dict(self):
coords = NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor")
d = coords.to_dict()
assert d["x_pct"] == 0.15
assert d["y_pct"] == 0.07
assert d["method"] == "ocr_anchor"
def test_to_dict_with_bbox(self):
coords = NavigateCoords(
x_pct=0.15, y_pct=0.07,
bbox_pct=(0.10, 0.05, 0.20, 0.09),
method="vlm_grounder",
)
d = coords.to_dict()
assert "bbox_pct" in d
assert len(d["bbox_pct"]) == 4
# ── make_ocr_simple_from_detailed tests ────────────────────────────────
class TestMakeOcrSimpleFromDetailed:
def test_conversion(self):
tokens = [
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
OcrTokenInfo(text="Password", bbox=(100, 100, 250, 140)),
]
detailed = mock_ocr_detailed_client_factory(tokens)
simple = make_ocr_simple_from_detailed(detailed)
result = simple("/tmp/test.png")
assert result == ["Login", "Password"]
def test_empty_tokens(self):
detailed = mock_ocr_detailed_client_factory([])
simple = make_ocr_simple_from_detailed(detailed)
result = simple("/tmp/test.png")
assert result == []
# ── navigate_login tests ───────────────────────────────────────────────
class TestNavigateLogin:
def test_full_success(self):
"""All fields grounded → NavigateResult with coords."""
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90), confidence=0.95),
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140), confidence=0.95),
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190), confidence=0.95),
])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
{"index": 2, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
{"index": 3, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
],
"overall_confidence": 0.9,
})
result = navigate_login(
"/tmp/login.png",
ocr_client=ocr, vlm_client=vlm,
skip_pre_verify=True,
)
assert result.all_resolved == True
assert result.login_coords is not None
assert result.password_coords is not None
assert result.submit_coords is not None
assert result.submit_coords.x_pct > 0
assert result.submit_coords.y_pct > 0
def test_no_clients_error(self):
"""Missing OCR/VLM clients → error."""
result = navigate_login("/tmp/login.png", ocr_client=None, vlm_client=None)
assert result.all_resolved == False
assert "required" in result.error
def test_pre_verify_fail(self):
"""Pre-verify fails → early abort."""
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40)),
])
vlm = mock_vlm_client_factory({})
result = navigate_login(
"/tmp/page.png",
ocr_client=ocr, vlm_client=vlm,
skip_pre_verify=False,
)
assert result.all_resolved == False
assert result.pre_verify is not None
assert result.pre_verify.match == False
def test_skip_pre_verify(self):
"""Skip pre-verify → proceed to grounding even if form incomplete."""
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140)),
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190)),
])
vlm = mock_vlm_client_factory({})
result = navigate_login(
"/tmp/login.png",
ocr_client=ocr, vlm_client=vlm,
skip_pre_verify=True,
)
assert result.pre_verify is None # skipped
assert result.all_resolved == True
# ── NavigateResult dataclass tests ─────────────────────────────────────
class TestNavigateResult:
def test_default(self):
result = NavigateResult()
assert result.all_resolved == False
assert result.login_coords is None
assert result.error == ""
def test_with_coords(self):
result = NavigateResult(
login_coords=NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor"),
all_resolved=True,
)
assert result.login_coords.x_pct == 0.15
# ── Import validation ──────────────────────────────────────────────────
class TestImportValidation:
def test_action_resolver_imports(self):
"""Verify action_resolver module imports cleanly."""
from core.navigation.action_resolver import (
NavigateCoords,
NavigateResult,
grounded_to_coords,
make_ocr_detailed_from_grid,
make_ocr_simple_from_detailed,
navigate_login,
)
assert NavigateCoords is not None
assert NavigateResult is not None
def test_navigation_package_handler(self):
"""Verify _handle_navigate_action is importable from package."""
from core.navigation import _handle_navigate_action
assert callable(_handle_navigate_action)
def test_navigation_package_exports(self):
"""Verify package __all__ includes navigate exports."""
import core.navigation as nav
assert "navigate_login" in nav.__all__
assert "NavigateResult" in nav.__all__
assert "_handle_navigate_action" in nav.__all__

View File

@@ -0,0 +1,78 @@
"""Tests unitaires du store de logs poussés par les clients Léa (push-log-DGX).
Le store persiste les logs reçus du client, rangés par `machine_id`, pour
consultation au dashboard (diagnostic des postes sans AnyDesk). Stockage
fichier (JSONL par machine_id), rétention configurable.
Branche : feat/push-log-dgx — DETTE-020/021 (observabilité).
"""
from __future__ import annotations
import sys
from pathlib import Path
# Racine projet pour les imports locaux (meme pattern que tests/integration)
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def test_append_then_read_roundtrip(tmp_path):
"""append() persiste un batch ; read() le restitue dans l'ordre."""
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
store = AgentLogsStore(base_dir=tmp_path / "agent_logs")
entries = [
{"ts": "2026-06-26T16:00:00", "level": "INFO",
"logger": "agent_v1.main", "message": "demarrage"},
{"ts": "2026-06-26T16:00:01", "level": "WARNING",
"logger": "agent_v1.core.executor", "message": "popup detectee"},
]
store.append("lea-emilie-001", entries)
got = store.read("lea-emilie-001")
assert len(got) == 2
assert got[0]["message"] == "demarrage"
assert got[0]["level"] == "INFO"
assert got[1]["level"] == "WARNING"
assert got[1]["logger"] == "agent_v1.core.executor"
def test_machine_id_path_traversal_stays_within_base(tmp_path):
"""Un machine_id malveillant (entrée réseau) ne doit jamais écrire hors du base_dir."""
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
base = (tmp_path / "agent_logs").resolve()
store = AgentLogsStore(base_dir=base)
store.append("../../../evil", [{"message": "pwn"}])
written = list(base.rglob("*.jsonl"))
assert written, "le batch doit être persisté SOUS base (pas d'évasion ni perte)"
for p in written:
assert base in p.resolve().parents, f"{p} échappe à {base}"
# Aucune fuite hors de base
assert not list(tmp_path.glob("evil*"))
def test_purge_old_removes_files_older_than_retention(tmp_path):
"""purge_old() supprime les fichiers-jour antérieurs à la rétention (G4 Qwen)."""
from datetime import datetime, timezone
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
base = tmp_path / "agent_logs"
store = AgentLogsStore(base_dir=base)
mdir = base / "lea-001"
mdir.mkdir(parents=True)
(mdir / "2026-05-01.jsonl").write_text('{"message": "vieux"}\n', encoding="utf-8")
(mdir / "2026-06-26.jsonl").write_text('{"message": "recent"}\n', encoding="utf-8")
now = datetime(2026, 6, 26, tzinfo=timezone.utc)
removed = store.purge_old(retention_days=30, now=now)
remaining = {p.name for p in mdir.glob("*.jsonl")}
assert remaining == {"2026-06-26.jsonl"}
assert removed == 1

View File

@@ -0,0 +1,220 @@
"""TDD — push-log-DGX : log shipper client Léa (remontée auto des logs).
Le serveur expose déjà `POST /api/v1/agents/logs` (body
`{machine_id, logs:[{ts, level, logger, message}]}`, borne
`RPA_AGENT_LOGS_MAX_BATCH`). Côté client, on veut :
- `LogShipperHandler(logging.Handler)` : sur `emit`, formate un LogRecord
au schéma exact `{ts, level, logger, message}`, applique un assainissement
PII au message, et empile dans un buffer.
- `LogShipper` : flush périodique du buffer par BATCH (≤ max_batch) via un
`sender` callable INJECTABLE `(machine_id, logs) -> bool`. Résilience :
si `sender` renvoie False ou lève, les logs RESTENT (rejoués au flush
suivant — ZÉRO perte ; conformité AI Act Art. 12).
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
lourd du package client (cf. DETTE-011/013, comme test_agent_v1_logging.py).
"""
import importlib.util
import logging
from pathlib import Path
import pytest
_MOD_PATH = (
Path(__file__).resolve().parents[2]
/ "agent_v0" / "agent_v1" / "network" / "log_shipper.py"
)
def _load_module():
spec = importlib.util.spec_from_file_location("lea_log_shipper", _MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def mod():
return _load_module()
def _make_record(name="lea.test", level=logging.INFO, msg="hello %s", args=("world",)):
"""Construit un vrai LogRecord (pas un mock) pour tester le formatage."""
return logging.LogRecord(
name=name, level=level, pathname=__file__, lineno=1,
msg=msg, args=args, exc_info=None,
)
# ---------------------------------------------------------------------------
# 1. emit formate un LogRecord au schéma exact {ts, level, logger, message}
# ---------------------------------------------------------------------------
def test_emit_formate_au_schema_exact(mod):
shipper = mod.LogShipper(machine_id="poste-1", sender=lambda m, l: True)
handler = shipper.handler
handler.emit(_make_record(name="lea.captor", level=logging.WARNING,
msg="bonjour %s", args=("monde",)))
buffered = shipper.peek_buffer()
assert len(buffered) == 1
entry = buffered[0]
# Schéma EXACT : pas de clé en plus, pas de clé en moins.
assert set(entry.keys()) == {"ts", "level", "logger", "message"}
assert entry["level"] == "WARNING"
assert entry["logger"] == "lea.captor"
assert entry["message"] == "bonjour monde" # args interpolés
assert isinstance(entry["ts"], (int, float))
# ---------------------------------------------------------------------------
# 2. log_safe / assainissement PII appliqué au message avant envoi
# ---------------------------------------------------------------------------
def test_pii_assaini_avant_envoi(mod):
# Sanitizer injecté déterministe : PII -> token (mime anonymize_text).
def fake_sanitizer(text):
return text.replace("ROSSIGNOL", "[NOM_1]")
shipper = mod.LogShipper(
machine_id="poste-1", sender=lambda m, l: True,
message_sanitizer=fake_sanitizer,
)
shipper.handler.emit(_make_record(msg="clic sur patient ROSSIGNOL", args=None))
entry = shipper.peek_buffer()[0]
assert "ROSSIGNOL" not in entry["message"]
assert "[NOM_1]" in entry["message"]
# ---------------------------------------------------------------------------
# 3. flush envoie un batch <= max et appelle sender(machine_id, logs)
# ---------------------------------------------------------------------------
def test_flush_envoie_batch_borne_et_appelle_sender(mod):
calls = []
def sender(machine_id, logs):
calls.append((machine_id, logs))
return True
shipper = mod.LogShipper(machine_id="poste-42", sender=sender, max_batch=10)
for i in range(5):
shipper.handler.emit(_make_record(msg=f"event {i}", args=None))
sent = shipper.flush()
assert sent == 5
assert len(calls) == 1
machine_id, logs = calls[0]
assert machine_id == "poste-42"
assert len(logs) == 5
assert logs[0]["message"] == "event 0"
# Buffer vidé après succès
assert shipper.peek_buffer() == []
# ---------------------------------------------------------------------------
# 4. sender échoue (False / exception) -> logs CONSERVÉS, rejoués au flush suivant
# ---------------------------------------------------------------------------
def test_sender_echec_false_conserve_les_logs(mod):
state = {"fail": True, "received": None}
def flaky_sender(machine_id, logs):
if state["fail"]:
return False # échec récupérable
state["received"] = list(logs)
return True
shipper = mod.LogShipper(machine_id="p", sender=flaky_sender)
for i in range(3):
shipper.handler.emit(_make_record(msg=f"m{i}", args=None))
sent = shipper.flush() # échec
assert sent == 0
assert len(shipper.peek_buffer()) == 3 # ZÉRO perte
state["fail"] = False
sent = shipper.flush() # rejeu
assert sent == 3
assert [e["message"] for e in state["received"]] == ["m0", "m1", "m2"]
assert shipper.peek_buffer() == []
def test_sender_exception_conserve_les_logs(mod):
def exploding_sender(machine_id, logs):
raise ConnectionError("serveur down")
shipper = mod.LogShipper(machine_id="p", sender=exploding_sender)
shipper.handler.emit(_make_record(msg="important", args=None))
sent = shipper.flush() # ne doit PAS propager
assert sent == 0
assert len(shipper.peek_buffer()) == 1 # log conservé
# ---------------------------------------------------------------------------
# 5. buffer vide -> sender NON appelé
# ---------------------------------------------------------------------------
def test_buffer_vide_sender_non_appele(mod):
calls = []
shipper = mod.LogShipper(
machine_id="p", sender=lambda m, l: calls.append((m, l)) or True
)
sent = shipper.flush()
assert sent == 0
assert calls == []
# ---------------------------------------------------------------------------
# 6. > max_batch entrées -> découpage en plusieurs batches
# ---------------------------------------------------------------------------
def test_decoupage_en_plusieurs_batches(mod):
batches = []
def sender(machine_id, logs):
batches.append(len(logs))
return True
shipper = mod.LogShipper(machine_id="p", sender=sender, max_batch=3)
for i in range(7):
shipper.handler.emit(_make_record(msg=f"x{i}", args=None))
sent = shipper.flush()
assert sent == 7
# 7 entrées, max_batch=3 -> 3 + 3 + 1
assert batches == [3, 3, 1]
# Chaque batch <= max_batch
assert all(n <= 3 for n in batches)
assert shipper.peek_buffer() == []
def test_decoupage_echec_partiel_conserve_le_reste(mod):
"""Si un batch intermédiaire échoue, on arrête et on garde le reste (0 perte)."""
batches = []
def sender(machine_id, logs):
batches.append([e["message"] for e in logs])
# Le 2e batch échoue
return len(batches) != 2
shipper = mod.LogShipper(machine_id="p", sender=sender, max_batch=2)
for i in range(6):
shipper.handler.emit(_make_record(msg=f"x{i}", args=None))
sent = shipper.flush()
# 1er batch (x0,x1) part ; 2e (x2,x3) échoue -> on arrête.
assert sent == 2
assert batches[0] == ["x0", "x1"]
# x2..x5 restent dans le buffer dans l'ordre.
restant = [e["message"] for e in shipper.peek_buffer()]
assert restant == ["x2", "x3", "x4", "x5"]

View File

@@ -0,0 +1,74 @@
"""TDD — DETTE-021 : journalisation client Léa effective (vers fichier).
Aujourd'hui `LOG_FILE` est défini (`agent_v0/agent_v1/config.py`) mais jamais
branché ; `basicConfig` écrit sur stderr — perdu car Léa tourne en `pythonw.exe`
(sans console). On veut une fonction `setup_logging()` qui branche un handler
FICHIER avec rotation quotidienne + rétention (Règlement IA Art. 12, 180 j).
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
lourd du package client (cf. DETTE-011/013).
"""
import importlib.util
import logging
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
_MOD_PATH = Path(__file__).resolve().parents[2] / "agent_v0" / "agent_v1" / "logging_setup.py"
def _load_setup_logging():
spec = importlib.util.spec_from_file_location("lea_logging_setup", _MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.setup_logging
def _cleanup_root():
root = logging.getLogger()
for h in list(root.handlers):
if getattr(h, "_lea_managed", False):
h.close()
root.removeHandler(h)
def test_setup_logging_ecrit_dans_le_fichier(tmp_path):
"""Les logs doivent atterrir dans LOG_FILE (et plus seulement sur stderr)."""
log_file = tmp_path / "agent_v1.log"
setup_logging = _load_setup_logging()
try:
setup_logging(log_file=log_file, level=logging.INFO)
logging.getLogger("lea.test").info("message de diagnostic")
for h in logging.getLogger().handlers:
h.flush()
assert log_file.exists(), "le fichier de log doit être créé"
assert "message de diagnostic" in log_file.read_text(encoding="utf-8")
finally:
_cleanup_root()
def test_setup_logging_rotation_et_retention(tmp_path):
"""Rotation quotidienne + rétention configurable (180 j par défaut — Art. 12)."""
log_file = tmp_path / "agent_v1.log"
setup_logging = _load_setup_logging()
try:
setup_logging(log_file=log_file, retention_days=180)
handlers = [h for h in logging.getLogger().handlers
if isinstance(h, TimedRotatingFileHandler)]
assert handlers, "un TimedRotatingFileHandler doit être branché"
assert handlers[0].backupCount == 180
finally:
_cleanup_root()
def test_setup_logging_idempotent(tmp_path):
"""Appels répétés n'empilent pas les handlers fichier (pas de doublon)."""
log_file = tmp_path / "agent_v1.log"
setup_logging = _load_setup_logging()
try:
setup_logging(log_file=log_file)
setup_logging(log_file=log_file)
file_handlers = [h for h in logging.getLogger().handlers
if isinstance(h, TimedRotatingFileHandler)]
assert len(file_handlers) == 1, "pas de handler fichier en double"
finally:
_cleanup_root()

View File

@@ -0,0 +1,248 @@
"""Tests du watchdog de session interactive (résilience RDP/Citrix).
Vérifie que :
- Le tray est ré-affiché à la reconnexion RDP (run_ui rappelé).
- Un seul tray tourne à la fois (invariant « un seul tray »).
- Les threads de fond de l'agent (heartbeat/replay) ne sont JAMAIS
relancés par le watchdog (il ne relance QUE l'UI).
- Un Quitter explicite arrête le watchdog (pas de résurrection du tray).
- Le détecteur de session Windows tombe en marche (True) hors Windows.
Aucune vraie UI : run_ui et is_available sont des callables mockés.
"""
import sys
import threading
import time
from unittest.mock import MagicMock
# Mocker les libs GUI/Win32 avant tout import du module sous test.
sys.modules.setdefault("pynput", MagicMock())
sys.modules.setdefault("pynput.mouse", MagicMock())
sys.modules.setdefault("pynput.keyboard", MagicMock())
sys.modules.setdefault("pystray", MagicMock())
from agent_v0.agent_v1.ui.session_watchdog import ( # noqa: E402
InteractiveSessionWatchdog,
is_interactive_desktop_available,
)
# ---------------------------------------------------------------------------
# Détection de session
# ---------------------------------------------------------------------------
def test_detection_hors_windows_renvoie_true(monkeypatch):
"""Hors Windows (dev/tests Linux) : bureau toujours 'disponible'."""
monkeypatch.setattr(
"agent_v0.agent_v1.ui.session_watchdog.platform.system",
lambda: "Linux",
)
assert is_interactive_desktop_available() is True
# ---------------------------------------------------------------------------
# Boucle du watchdog
# ---------------------------------------------------------------------------
def test_relance_ui_a_la_reconnexion():
"""Session absente puis présente => le tray est (ré)affiché une fois dispo.
Scénario : la 1re sonde dit 'indisponible' (RDP déconnecté), la 2e dit
'disponible' (reconnexion) => run_ui doit être appelé exactement une fois,
puis le watchdog s'arrête.
"""
availability = iter([False, True])
run_ui_calls = []
def _run_ui():
run_ui_calls.append(time.time())
# L'agent vit jusqu'à ce que le tray ait été affiché une fois.
state = {"alive": True}
def _is_running():
# Après le premier affichage du tray, l'agent s'arrête.
return state["alive"] and len(run_ui_calls) == 0
def _is_available():
return next(availability)
wd = InteractiveSessionWatchdog(
run_ui=_run_ui,
is_running=_is_running,
is_available=_is_available,
poll_interval_s=0.01, # sonde très rapide pour le test
)
wd.run()
# Le tray a été (ré)affiché exactement une fois après la reconnexion.
assert len(run_ui_calls) == 1
def test_reaffichage_apres_chaque_deconnexion():
"""Deux cycles connexion→déconnexion => tray relancé à chaque reconnexion."""
run_ui_calls = []
# is_available toujours True ; le tray 'sort' immédiatement (déconnexion).
def _run_ui():
run_ui_calls.append(1)
def _is_running():
# Vivre pour 2 affichages de tray, puis arrêter.
return len(run_ui_calls) < 2
wd = InteractiveSessionWatchdog(
run_ui=_run_ui,
is_running=_is_running,
is_available=lambda: True,
poll_interval_s=0.01,
)
wd.run()
assert len(run_ui_calls) == 2
def test_un_seul_tray_a_la_fois():
"""L'invariant 'un seul tray' : run_ui n'est jamais réentrant en parallèle."""
concurrent = {"count": 0, "max": 0}
lock = threading.Lock()
def _run_ui():
with lock:
concurrent["count"] += 1
concurrent["max"] = max(concurrent["max"], concurrent["count"])
time.sleep(0.02) # simule un tray qui tourne un peu
with lock:
concurrent["count"] -= 1
calls = {"n": 0}
def _is_running():
calls["n"] += 1
return calls["n"] <= 3 # 3 cycles de tray
wd = InteractiveSessionWatchdog(
run_ui=_run_ui,
is_running=_is_running,
is_available=lambda: True,
poll_interval_s=0.01,
)
wd.run()
# Jamais deux trays simultanés.
assert concurrent["max"] == 1
def test_stop_reveille_le_watchdog_en_attente():
"""stop() sort immédiatement la boucle quand la session est absente."""
run_ui_calls = []
wd = InteractiveSessionWatchdog(
run_ui=lambda: run_ui_calls.append(1),
is_running=lambda: True,
is_available=lambda: False, # jamais de session => reste en attente
poll_interval_s=60, # long : seul stop() peut débloquer
)
t = threading.Thread(target=wd.run)
t.start()
time.sleep(0.05) # laisser entrer dans l'attente
wd.stop()
t.join(timeout=2)
assert not t.is_alive() # le watchdog est bien sorti
assert run_ui_calls == [] # aucun tray (jamais de session dispo)
def test_crash_du_tray_ne_tue_pas_le_watchdog():
"""Une exception dans run_ui est absorbée ; le watchdog reste maître."""
calls = {"n": 0}
def _run_ui():
calls["n"] += 1
raise RuntimeError("tray HS")
def _is_running():
return calls["n"] < 2 # tolérer 2 crashs puis sortir
wd = InteractiveSessionWatchdog(
run_ui=_run_ui,
is_running=_is_running,
is_available=lambda: True,
poll_interval_s=0.01,
)
# Ne doit PAS lever : le crash est loggé, pas propagé.
wd.run()
assert calls["n"] == 2
# ---------------------------------------------------------------------------
# Intégration avec main._agent_should_live (Quitter vs déconnexion)
# ---------------------------------------------------------------------------
def test_tray_run_reentrant_ne_relance_pas_les_threads_de_fond(monkeypatch):
"""SmartTrayV1.run() appelé 2x (reconnexion RDP) : threads de fond 1x seulement.
On vérifie que `_start_background_once` est idempotent : les threads
connexion/cache et l'accueil ne démarrent qu'au premier affichage, mais
une nouvelle icône pystray est recréée à chaque appel (ré-affichage).
"""
import threading as _threading
from agent_v0.agent_v1.ui import smart_tray as smart_tray_mod
tray = smart_tray_mod.SmartTrayV1.__new__(smart_tray_mod.SmartTrayV1)
tray._bg_started = False
tray.machine_id = "poste_test"
tray.server_client = None # pas de threads réseau => simplifie
tray.icon = None
greet_calls = {"n": 0}
hotkey_calls = {"n": 0}
icons_created = {"n": 0}
tray._notifier = MagicMock()
tray._notifier.greet.side_effect = lambda: greet_calls.__setitem__("n", greet_calls["n"] + 1)
monkeypatch.setattr(tray, "_start_hotkey", lambda: hotkey_calls.__setitem__("n", hotkey_calls["n"] + 1))
monkeypatch.setattr(tray, "_current_icon", lambda: object())
monkeypatch.setattr(tray, "_get_menu_items", lambda: [])
# Icône pystray factice : run() ne bloque pas (simule une sortie immédiate).
class _FakeIcon:
def __init__(self, *a, **k):
icons_created["n"] += 1
def run(self):
return None
monkeypatch.setattr(smart_tray_mod.pystray, "Icon", _FakeIcon)
monkeypatch.setattr(smart_tray_mod.pystray, "Menu", lambda *a, **k: None)
# Deux affichages successifs (déconnexion → reconnexion).
tray.run()
tray.run()
# Accueil + hotkey : une seule fois (one-shot).
assert greet_calls["n"] == 1
assert hotkey_calls["n"] == 1
# Mais une nouvelle icône à chaque affichage (le tray revient bien).
assert icons_created["n"] == 2
def test_agent_should_live_distingue_quit_et_deconnexion():
"""Quitter explicite arrête le watchdog ; une déconnexion RDP non."""
from types import SimpleNamespace
from agent_v0.agent_v1.main import _agent_should_live
# Agent actif, tray sans quit demandé => doit vivre (déconnexion RDP OK).
agent = SimpleNamespace(running=True, ui=SimpleNamespace(_quit_requested=False))
assert _agent_should_live(agent) is True
# Quitter explicite => ne doit plus vivre (pas de résurrection).
agent.ui._quit_requested = True
assert _agent_should_live(agent) is False
# agent.running=False => ne vit plus (arrêt global).
agent2 = SimpleNamespace(running=False, ui=SimpleNamespace(_quit_requested=False))
assert _agent_should_live(agent2) is False

View File

@@ -0,0 +1,408 @@
"""TDD — DETTE-022 MAJ silencieuse v2 : NOYAU client de mise à jour Léa.
Périmètre testé (parties PURES / testables, GATED, OFF par défaut) :
- `parse_version` / `is_newer` côté client (R3, self-contained — le bundle
client n'embarque pas server_v1).
- `should_update(local_version, server_response)` : décision « faut-il
updater ? quelle version/type ? » à partir de la réponse serveur.
- `download_update(...)` via un `downloader` callable INJECTABLE : AUCUN
réseau réel en test. Vérifie le SHA256, écrit le ZIP dans le staging,
retourne un plan d'update — SANS toucher aux fichiers vivants.
- Flag `RPA_AUTO_UPDATE_ENABLED` (défaut OFF) : `auto_update_enabled()`.
HORS périmètre (réservé révision humaine — trop risqué pour un agent) :
swap réel des fichiers, édition Lea.bat, redémarrage. Le module expose des
STUBS explicites (`apply_update`, `write_boot_ok_marker`) marqués TODO.
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
lourd du package client (cf. DETTE-013, comme test_agent_v1_log_shipper).
"""
import hashlib
import importlib.util
from pathlib import Path
import pytest
_MOD_PATH = (
Path(__file__).resolve().parents[2]
/ "agent_v0" / "agent_v1" / "network" / "updater.py"
)
def _load_module():
spec = importlib.util.spec_from_file_location("lea_updater", _MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def mod():
return _load_module()
# ---------------------------------------------------------------------------
# R3 — parse_version côté client (self-contained)
# ---------------------------------------------------------------------------
class TestClientParseVersion:
def test_ordre_semver(self, mod):
assert mod.parse_version("1.0.2") < mod.parse_version("1.0.10")
assert mod.is_newer("1.0.10", "1.0.2") is True
assert mod.is_newer("1.0.1", "1.0.1") is False
def test_tolerant_et_fallback(self, mod):
assert mod.parse_version("v1.2.3") == (1, 2, 3)
assert mod.parse_version("garbage") == (0,)
assert mod.parse_version(None) == (0,)
# ---------------------------------------------------------------------------
# Flag RPA_AUTO_UPDATE_ENABLED — OFF par défaut
# ---------------------------------------------------------------------------
class TestFlag:
def test_off_par_defaut(self, mod, monkeypatch):
monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False)
assert mod.auto_update_enabled() is False
def test_on_si_active(self, mod, monkeypatch):
for val in ("true", "1", "yes", "on", "TRUE"):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", val)
assert mod.auto_update_enabled() is True
def test_off_si_valeur_invalide(self, mod, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "false")
assert mod.auto_update_enabled() is False
# ---------------------------------------------------------------------------
# should_update — décision à partir de la réponse serveur
# ---------------------------------------------------------------------------
class TestShouldUpdate:
def test_pas_de_maj_si_response_negative(self, mod):
plan = mod.should_update(
"1.0.1", {"update_available": False, "latest_version": "1.0.1"}
)
assert plan is None
def test_maj_si_serveur_propose_version_plus_recente(self, mod):
plan = mod.should_update(
"1.0.1",
{
"update_available": True,
"latest_version": "1.0.2",
"update_type": "code-only",
"url": "http://srv/api/fleet/download/pc-1?type=code-only&version=1.0.2",
},
)
assert plan is not None
assert plan["target_version"] == "1.0.2"
assert plan["update_type"] == "code-only"
def test_double_garde_pas_de_downgrade(self, mod):
# Même si le serveur dit update_available, le client revérifie semver :
# il ne descend JAMAIS vers une version <= locale (défense en profondeur).
plan = mod.should_update(
"1.0.5",
{"update_available": True, "latest_version": "1.0.2",
"update_type": "code-only", "url": "http://x"},
)
assert plan is None
def test_type_inconnu_normalise_code_only(self, mod):
plan = mod.should_update(
"1.0.1",
{"update_available": True, "latest_version": "1.0.2",
"update_type": "weird", "url": "http://x"},
)
assert plan["update_type"] == "code-only"
def test_response_malformee_pas_de_crash(self, mod):
assert mod.should_update("1.0.1", {}) is None
assert mod.should_update("1.0.1", None) is None
assert mod.should_update("1.0.1", {"update_available": True}) is None
# ---------------------------------------------------------------------------
# download_update — downloader INJECTABLE, SHA256, aucun réseau réel
# ---------------------------------------------------------------------------
class TestDownloadUpdate:
def test_telecharge_et_verifie_sha256_ok(self, mod, tmp_path):
payload = b"PK\x03\x04 fake zip bytes"
sha = hashlib.sha256(payload).hexdigest()
calls = {}
def fake_downloader(url):
calls["url"] = url
return payload
plan = {
"target_version": "1.0.2",
"update_type": "code-only",
"url": "http://srv/dl?version=1.0.2",
"sha256": sha,
}
result = mod.download_update(
plan, staging_dir=tmp_path, downloader=fake_downloader
)
assert result["ok"] is True
assert calls["url"] == "http://srv/dl?version=1.0.2"
# Le ZIP est écrit dans le staging (Lea_next-like), PAS dans les fichiers vivants.
staged = Path(result["staged_zip"])
assert staged.exists()
assert staged.read_bytes() == payload
assert staged.parent == tmp_path
def test_sha256_mismatch_rejette_et_nettoie(self, mod, tmp_path):
payload = b"corrupted"
def fake_downloader(url):
return payload
plan = {
"target_version": "1.0.2",
"update_type": "code-only",
"url": "http://x",
"sha256": "0" * 64, # ne correspond pas
}
result = mod.download_update(
plan, staging_dir=tmp_path, downloader=fake_downloader
)
assert result["ok"] is False
assert "sha256" in result["error"].lower()
# Aucun ZIP corrompu laissé dans le staging.
assert list(tmp_path.glob("*.zip")) == []
def test_sha256_absent_accepte_avec_avertissement(self, mod, tmp_path):
# Pas de sha256 fourni : best-effort, on accepte mais on le signale.
payload = b"PK no-sha"
plan = {
"target_version": "1.0.2",
"update_type": "code-only",
"url": "http://x",
}
result = mod.download_update(
plan, staging_dir=tmp_path, downloader=lambda u: payload
)
assert result["ok"] is True
assert result.get("sha256_verified") is False
def test_downloader_leve_pas_de_crash(self, mod, tmp_path):
def boom(url):
raise RuntimeError("réseau down")
plan = {"target_version": "1.0.2", "update_type": "code-only",
"url": "http://x", "sha256": "x"}
result = mod.download_update(plan, staging_dir=tmp_path, downloader=boom)
assert result["ok"] is False
assert "error" in result
# ---------------------------------------------------------------------------
# apply_update — ARMEMENT du swap (extraction agent_v1_new + marqueur).
# NE swappe PAS et NE touche PAS les fichiers vivants (Lea.bat le fait au boot).
# ---------------------------------------------------------------------------
def _make_zip(path, entries):
"""Fabrique un ZIP {nom: contenu} pour les tests."""
import zipfile
with zipfile.ZipFile(path, "w") as zf:
for name, content in entries.items():
zf.writestr(name, content)
return path
class TestApplyUpdateArm:
def test_arme_extrait_et_pose_marqueur(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2", "sub/x.py": "y"})
res = mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert res["armed"] is True and res["applied"] is False
new_dir = app / "agent_v1_new"
assert (new_dir / "main.py").read_text() == "v2"
assert (new_dir / "sub" / "x.py").read_text() == "y"
import json as _j
data = _j.loads((app / "UPDATE_READY").read_text())
assert data["target_version"] == "1.0.2"
assert data["update_type"] == "code-only"
def test_ne_touche_pas_le_agent_v1_vivant(self, mod, tmp_path):
app = tmp_path / "app"; (app / "agent_v1").mkdir(parents=True)
live = app / "agent_v1" / "sentinelle.txt"
live.write_text("VERSION_VIVANTE")
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"})
mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert live.read_text() == "VERSION_VIVANTE" # swap différé à Lea.bat
def test_zip_introuvable_pas_de_crash_ni_marqueur(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
res = mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only",
"staged_zip": str(tmp_path / "absent.zip")},
app_dir=app,
)
assert res["armed"] is False and "error" in res
assert not (app / "UPDATE_READY").exists()
def test_relance_nettoie_agent_v1_new_precedent(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
stale = app / "agent_v1_new"; stale.mkdir()
(stale / "vieux.txt").write_text("obsolete")
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"})
mod.apply_update(
{"target_version": "1.0.3", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert not (app / "agent_v1_new" / "vieux.txt").exists()
assert (app / "agent_v1_new" / "main.py").read_text() == "v2"
def test_zip_slip_refuse(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
z = _make_zip(tmp_path / "evil.zip", {"../evil.py": "pwn"})
res = mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert res["armed"] is False
assert not (app / "evil.py").exists()
class TestWriteBootOkMarker:
def test_ecrit_boot_ok_et_desarme_pending(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
(app / "PENDING_BOOT_1.0.2").write_text("x")
res = mod.write_boot_ok_marker("1.0.2", app_dir=app)
assert res["written"] is True
assert (app / "boot_ok_1.0.2").exists()
assert not (app / "PENDING_BOOT_1.0.2").exists()
# ---------------------------------------------------------------------------
# run_update_cycle — orchestrateur GATED (check → décide → stage → stub apply)
# AUCUN réseau réel, AUCUN swap réel : checker/downloader INJECTABLES, le swap
# reste un stub no-op (réservé révision humaine).
# ---------------------------------------------------------------------------
class TestRunUpdateCycle:
def _checker(self, response):
"""Fabrique un checker injectable qui renvoie `response`."""
def _c(local_version, machine_id):
return response
return _c
def test_gate_off_ne_fait_rien(self, mod, tmp_path, monkeypatch):
# Flag OFF (défaut) : le cycle ne doit RIEN faire (pas d'appel réseau).
monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False)
called = {"n": 0}
def _checker(local_version, machine_id):
called["n"] += 1
return {"update_available": True, "latest_version": "9.9.9",
"url": "http://x", "sha256": None}
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=_checker,
downloader=lambda u: b"x",
)
assert result["status"] == "disabled"
assert called["n"] == 0 # aucun appel réseau quand OFF
def test_a_jour_ne_stage_rien(self, mod, tmp_path, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=self._checker(
{"update_available": False, "latest_version": "1.0.1"}
),
downloader=lambda u: b"should-not-be-called",
)
assert result["status"] == "up_to_date"
assert list(tmp_path.glob("*.zip")) == []
def test_maj_dispo_arme_le_swap_mais_ne_swappe_pas(
self, mod, tmp_path, monkeypatch
):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
# payload = un VRAI ZIP (le download le stage, apply_update l'extrait)
import io, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("main.py", "code v1.0.2")
payload = buf.getvalue()
sha = hashlib.sha256(payload).hexdigest()
app = tmp_path / "app"; app.mkdir()
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=self._checker({
"update_available": True,
"latest_version": "1.0.2",
"update_type": "code-only",
"url": "http://srv/dl?version=1.0.2",
"sha256": sha,
}),
downloader=lambda u: payload,
app_dir=app,
)
# Téléchargé + vérifié + ARMÉ (agent_v1_new + UPDATE_READY), mais PAS
# swappé : le remplacement atomique est fait par Lea.bat au reboot.
assert result["status"] == "armed"
assert result["target_version"] == "1.0.2"
assert result["sha256_verified"] is True
assert result["applied"] is False
assert (app / "UPDATE_READY").exists()
assert (app / "agent_v1_new" / "main.py").read_text() == "code v1.0.2"
def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=self._checker({
"update_available": True,
"latest_version": "1.0.2",
"update_type": "code-only",
"url": "http://x",
"sha256": "0" * 64,
}),
downloader=lambda u: b"corrupted",
)
assert result["status"] == "download_failed"
assert list(tmp_path.glob("*.zip")) == []
def test_checker_qui_leve_pas_de_crash(self, mod, tmp_path, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
def _boom(local_version, machine_id):
raise RuntimeError("serveur down / 503")
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=_boom,
downloader=lambda u: b"x",
)
# Best-effort : jamais d'exception ne remonte (ne casse pas Léa).
assert result["status"] == "check_failed"

View File

@@ -0,0 +1,155 @@
"""Tests unitaires de la politique de sauvegarde des captures (agent_v1).
Objectif : réduire le poids disque des captures (90 Go / 13 sessions = trop)
sans casser la précision du grounding. La politique distingue le *type* de
shot :
- ``crop`` → PNG lossless (cible de grounding qwen3-vl, précision pixel) ;
- ``full`` / ``window`` / ``context`` → JPEG ``optimize=True`` (vue humaine /
contexte, compression ~5-10x acceptable) ;
- ``heartbeat`` → JPEG **downscalé** (liveness, pas de grounding → on peut
réduire la résolution).
La fonction ``save_capture`` retourne le chemin RÉELLEMENT écrit (extension
ajustée selon le format), pour que l'appelant streame le bon fichier.
Branche feat/push-log-dgx — réduction du poids de capture (unité testée,
non encore câblée dans capturer.py).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PIL import Image
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def _noisy_image(width: int, height: int) -> Image.Image:
"""Image RGB avec du bruit réel.
Un aplat uni se compresse à quasi-zéro en PNG comme en JPEG : la
comparaison de poids serait truquée. On injecte du bruit pour que la
différence PNG/JPEG soit représentative d'un vrai screenshot.
"""
return Image.frombytes("RGB", (width, height), os.urandom(width * height * 3))
def test_crop_reste_png_et_dimensions_identiques(tmp_path):
"""Un crop est sauvé en PNG lossless, dimensions inchangées."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(80, 80)
base = str(tmp_path / "shot_0001_crop")
out_path = save_capture(img, base, kind="crop")
assert out_path.endswith(".png"), f"crop doit rester PNG, obtenu {out_path}"
assert os.path.exists(out_path)
reread = Image.open(out_path)
assert reread.size == (80, 80)
# PNG lossless : les pixels doivent être identiques au bruit d'origine.
assert list(reread.convert("RGB").getdata()) == list(img.getdata())
def test_full_est_jpeg(tmp_path):
"""Un full est sauvé en JPEG (.jpg)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(640, 480)
base = str(tmp_path / "shot_0001_full")
out_path = save_capture(img, base, kind="full")
assert out_path.endswith(".jpg"), f"full doit être JPEG, obtenu {out_path}"
assert os.path.exists(out_path)
def test_full_jpeg_significativement_plus_leger_que_png(tmp_path):
"""Le JPEG full doit peser nettement moins que le PNG équivalent.
On génère une image bruitée plein écran (2560×1600) et on compare le
poids du JPEG produit par la politique au poids d'un PNG lossless du
même contenu. Le gain doit être substantiel (au moins 2x plus léger).
"""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
jpeg_path = save_capture(img, str(tmp_path / "full_jpeg"), kind="full")
png_ref = tmp_path / "full_ref.png"
img.save(png_ref, "PNG")
jpeg_size = os.path.getsize(jpeg_path)
png_size = os.path.getsize(png_ref)
assert jpeg_size < png_size / 2, (
f"JPEG ({jpeg_size}o) doit peser < moitié du PNG ({png_size}o)"
)
def test_context_et_window_sont_jpeg(tmp_path):
"""context et window suivent la même politique JPEG que full."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(320, 240)
for kind in ("context", "window"):
out_path = save_capture(img, str(tmp_path / f"x_{kind}"), kind=kind)
assert out_path.endswith(".jpg"), f"{kind} doit être JPEG, obtenu {out_path}"
assert os.path.exists(out_path)
def test_heartbeat_est_downscale(tmp_path):
"""Un heartbeat est downscalé (largeur réduite) et reste JPEG."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
out_path = save_capture(img, str(tmp_path / "heartbeat_1234"), kind="heartbeat")
assert out_path.endswith(".jpg"), f"heartbeat doit être JPEG, obtenu {out_path}"
reread = Image.open(out_path)
assert reread.width < 2560, "heartbeat doit être downscalé en largeur"
# Ratio préservé (16:10 → la hauteur doit suivre la largeur réduite).
ratio_src = 2560 / 1600
ratio_out = reread.width / reread.height
assert abs(ratio_src - ratio_out) < 0.02, "le ratio doit être préservé"
def test_heartbeat_plus_leger_que_full_jpeg(tmp_path):
"""Le downscale du heartbeat le rend plus léger que le full JPEG plein res."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
hb = save_capture(img, str(tmp_path / "heartbeat_5678"), kind="heartbeat")
full = save_capture(img, str(tmp_path / "shot_9999_full"), kind="full")
assert os.path.getsize(hb) < os.path.getsize(full), (
"le heartbeat downscalé doit peser moins que le full JPEG plein res"
)
def test_kind_inconnu_leve_erreur(tmp_path):
"""Un kind non reconnu doit échouer explicitement (fail-closed)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(40, 40)
try:
save_capture(img, str(tmp_path / "x"), kind="inexistant")
except ValueError:
return
raise AssertionError("un kind inconnu doit lever ValueError")
def test_rgba_converti_pour_jpeg(tmp_path):
"""Une image RGBA doit être convertie avant l'encodage JPEG (pas d'alpha)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = Image.new("RGBA", (64, 64), (10, 20, 30, 128))
out_path = save_capture(img, str(tmp_path / "shot_rgba_full"), kind="full")
assert out_path.endswith(".jpg")
assert os.path.exists(out_path)

View File

@@ -0,0 +1,320 @@
"""Politique de format des captures + robustesse du répertoire shots.
Deux corrections testées ici (agent_v0/agent_v1/vision) :
1. FORMAT (allègement) : `capturer.py` doit déléguer l'écriture à
`capture_io.save_capture`, qui applique la politique :
- crop → PNG lossless (cible de grounding qwen3-vl)
- full/window/context → JPEG q85
- heartbeat → JPEG downscalé (largeur max ~1280)
Aujourd'hui tout était sauvé en `img.save(path, "PNG", quality=...)`
(le `quality` est ignoré par PNG → PNG lossless plein écran, ~90 Go).
2. BUG chemin (poste Émilie) : ``[Errno 2] No such file or directory:
..._background/shots/context...``. Le répertoire `shots/` est créé une
seule fois dans `__init__`, mais l'auto-cleanup (`SessionStorage`,
`shutil.rmtree`) peut supprimer tout le dossier de session `_background`.
Les sauvegardes suivantes doivent recréer le répertoire cible
(`os.makedirs(dir, exist_ok=True)`) avant chaque écriture.
Tests 100% mockés : aucune vraie capture écran (mss est patché).
"""
from __future__ import annotations
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
# ---------------------------------------------------------------------------
# Helpers (repris du style de test_capturer_monitor_guard.py)
# ---------------------------------------------------------------------------
def _make_mock_mss(monitors):
"""Mock `mss.mss()` renvoyant un monitor sain unique (image grise unie)."""
def factory():
instance = MagicMock()
instance.monitors = monitors
grab_result = MagicMock()
m = monitors[1] if len(monitors) > 1 else monitors[0]
w, h = m["width"], m["height"]
grab_result.size = (w, h)
grab_result.bgra = b"\x80\x80\x80\x00" * (w * h)
instance.grab = MagicMock(return_value=grab_result)
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=instance)
cm.__exit__ = MagicMock(return_value=False)
return cm
return factory
_NORMAL_MONITORS = [
{"left": 0, "top": 0, "width": 800, "height": 600}, # composite
{"left": 0, "top": 0, "width": 800, "height": 600}, # primaire sain
]
def _vision_capturer(tmp_path):
from agent_v0.agent_v1.vision.capturer import VisionCapturer
return VisionCapturer(str(tmp_path))
def _patch_mss():
"""Contexte : mss patché + time.sleep no-op + pas de floutage.
Le floutage est désactivé pour isoler la politique d'écriture (le blur
ouvre/modifie l'image mais n'impacte pas le format de sortie ; on le coupe
pour rester déterministe).
"""
return (
patch(
"agent_v0.agent_v1.vision.capturer.mss.mss",
side_effect=_make_mock_mss(_NORMAL_MONITORS),
),
patch("agent_v0.agent_v1.vision.capturer.time.sleep"),
patch("agent_v0.agent_v1.vision.capturer.BLUR_SENSITIVE", False),
)
# ===========================================================================
# PARTIE A — Politique save_capture (unité capture_io)
# ===========================================================================
def test_save_capture_crop_stays_png(tmp_path: Path):
from agent_v0.agent_v1.vision import capture_io
img = Image.new("RGB", (80, 80), (10, 20, 30))
out = capture_io.save_capture(img, str(tmp_path / "shot_crop"), "crop")
assert out.endswith(".png"), f"crop doit rester PNG, got {out!r}"
assert Path(out).exists()
with Image.open(out) as reopened:
assert reopened.format == "PNG"
@pytest.mark.parametrize("kind", ["full", "window", "context"])
def test_save_capture_context_kinds_are_jpeg(tmp_path: Path, kind: str):
from agent_v0.agent_v1.vision import capture_io
img = Image.new("RGB", (640, 480), (120, 130, 140))
out = capture_io.save_capture(img, str(tmp_path / f"shot_{kind}"), kind)
assert out.endswith(".jpg"), f"{kind} doit être JPEG, got {out!r}"
assert Path(out).exists()
with Image.open(out) as reopened:
assert reopened.format == "JPEG"
def test_save_capture_heartbeat_is_downscaled_jpeg(tmp_path: Path):
from agent_v0.agent_v1.vision import capture_io
# Image large (2560) → doit être réduite à HEARTBEAT_MAX_WIDTH.
img = Image.new("RGB", (2560, 1440), (50, 60, 70))
out = capture_io.save_capture(img, str(tmp_path / "hb"), "heartbeat")
assert out.endswith(".jpg")
with Image.open(out) as reopened:
assert reopened.format == "JPEG"
assert reopened.width == capture_io.HEARTBEAT_MAX_WIDTH, (
f"heartbeat doit être downscalé à {capture_io.HEARTBEAT_MAX_WIDTH}, "
f"got {reopened.width}"
)
# ratio préservé (1440 * 1280/2560 = 720)
assert reopened.height == 720
def test_save_capture_heartbeat_smaller_than_max_is_not_upscaled(tmp_path: Path):
from agent_v0.agent_v1.vision import capture_io
img = Image.new("RGB", (640, 360), (1, 2, 3))
out = capture_io.save_capture(img, str(tmp_path / "hb_small"), "heartbeat")
with Image.open(out) as reopened:
assert reopened.width == 640, "no-op si déjà plus petit que le max"
def test_save_capture_heartbeat_downscale_reduces_pixel_count(tmp_path: Path):
"""Preuve de l'allègement heartbeat par la mesure objective du code :
le downscale réduit le nombre de pixels (2560×1440 → 1280×720 = /4 surface).
On mesure la géométrie de sortie (déterministe), pas le poids d'un JPEG
synthétique (qui dépend de libjpeg et n'est pas représentatif d'un vrai
écran)."""
from agent_v0.agent_v1.vision import capture_io
src = Image.new("RGB", (2560, 1440))
out = capture_io.save_capture(src, str(tmp_path / "hb_measure"), "heartbeat")
with Image.open(out) as small:
src_pixels = src.width * src.height
out_pixels = small.width * small.height
assert out_pixels < src_pixels / 3, (
f"Le downscale heartbeat doit diviser la surface par ~4 "
f"({src_pixels}{out_pixels})"
)
def test_save_capture_rejects_unknown_kind(tmp_path: Path):
from agent_v0.agent_v1.vision import capture_io
img = Image.new("RGB", (10, 10))
with pytest.raises(ValueError):
capture_io.save_capture(img, str(tmp_path / "x"), "bogus")
# ===========================================================================
# PARTIE B — Câblage dans capturer.py (format des sorties runtime)
# ===========================================================================
def test_capture_full_context_writes_jpeg(tmp_path: Path):
"""capture_full_context (context / focus_change / result_of_*) → JPEG."""
p1, p2, p3 = _patch_mss()
with p1, p2, p3:
cap = _vision_capturer(tmp_path)
out = cap.capture_full_context("focus_change", force=True)
assert out, "capture attendue"
assert out.endswith(".jpg"), f"context doit être JPEG, got {out!r}"
assert Path(out).exists()
with Image.open(out) as im:
assert im.format == "JPEG"
def test_capture_full_context_heartbeat_is_jpeg(tmp_path: Path):
"""Un suffixe 'heartbeat' doit produire un JPEG (downscalé côté politique)."""
p1, p2, p3 = _patch_mss()
with p1, p2, p3:
cap = _vision_capturer(tmp_path)
out = cap.capture_full_context("heartbeat", force=True)
assert out.endswith(".jpg"), f"heartbeat doit être JPEG, got {out!r}"
with Image.open(out) as im:
assert im.format == "JPEG"
def test_capture_dual_full_is_jpeg_crop_is_png(tmp_path: Path):
"""capture_dual : full/window en JPEG, crop en PNG (contrat serveur)."""
p1, p2, p3 = _patch_mss()
with p1, p2, p3, patch(
# Neutraliser la capture fenêtre (dépend d'API OS) pour isoler full+crop
"agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window",
return_value=None,
):
cap = _vision_capturer(tmp_path)
result = cap.capture_dual(x=100, y=200, screenshot_id="shot42")
assert "full" in result and "crop" in result
assert result["full"].endswith(".jpg"), f"full doit être JPEG, got {result['full']!r}"
assert result["crop"].endswith(".png"), f"crop doit rester PNG, got {result['crop']!r}"
assert Path(result["full"]).exists()
assert Path(result["crop"]).exists()
with Image.open(result["full"]) as im:
assert im.format == "JPEG"
with Image.open(result["crop"]) as im:
assert im.format == "PNG"
def test_capture_active_window_writes_jpeg(tmp_path: Path):
"""La fenêtre active est une vue contextuelle → JPEG."""
p1, p2, p3 = _patch_mss()
fake_rect = {
"rect": [100, 100, 500, 400],
"size": [400, 300],
"title": "Bloc-notes",
"app_name": "notepad.exe",
}
full_img = Image.new("RGB", (800, 600), (90, 90, 90))
with p1, p2, p3, patch(
"agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect",
return_value=fake_rect,
):
cap = _vision_capturer(tmp_path)
result = cap.capture_active_window(
x=200, y=200, screenshot_id="shotW", full_img=full_img
)
assert result is not None
assert result["window_image"].endswith(".jpg"), (
f"window doit être JPEG, got {result['window_image']!r}"
)
with Image.open(result["window_image"]) as im:
assert im.format == "JPEG"
# ===========================================================================
# PARTIE C — BUG chemin : shots/ recréé si supprimé par l'auto-cleanup
# ===========================================================================
def test_capture_full_context_recreates_shots_dir_after_rmtree(tmp_path: Path):
"""Reproduction du bug poste Émilie.
L'auto-cleanup (`SessionStorage.shutil.rmtree`) supprime tout le dossier
de session `_background` (donc `shots/`). Une capture ultérieure ne doit
PAS lever `[Errno 2] No such file or directory` : le répertoire cible
doit être recréé avant l'écriture.
"""
p1, p2, p3 = _patch_mss()
with p1, p2, p3:
cap = _vision_capturer(tmp_path)
# Simule l'auto-cleanup : la session entière est purgée après ACK.
shutil.rmtree(cap.shots_dir)
assert not Path(cap.shots_dir).exists()
out = cap.capture_full_context("context_after_purge", force=True)
assert out, "La capture doit réussir même après purge du dossier shots"
assert Path(out).exists(), "Le fichier doit être physiquement écrit"
assert Path(cap.shots_dir).exists(), "shots/ doit avoir été recréé"
def test_capture_dual_recreates_shots_dir_after_rmtree(tmp_path: Path):
"""capture_dual doit aussi survivre à la purge du dossier shots."""
p1, p2, p3 = _patch_mss()
with p1, p2, p3, patch(
"agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window",
return_value=None,
):
cap = _vision_capturer(tmp_path)
shutil.rmtree(cap.shots_dir)
result = cap.capture_dual(x=50, y=60, screenshot_id="shot_purge")
assert result.get("full") and result.get("crop"), (
"capture_dual doit produire full+crop même après purge"
)
assert Path(result["full"]).exists()
assert Path(result["crop"]).exists()
def test_capture_active_window_recreates_shots_dir_after_rmtree(tmp_path: Path):
"""capture_active_window (crop fenêtre depuis full fourni) survit à la purge."""
p1, p2, p3 = _patch_mss()
fake_rect = {
"rect": [10, 10, 210, 210],
"size": [200, 200],
"title": "W",
"app_name": "w.exe",
}
full_img = Image.new("RGB", (400, 400), (70, 70, 70))
with p1, p2, p3, patch(
"agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect",
return_value=fake_rect,
):
cap = _vision_capturer(tmp_path)
shutil.rmtree(cap.shots_dir)
result = cap.capture_active_window(
x=50, y=50, screenshot_id="shotW_purge", full_img=full_img
)
assert result is not None, "capture fenêtre doit réussir après purge"
assert Path(result["window_image"]).exists()

View File

@@ -0,0 +1,202 @@
"""Tests documenting the coords consumption gap: write-only navigate coords.
Test 1 (POSITIVE): _resolve_runtime_vars mechanism works — template strings
like {{navigate_login_coords.x_pct}} resolve correctly when variables dict
contains the stored coords.
Test 2 (NEGATIVE): _edge_to_normalized_actions bakes coords as literal floats,
never producing template strings — so runtime variable resolution is never
triggered for navigate coords, proving the write-only gap.
These tests are evidence, not regression guards. Test 2 documents a known
structural gap; when the gap is fixed, Test 2 should be updated to assert
templates ARE produced.
"""
import os
import re
from types import SimpleNamespace
os.environ.setdefault("RPA_AUTH_DISABLED", "true")
from agent_v0.server_v1.replay_engine import (
_edge_to_normalized_actions,
_resolve_runtime_vars,
_resolve_runtime_vars_in_str,
)
# ── Fake fixtures (minimal, per test_visual_anchor_semantics.py pattern) ──
class _FakeAction:
def __init__(self, type_, target=None, parameters=None):
self.type = type_
self.target = target
self.parameters = parameters or {}
class _FakeEdge:
def __init__(self, action):
self.edge_id = "edge_coords_gap"
self.from_node = "node_src"
self.to_node = "node_dst"
self.action = action
# ── Test 1: resolve mechanism is viable ──────────────────────────────────
class TestResolveRuntimeVarsViable:
"""Prove _resolve_runtime_vars infrastructure works with template strings."""
VARIABLES = {
"navigate_login_coords": {
"x_pct": 0.15,
"y_pct": 0.07,
"method": "ocr_anchor",
}
}
def test_resolve_in_str_dot_path(self):
"""{{navigate_login_coords.x_pct}} → "0.15" (string, not float)."""
result = _resolve_runtime_vars_in_str(
"{{navigate_login_coords.x_pct}}", self.VARIABLES
)
assert result == "0.15"
def test_resolve_in_str_y_pct(self):
"""{{navigate_login_coords.y_pct}} → "0.07"."""
result = _resolve_runtime_vars_in_str(
"{{navigate_login_coords.y_pct}}", self.VARIABLES
)
assert result == "0.07"
def test_resolve_dict_with_templates(self):
"""_resolve_runtime_vars substitutes templates inside dict values."""
action = {
"type": "click",
"x_pct": "{{navigate_login_coords.x_pct}}",
"y_pct": "{{navigate_login_coords.y_pct}}",
}
resolved = _resolve_runtime_vars(action, self.VARIABLES)
assert resolved["x_pct"] == "0.15"
assert resolved["y_pct"] == "0.07"
assert resolved["type"] == "click" # no-template strings unchanged
def test_resolve_nested_dict(self):
"""_resolve_runtime_vars handles nested dicts with templates."""
action = {
"parameters": {
"coords": "{{navigate_login_coords.x_pct}}",
},
}
resolved = _resolve_runtime_vars(action, self.VARIABLES)
assert resolved["parameters"]["coords"] == "0.15"
def test_resolve_missing_var_leaves_template_intact(self):
"""Missing variable: template string stays unchanged."""
result = _resolve_runtime_vars_in_str(
"{{navigate_password_coords.x_pct}}", self.VARIABLES
)
assert "{{navigate_password_coords.x_pct}}" in result
def test_resolve_float_passthrough(self):
"""_resolve_runtime_vars returns non-str values unchanged — floats pass through."""
action = {"x_pct": 0.15, "y_pct": 0.07}
resolved = _resolve_runtime_vars(action, self.VARIABLES)
# Floats are NOT substituted — they're not strings containing {{...}}
assert resolved["x_pct"] == 0.15 # literal float, unchanged
assert resolved["y_pct"] == 0.07
# ── Test 2: compiler gap — literals not templates ────────────────────────
class TestCompilerGapLiteralFloats:
"""Document that _edge_to_normalized_actions produces literal floats,
never template strings — so navigate coords are write-only.
This is the STRUCTURAL GAP: the compiler bakes coords as floats,
_resolve_runtime_vars only operates on strings, so stored navigate
variables are never consumed downstream.
"""
def test_mouse_click_produces_literal_floats(self):
"""mouse_click edge: x_pct/y_pct are literal floats, not templates."""
target = SimpleNamespace(
by_position=(0.15, 0.07),
by_role=None,
by_text=None,
context_hints={},
)
edge = _FakeEdge(
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
)
actions = _edge_to_normalized_actions(edge, params={})
assert len(actions) == 1
action = actions[0]
# GAP: coords are literal floats, not template strings
assert isinstance(action["x_pct"], float)
assert isinstance(action["y_pct"], float)
assert action["x_pct"] == 0.15
assert action["y_pct"] == 0.07
# Proof: no template string is ever produced by the compiler
assert not isinstance(action["x_pct"], str)
assert not isinstance(action["y_pct"], str)
def test_literal_floats_not_resolved(self):
"""Literal floats pass through _resolve_runtime_vars unchanged —
proving navigate coords stored in variables are NEVER consumed."""
target = SimpleNamespace(
by_position=(0.15, 0.07),
by_role=None,
by_text=None,
context_hints={},
)
edge = _FakeEdge(
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
)
actions = _edge_to_normalized_actions(edge, params={})
action = actions[0]
# Simulate variables from a prior navigate_login step
different_coords = {
"navigate_login_coords": {"x_pct": 0.20, "y_pct": 0.10}
}
resolved = _resolve_runtime_vars(action, different_coords)
# Coords REMAIN the original literal floats — no substitution
assert resolved["x_pct"] == 0.15 # NOT 0.20 (no substitution)
assert resolved["y_pct"] == 0.07 # NOT 0.10 (no substitution)
def test_text_input_produces_literal_floats(self):
"""text_input edge: same literal float pattern for click target."""
target = SimpleNamespace(
by_position=(0.30, 0.50),
by_role=None,
by_text=None,
context_hints={},
)
edge = _FakeEdge(
_FakeAction("text_input", target=target, parameters={"text": "admin"})
)
actions = _edge_to_normalized_actions(edge, params={})
assert len(actions) == 1
action = actions[0]
assert isinstance(action["x_pct"], float)
assert isinstance(action["y_pct"], float)
assert action["x_pct"] == 0.30
assert action["y_pct"] == 0.50
def test_navigate_action_type_unknown(self):
"""navigate action type is NOT handled by _edge_to_normalized_actions —
falls into the else branch logging "Type d'action inconnu"."""
edge = _FakeEdge(_FakeAction("navigate", parameters={"target": "login"}))
actions = _edge_to_normalized_actions(edge, params={})
# navigate produces empty actions — not compiled at all
assert actions == []

View File

@@ -0,0 +1,219 @@
"""Tests TDD — Extraction « dossier patient » (brique 3).
Deux couches testées :
1. ``vwb_db.persist_extracted_dossier`` : depuis une grille OCR
(List[List[cell]]), crée ExtractionJob → ExtractedTable → ExtractedField
et commit. Testé sur SQLite mémoire via un app-context Flask jetable
(PAS la vraie DB VWB — isolation).
2. ``replay_engine._handle_extract_dossier_action`` : lit last_screenshot,
appelle ``extract_grid_from_image`` (mocké), applique la gate qualité
(complete / needs_review), persiste via vwb_db et n'échoue JAMAIS le
replay (grille vide → needs_review, sans lever).
⚠️ Canal extraction = données patient EN CLAIR (volontaire) : on vérifie
que les valeurs sont persistées telles quelles, sans tokenisation.
"""
import pytest
from flask import Flask
# vwb_db ajoute visual_workflow_builder/backend au sys.path à l'import →
# doit précéder l'import de db.models (couplage worker→DB VWB mutualisé).
import agent_v0.server_v1.vwb_db as vwb_db
import agent_v0.server_v1.replay_engine as replay_engine
from db.models import db, ExtractionJob, ExtractedTable, ExtractedField
# ---------------------------------------------------------------------------
# Fixtures : app Flask jetable sur SQLite mémoire (isolation totale)
# ---------------------------------------------------------------------------
@pytest.fixture
def mem_app():
"""App Flask minimale liée à une DB SQLite en mémoire."""
app = Flask("test_extract_dossier")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
with app.app_context():
db.create_all()
yield app
def _grid_2x2():
"""Grille connue 2×2 (confiances hautes)."""
return [
[
{"text": "Nom", "bbox": [[0, 0], [1, 0], [1, 1], [0, 1]], "confidence": 0.95, "row": 0, "col": 0},
{"text": "MOREL", "bbox": [[2, 0], [3, 0], [3, 1], [2, 1]], "confidence": 0.92, "row": 0, "col": 1},
],
[
{"text": "IPP", "bbox": [[0, 2], [1, 2], [1, 3], [0, 3]], "confidence": 0.90, "row": 1, "col": 0},
{"text": "25123456", "bbox": [[2, 2], [3, 2], [3, 3], [2, 3]], "confidence": 0.88, "row": 1, "col": 1},
],
]
# ---------------------------------------------------------------------------
# 1) persist_extracted_dossier
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_persist_extracted_dossier_creates_job_table_fields(mem_app):
job_id = vwb_db.persist_extracted_dossier(
_grid_2x2(),
patient_ref="MOREL Catherine",
source_session_id="sess-42",
screenshot_ref="/captures/last.png",
screen_bbox={"x": 0, "y": 0, "width": 800, "height": 600},
status="complete",
)
assert isinstance(job_id, str) and job_id
job = db.session.get(ExtractionJob, job_id)
assert job is not None
assert job.status == "complete"
assert job.patient_ref == "MOREL Catherine" # EN CLAIR, non tokenisé
assert job.source_session_id == "sess-42"
tables = ExtractedTable.query.filter_by(job_id=job_id).all()
assert len(tables) == 1
assert tables[0].screenshot_ref == "/captures/last.png"
assert tables[0].screen_bbox == {"x": 0, "y": 0, "width": 800, "height": 600}
fields = ExtractedField.query.filter_by(table_id=tables[0].id).all()
assert len(fields) == 4 # 2×2 cellules
values = {(f.row, f.col): f.value for f in fields}
assert values[(0, 1)] == "MOREL" # valeur patient EN CLAIR conservée
assert values[(1, 1)] == "25123456"
confs = {(f.row, f.col): f.confidence for f in fields}
assert confs[(0, 0)] == pytest.approx(0.95)
@pytest.mark.unit
def test_persist_extracted_dossier_empty_grid_still_creates_job(mem_app):
"""Grille vide → Job + Table sans Field (statut transmis tel quel)."""
job_id = vwb_db.persist_extracted_dossier(
[],
patient_ref=None,
source_session_id="sess-empty",
screenshot_ref="/captures/empty.png",
screen_bbox=None,
status="needs_review",
)
job = db.session.get(ExtractionJob, job_id)
assert job is not None and job.status == "needs_review"
tables = ExtractedTable.query.filter_by(job_id=job_id).all()
assert len(tables) == 1
assert ExtractedField.query.filter_by(table_id=tables[0].id).count() == 0
# ---------------------------------------------------------------------------
# 2) _handle_extract_dossier_action
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_handle_extract_dossier_complete(mem_app, monkeypatch, tmp_path):
# screenshot bidon sur disque (le mock OCR ignore le contenu)
shot = tmp_path / "shot.png"
shot.write_bytes(b"\x89PNG")
# extract_grid_from_image mocké → grille 2×2 de confiance haute
monkeypatch.setattr(
"core.llm.extract_grid_from_image",
lambda *a, **k: _grid_2x2(),
)
# vwb_app_context pointé sur l'app mémoire de la fixture
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
monkeypatch.setattr(replay_engine, "vwb_db", vwb_db, raising=False)
replay_state = {
"last_screenshot": str(shot),
"variables": {},
"replay_id": "rep-1",
}
action = {
"type": "extract_dossier",
"parameters": {
"output_var": "dossier_id",
"patient_ref": "MOREL Catherine",
"expected_cols": 2,
"min_confidence": 0.5,
},
}
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-42")
assert ok is True
job_id = replay_state["variables"]["dossier_id"]
assert isinstance(job_id, str) and job_id
with mem_app.app_context():
job = db.session.get(ExtractionJob, job_id)
assert job is not None
assert job.status == "complete" # gate OK : non vide, conf ok, 2 cols
@pytest.mark.unit
def test_handle_extract_dossier_low_confidence_needs_review(mem_app, monkeypatch, tmp_path):
shot = tmp_path / "shot.png"
shot.write_bytes(b"\x89PNG")
low_grid = [
[{"text": "x", "bbox": [], "confidence": 0.10, "row": 0, "col": 0}],
]
monkeypatch.setattr("core.llm.extract_grid_from_image", lambda *a, **k: low_grid)
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
replay_state = {"last_screenshot": str(shot), "variables": {}, "replay_id": "rep-2"}
action = {"type": "extract_dossier", "parameters": {"min_confidence": 0.5}}
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-low")
assert ok is False # gate a basculé en needs_review
job_id = replay_state["variables"]["extracted_dossier"]
with mem_app.app_context():
assert db.session.get(ExtractionJob, job_id).status == "needs_review"
@pytest.mark.unit
def test_handle_extract_dossier_empty_grid_no_raise(mem_app, monkeypatch, tmp_path):
shot = tmp_path / "shot.png"
shot.write_bytes(b"\x89PNG")
monkeypatch.setattr("core.llm.extract_grid_from_image", lambda *a, **k: [])
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
replay_state = {"last_screenshot": str(shot), "variables": {}, "replay_id": "rep-3"}
action = {"type": "extract_dossier", "parameters": {}}
# Ne lève jamais ; grille vide → needs_review
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-empty")
assert ok is False
job_id = replay_state["variables"]["extracted_dossier"]
with mem_app.app_context():
assert db.session.get(ExtractionJob, job_id).status == "needs_review"
@pytest.mark.unit
def test_handle_extract_dossier_persist_failure_no_raise(mem_app, monkeypatch, tmp_path):
"""Si la persistance lève, le handler log et n'échoue PAS le replay."""
shot = tmp_path / "shot.png"
shot.write_bytes(b"\x89PNG")
monkeypatch.setattr("core.llm.extract_grid_from_image", lambda *a, **k: _grid_2x2())
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
def _boom(*a, **k):
raise RuntimeError("DB down")
monkeypatch.setattr(vwb_db, "persist_extracted_dossier", _boom)
replay_state = {"last_screenshot": str(shot), "variables": {}, "replay_id": "rep-4"}
action = {"type": "extract_dossier", "parameters": {}}
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-boom")
assert ok is False # jamais de raise
@pytest.mark.unit
def test_extract_dossier_declared_in_action_type_sets():
assert "extract_dossier" in replay_engine._ALLOWED_ACTION_TYPES
assert "extract_dossier" in replay_engine._SERVER_SIDE_ACTION_TYPES

View File

@@ -0,0 +1,68 @@
"""Tests de l'orchestrateur extract_dossier_from_image.
Enchaîne OCR → tokens_from_grid → map_roles → assess_quality. L'OCR (`ocr_fn`)
et le client VLM (`vlm_client`) sont INJECTABLES → testable sans réseau ni OCR
réel. C'est cette fonction que le handler runtime `_handle_extract_dossier_action`
appellera (avec le vrai OCR et le vrai client vLLM).
"""
from core.extraction.role_mapper import extract_dossier_from_image
def _cell(text, x0, conf=0.9, row=0, col=0):
return {"text": text, "bbox": [[x0, 0], [x0 + 10, 0], [x0 + 10, 8], [x0, 8]],
"confidence": conf, "row": row, "col": col}
def _fake_vlm(response):
def client(image_path, prompt):
return response
return client
def test_orchestre_ocr_vlm_qualite():
grid = [[_cell("DUPONT", 0, conf=0.95, col=0), _cell("Jean", 20, conf=0.9, col=1)]]
res = extract_dossier_from_image(
"img.png",
_fake_vlm('{"champs":[{"label":"Nom complet","value_ids":[0,1]}]}'),
ocr_fn=lambda path: grid,
)
assert len(res["fields"]) == 1
assert res["fields"][0].value == "DUPONT Jean"
assert res["fields"][0].anchored is True
assert res["status"] in ("complete", "partial", "needs_review", "failed")
assert res["n_tokens"] == 2
def test_ocr_vide_donne_failed():
res = extract_dossier_from_image(
"img.png",
_fake_vlm('{"champs":[]}'),
ocr_fn=lambda path: [],
)
assert res["status"] == "failed"
assert res["fields"] == []
def test_status_needs_review_si_role_requis_absent():
grid = [[_cell("X", 0)]]
res = extract_dossier_from_image(
"img.png",
_fake_vlm('{"champs":[{"label":"Autre","value_ids":[0]}]}'),
ocr_fn=lambda path: grid,
required_roles=["Nom"],
)
assert res["status"] == "needs_review"
def test_roles_transmis_au_vlm():
grid = [[_cell("X", 0)]]
captured = {}
def client(image_path, prompt):
captured["prompt"] = prompt
return '{"champs":[]}'
extract_dossier_from_image(
"img.png", client, ocr_fn=lambda path: grid, roles=["Diagnostic", "GEMSA"],
)
assert "Diagnostic" in captured["prompt"] and "GEMSA" in captured["prompt"]

View File

@@ -0,0 +1,79 @@
"""Tests pour extract_grid_from_image — lecture de tableau STRUCTURÉE.
Contrairement à extract_table_from_image (qui jette x et retourne une liste
plate triée par y), extract_grid_from_image reconstruit une vraie grille
List[List[cell]] : clustering des lignes par proximité y, des colonnes par
proximité x. bbox + confiance conservées par cellule.
Les tokens OCR sont injectés (mock du reader EasyOCR) → pas de PNG réel,
pas de GPU.
"""
from pathlib import Path
from types import SimpleNamespace
from PIL import Image
import core.llm.ocr_extractor as ocr_extractor
def _blank_png(path: Path) -> None:
Image.new("RGB", (300, 120), "white").save(path)
def _bbox(x0: float, y0: float, x1: float, y1: float):
"""bbox EasyOCR = 4 points [tl, tr, br, bl], chaque point (x, y)."""
return [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
def _fake_reader(tokens):
"""Reader factice : readtext() renvoie la liste (bbox, text, conf) fournie."""
return SimpleNamespace(readtext=lambda *a, **k: tokens)
def test_extract_grid_2x3(tmp_path, monkeypatch):
image_path = tmp_path / "table.png"
_blank_png(image_path)
# 2 lignes (y≈10 et y≈60) × 3 colonnes (x≈10, x≈110, x≈210).
# Volontairement mélangées dans l'ordre OCR pour vérifier le tri.
tokens = [
(_bbox(110, 58, 160, 78), "B2", 0.97),
(_bbox(10, 10, 60, 30), "A1", 0.91),
(_bbox(210, 12, 260, 32), "C1", 0.88),
(_bbox(210, 60, 260, 80), "C2", 0.95),
(_bbox(10, 60, 60, 80), "A2", 0.90),
(_bbox(110, 8, 160, 28), "B1", 0.93),
]
monkeypatch.setattr(ocr_extractor, "_get_reader", lambda: _fake_reader(tokens))
grid = ocr_extractor.extract_grid_from_image(str(image_path))
# Grille 2×3 ordonnée
assert len(grid) == 2, "doit détecter 2 lignes"
assert all(len(row) == 3 for row in grid), "chaque ligne doit avoir 3 colonnes"
texts = [[cell["text"] for cell in row] for row in grid]
assert texts == [["A1", "B1", "C1"], ["A2", "B2", "C2"]]
# Métadonnées conservées + indices row/col cohérents
cell = grid[0][2]
assert cell["text"] == "C1"
assert cell["confidence"] == 0.88
assert cell["bbox"] == _bbox(210, 12, 260, 32)
assert cell["row"] == 0
assert cell["col"] == 2
assert grid[1][0]["row"] == 1 and grid[1][0]["col"] == 0
def test_extract_grid_empty_when_no_tokens(tmp_path, monkeypatch):
image_path = tmp_path / "blank.png"
_blank_png(image_path)
monkeypatch.setattr(ocr_extractor, "_get_reader", lambda: _fake_reader([]))
grid = ocr_extractor.extract_grid_from_image(str(image_path))
assert grid == []
def test_extract_grid_missing_file_returns_empty():
grid = ocr_extractor.extract_grid_from_image("/no/such/file.png")
assert grid == []

View File

@@ -0,0 +1,406 @@
"""Tests for core/navigation/grounding.py — OCR-anchored grounding + VLM fallback + coords cache."""
import json
import pytest
from core.navigation.grounding import (
OcrTokenInfo,
GroundedElement,
CoordsCacheEntry,
CoordsCache,
bbox_center,
make_element_key,
ocr_anchor_ground,
build_grounder_prompt,
parse_grounder_response,
ground_element,
)
from core.navigation.visual_verifier import normalize_text
# ── Mock factories ─────────────────────────────────────────────────────
def mock_ocr_detailed_client_factory(tokens: list):
"""Factory for mock OcrDetailedClient returning List[OcrTokenInfo]."""
def client(image_path: str) -> list:
return tokens
return client
def mock_vlm_client_factory(response_json: dict):
"""Factory for mock VlmClient returning given JSON."""
def client(image_path: str, prompt: str) -> str:
return json.dumps(response_json)
return client
# ── bbox_center tests ──────────────────────────────────────────────────
class TestBboxCenter:
def test_basic(self):
assert bbox_center((100, 200, 300, 400)) == (200, 300)
def test_zero_origin(self):
assert bbox_center((0, 0, 100, 100)) == (50, 50)
def test_symmetric(self):
assert bbox_center((10, 10, 20, 20)) == (15, 15)
# ── make_element_key tests ─────────────────────────────────────────────
class TestMakeElementKey:
def test_basic(self):
key = make_element_key("bouton", "Rechercher")
assert key == "bouton:rechercher"
def test_normalized(self):
key = make_element_key("champ", "Nom Prénom")
assert "nom" in key and "prenom" in key
def test_consistent(self):
# Same element always produces same key
assert make_element_key("bouton", "Connexion") == make_element_key("bouton", "CONNEXION")
# ── ocr_anchor_ground tests ────────────────────────────────────────────
class TestOcrAnchorGround:
def test_exact_match(self):
tokens = [OcrTokenInfo(text="Rechercher", bbox=(100, 50, 250, 90), confidence=0.95)]
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
assert result is not None
assert result.method == "ocr_anchor"
assert result.bbox == (100, 50, 250, 90)
assert result.center == (175, 70)
assert result.confidence == 0.95
def test_fuzzy_match(self):
tokens = [OcrTokenInfo(text="Rechércher", bbox=(100, 50, 250, 90))]
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
assert result is not None
assert result.source_ocr_text == "Rechércher"
def test_no_match(self):
tokens = [OcrTokenInfo(text="Accueil", bbox=(100, 50, 250, 90))]
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
assert result is None
def test_token_without_bbox(self):
tokens = [OcrTokenInfo(text="Rechercher", bbox=None)]
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
assert result is None # found text but no bbox → can't ground
def test_no_text_target(self):
tokens = [OcrTokenInfo(text="Dashboard", bbox=(0, 0, 1920, 1080))]
result = ocr_anchor_ground(tokens, {"role": "page"}) # no text key
assert result is None # no text to match
def test_multiple_tokens_first_match(self):
tokens = [
OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40)),
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90)),
]
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Connexion"})
assert result is not None
assert result.bbox == (200, 50, 350, 90)
# ── build_grounder_prompt tests ────────────────────────────────────────
class TestBuildGrounderPrompt:
def test_basic_prompt(self):
prompt = build_grounder_prompt({"role": "bouton", "text": "Connexion"})
assert "bouton" in prompt
assert "Connexion" in prompt
assert "bbox" in prompt
def test_with_context(self):
prompt = build_grounder_prompt(
{"role": "champ", "text": "Login"},
context="page login DPI",
)
assert "page login DPI" in prompt
def test_with_extra(self):
prompt = build_grounder_prompt(
{"role": "champ", "text": "IPP", "extra": "colonne gauche"},
)
assert "colonne gauche" in prompt
# ── parse_grounder_response tests ──────────────────────────────────────
class TestParseGrounderResponse:
def test_valid_response(self):
vlm_text = json.dumps({
"found": True,
"bbox": [0.1, 0.2, 0.3, 0.4],
"confidence": 0.92,
"description": "login button",
})
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
assert result is not None
assert result.method == "vlm_grounder"
assert result.bbox == (192, 216, 576, 432) # 0.1*1920, 0.2*1080, 0.3*1920, 0.4*1080
assert result.confidence == 0.92
def test_not_found(self):
vlm_text = json.dumps({"found": False, "bbox": [], "confidence": 0.0})
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
assert result is None
def test_json_in_markdown(self):
vlm_text = "```json\n{\"found\": true, \"bbox\": [0.5, 0.5, 0.6, 0.6], \"confidence\": 0.8}\n```"
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
assert result is not None
def test_garbled_response(self):
result = parse_grounder_response("I cannot find the element", 1920, 1080, {"role": "bouton", "text": "Connexion"})
assert result is None
def test_invalid_bbox_format(self):
vlm_text = json.dumps({"found": True, "bbox": [0.1, 0.2], "confidence": 0.8})
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
assert result is None # bbox must have 4 values
def test_confidence_as_string(self):
vlm_text = json.dumps({"found": True, "bbox": [0.1, 0.2, 0.3, 0.4], "confidence": "0.85"})
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
assert result is not None
assert result.confidence == 0.85
def test_bbox_clamped_to_screen(self):
vlm_text = json.dumps({"found": True, "bbox": [-0.1, -0.1, 1.5, 1.5], "confidence": 0.7})
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
assert result is not None
assert result.bbox[0] >= 0
assert result.bbox[1] >= 0
assert result.bbox[2] <= 1920
assert result.bbox[3] <= 1080
# ── ground_element (composition) tests ─────────────────────────────────
class TestGroundElement:
def test_ocr_anchor_success(self):
"""OCR finds text with bbox → grounded via OCR (deterministic)."""
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90), confidence=0.95),
])
vlm = mock_vlm_client_factory({})
result = ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=ocr,
vlm_client=vlm,
)
assert result is not None
assert result.method == "ocr_anchor"
assert result.bbox == (200, 50, 350, 90)
def test_vlm_fallback(self):
"""OCR doesn't find text → VLM grounder succeeds."""
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40)),
])
vlm = mock_vlm_client_factory({
"found": True,
"bbox": [0.2, 0.3, 0.4, 0.5],
"confidence": 0.85,
})
result = ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=ocr,
vlm_client=vlm,
)
assert result is not None
assert result.method == "vlm_grounder"
def test_not_found_any_method(self):
"""Both OCR and VLM fail → None."""
ocr = mock_ocr_detailed_client_factory([OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40))])
vlm = mock_vlm_client_factory({"found": False, "bbox": [], "confidence": 0.0})
result = ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=ocr,
vlm_client=vlm,
)
assert result is None
def test_ocr_error_vlm_fallback(self):
"""OCR engine fails → VLM fallback."""
def failing_ocr(image_path):
raise RuntimeError("OCR engine down")
vlm = mock_vlm_client_factory({
"found": True,
"bbox": [0.2, 0.3, 0.4, 0.5],
"confidence": 0.8,
})
result = ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=failing_ocr,
vlm_client=vlm,
)
assert result is not None
assert result.method == "vlm_grounder"
def test_vlm_error_ocr_success(self):
"""VLM fails but OCR succeeds → OCR anchor used."""
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90)),
])
def failing_vlm(image_path, prompt):
raise RuntimeError("VLM down")
result = ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=ocr,
vlm_client=failing_vlm,
)
assert result is not None
assert result.method == "ocr_anchor"
def test_both_fail(self):
"""OCR + VLM both fail → None."""
def failing_ocr(image_path):
raise RuntimeError("OCR down")
def failing_vlm(image_path, prompt):
raise RuntimeError("VLM down")
result = ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=failing_ocr,
vlm_client=failing_vlm,
)
assert result is None
def test_no_text_target(self):
"""Target without text → VLM grounder skipped, None."""
ocr = mock_ocr_detailed_client_factory([])
vlm = mock_vlm_client_factory({})
result = ground_element(
"/tmp/page.png",
{"role": "page"},
ocr_client=ocr,
vlm_client=vlm,
)
assert result is None
def test_cache_hit(self):
"""Cached coords exist → returned directly."""
cache = CoordsCache()
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
ocr = mock_ocr_detailed_client_factory([])
vlm = mock_vlm_client_factory({})
result = ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=ocr,
vlm_client=vlm,
coords_cache=cache,
)
assert result is not None
assert result.method == "cache"
assert result.bbox == (200, 50, 350, 90)
def test_cache_stored_on_ocr_anchor(self):
"""OCR anchor result → stored in cache."""
cache = CoordsCache()
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90)),
])
vlm = mock_vlm_client_factory({})
ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=ocr,
vlm_client=vlm,
coords_cache=cache,
)
cached = cache.get("bouton:connexion")
assert cached is not None
assert cached.bbox == (200, 50, 350, 90)
assert cached.method == "ocr_anchor"
def test_cache_stored_on_vlm_grounder(self):
"""VLM grounder result → stored in cache."""
cache = CoordsCache()
ocr = mock_ocr_detailed_client_factory([])
vlm = mock_vlm_client_factory({
"found": True,
"bbox": [0.2, 0.3, 0.4, 0.5],
"confidence": 0.85,
})
ground_element(
"/tmp/login.png",
{"role": "bouton", "text": "Connexion"},
ocr_client=ocr,
vlm_client=vlm,
coords_cache=cache,
)
cached = cache.get("bouton:connexion")
assert cached is not None
assert cached.method == "vlm_grounder"
# ── CoordsCache tests ──────────────────────────────────────────────────
class TestCoordsCache:
def test_put_and_get(self):
cache = CoordsCache()
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
entry = cache.get("bouton:connexion")
assert entry is not None
assert entry.bbox == (200, 50, 350, 90)
def test_get_missing(self):
cache = CoordsCache()
assert cache.get("bouton:connexion") is None
def test_invalidate(self):
cache = CoordsCache()
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
cache.invalidate("bouton:connexion")
assert cache.get("bouton:connexion") is None
def test_clear(self):
cache = CoordsCache()
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
cache.put("b", (0, 0, 20, 20), (10, 10), "vlm_grounder")
cache.clear()
assert cache.get("a") is None
assert cache.get("b") is None
def test_keys(self):
cache = CoordsCache()
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
cache.put("b", (0, 0, 20, 20), (10, 10), "vlm_grounder")
assert sorted(cache.keys()) == ["a", "b"]
def test_update_existing(self):
cache = CoordsCache()
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
cache.put("bouton:connexion", (300, 60, 400, 100), (350, 80), "vlm_grounder")
entry = cache.get("bouton:connexion")
assert entry is not None
assert entry.bbox == (300, 60, 400, 100) # updated
assert entry.validation_count == 2
def test_validation_count_increments(self):
cache = CoordsCache()
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
assert cache.get("a").validation_count == 1
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
assert cache.get("a").validation_count == 2

View File

@@ -0,0 +1,73 @@
"""Tests unitaires des helpers de logging PII-safe du client Léa (agent_v1).
Assainissement des logs à la source : on ne logge jamais le contenu brut
(titres de fenêtre, noms de workflow, chemins, métadonnées sensibles). On le
remplace par un hash court stable, une longueur, ou un dict filtré.
Branche feat/push-log-dgx — DETTE-020 (assainissement PII des logs, brique 4).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
_HEX8 = re.compile(r"^[0-9a-f]{8}$")
def test_title_hash_is_short_stable_hex():
from agent_v0.agent_v1.core.log_safe import _title_hash
h = _title_hash("Dossier MOREL Catherine")
assert _HEX8.match(h), f"attendu 8 hex, obtenu {h!r}"
assert h == _title_hash("Dossier MOREL Catherine") # déterministe
def test_title_hash_never_reveals_raw_title():
"""Propriété PII centrale : le hash ne contient jamais le contenu brut."""
from agent_v0.agent_v1.core.log_safe import _title_hash
title = "Dossier MOREL Catherine"
h = _title_hash(title)
assert title not in h
assert "MOREL" not in h
def test_title_hash_distinguishes_different_titles():
from agent_v0.agent_v1.core.log_safe import _title_hash
assert _title_hash("popup A") != _title_hash("popup B")
def test_title_hash_handles_empty_and_non_ascii():
from agent_v0.agent_v1.core.log_safe import _title_hash
assert _HEX8.match(_title_hash(""))
assert _HEX8.match(_title_hash("Éléonore — café ☕"))
def test_sanitize_metadata_drops_pii_keys_keeps_technical():
from agent_v0.agent_v1.core.log_safe import _sanitize_metadata
meta = {
"resolution": "1920x1080", "dpi": 96, "theme": "dark",
"title": "Dossier Dupont", "active_window": "Medicare", "window_title": "x",
}
safe = _sanitize_metadata(meta)
assert safe == {"resolution": "1920x1080", "dpi": 96, "theme": "dark"}
assert meta.get("title") == "Dossier Dupont" # original non muté
def test_path_ext_returns_extension_only():
from agent_v0.agent_v1.core.log_safe import _path_ext
assert _path_ext("/home/tim/Dossier Dupont 1980.png") == ".png"
assert "Dupont" not in _path_ext("/x/Dupont.png")
assert _path_ext("") == ""
assert _path_ext("/no/ext/here") == ""

View File

@@ -0,0 +1,151 @@
"""End-to-end mocked test for navigate action handler — 3 edge-case scenarios.
Tests the _handle_navigate_action handler with mocked OCR/VLM, verifying:
- Nominal: all resolved, coords populated in variables
- OCR miss + VLM fail: no phantom coords, all_resolved=False
- No screenshot: error="no_screenshot", False return
NOTE: The handler uses lazy imports inside its body. Mock targets must be
at the source module (core.navigation.action_resolver.navigate_login) rather
than the package-level re-export (core.navigation.navigate_login).
"""
import pytest
from unittest.mock import patch, MagicMock
from core.navigation.action_resolver import NavigateCoords, NavigateResult
from core.navigation import _handle_navigate_action
def _patch_all_deps(navigate_login_result=None, navigate_login_side_effect=None):
"""Return stacked patches for handler's lazy imports + navigate_login."""
nl_mock = MagicMock(return_value=navigate_login_result) if navigate_login_result else None
if navigate_login_side_effect:
nl_mock = MagicMock(side_effect=navigate_login_side_effect)
return (
patch("core.llm.extract_grid_from_image", return_value=[]),
patch("core.extraction.vlm_client.make_vllm_client", return_value=MagicMock()),
patch("core.navigation.action_resolver.make_ocr_detailed_from_grid",
return_value=MagicMock(return_value=[])),
patch("core.navigation.action_resolver.navigate_login", nl_mock),
)
class TestNominalCase:
"""All fields grounded → coords populated, all_resolved=True."""
def test_nominal_coords_populated(self):
mock_result = NavigateResult(
login_coords=NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor"),
password_coords=NavigateCoords(x_pct=0.15, y_pct=0.25, method="ocr_anchor"),
submit_coords=NavigateCoords(x_pct=0.50, y_pct=0.35, method="ocr_anchor"),
all_resolved=True,
)
action = {"parameters": {"action": "login"}}
replay_state = {
"last_screenshot_path": "/tmp/login_screen.png",
"screen_width": 1920,
"screen_height": 1080,
}
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
with p1, p2, p3, p4:
result = _handle_navigate_action(action, replay_state, "test-session")
assert result is True
vars_ = replay_state["variables"]
assert "navigate_login_coords" in vars_
assert vars_["navigate_login_coords"]["x_pct"] == 0.15
assert "navigate_password_coords" in vars_
assert "navigate_submit_coords" in vars_
assert vars_["navigate_result"]["all_resolved"] is True
class TestOcrMissVlmFail:
"""OCR misses target + VLM grounder also fails → no phantom coords."""
def test_no_phantom_coords_on_failure(self):
mock_result = NavigateResult(
login_coords=None,
password_coords=None,
submit_coords=None,
all_resolved=False,
error="grounding failed — no login form elements found",
)
action = {"parameters": {"action": "login"}}
replay_state = {
"last_screenshot_path": "/tmp/no_login_form.png",
"screen_width": 1920,
"screen_height": 1080,
}
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
with p1, p2, p3, p4:
result = _handle_navigate_action(action, replay_state, "test-session")
assert result is False
vars_ = replay_state["variables"]
# No coords keys should be present (coords are None → not stored)
assert "navigate_login_coords" not in vars_
assert "navigate_password_coords" not in vars_
assert "navigate_submit_coords" not in vars_
# Error must be non-empty
assert vars_["navigate_result"]["all_resolved"] is False
assert "grounding failed" in vars_["navigate_result"]["error"]
class TestNoScreenshot:
"""No screenshot in replay_state → error="no_screenshot", False."""
def test_no_screenshot_error(self):
action = {"parameters": {"action": "login"}}
replay_state = {} # No screenshot at all
result = _handle_navigate_action(action, replay_state, "test-session")
assert result is False
vars_ = replay_state["variables"]
assert vars_["navigate_login_coords"]["error"] == "no_screenshot"
def test_empty_screenshot_path(self):
action = {"parameters": {"action": "login"}}
replay_state = {"last_screenshot_path": ""}
result = _handle_navigate_action(action, replay_state, "test-session")
assert result is False
vars_ = replay_state["variables"]
assert vars_["navigate_login_coords"]["error"] == "no_screenshot"
class TestNeverFailReplay:
"""Handler must never raise — even on malformed input, returns False."""
def test_missing_parameters(self):
action = {} # No "parameters" key
replay_state = {"last_screenshot_path": "/tmp/x.png"}
mock_result = NavigateResult(all_resolved=False, error="no params")
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
with p1, p2, p3, p4:
result = _handle_navigate_action(action, replay_state, "test-session")
assert result is False
def test_exception_in_inner_call(self):
action = {"parameters": {"action": "login"}}
replay_state = {
"last_screenshot_path": "/tmp/login.png",
"screen_width": 1920,
"screen_height": 1080,
}
p1, p2, p3, p4 = _patch_all_deps(navigate_login_side_effect=RuntimeError("boom"))
with p1, p2, p3, p4:
result = _handle_navigate_action(action, replay_state, "test-session")
assert result is False
vars_ = replay_state["variables"]
assert vars_["navigate_result"]["all_resolved"] is False
assert "boom" in vars_["navigate_result"]["error"]

View File

@@ -0,0 +1,62 @@
"""Boot non-regression test for navigate wiring — catches import/regression bugs.
This test would have caught the ImportError where _handle_navigate_action
was incorrectly imported from replay_engine instead of core/navigation.
"""
import pytest
class TestApiStreamImports:
"""(1) api_stream must import without error."""
def test_import_api_stream(self):
from agent_v0.server_v1 import api_stream
assert api_stream is not None
class TestAllowedActionTypes:
"""(2) 'navigate' must be in both _ALLOWED and _SERVER_SIDE."""
def test_navigate_in_allowed(self):
from agent_v0.server_v1.replay_engine import _ALLOWED_ACTION_TYPES
assert "navigate" in _ALLOWED_ACTION_TYPES
def test_navigate_in_server_side(self):
from agent_v0.server_v1.replay_engine import _SERVER_SIDE_ACTION_TYPES
assert "navigate" in _SERVER_SIDE_ACTION_TYPES
class TestNavigateHandlerCallable:
"""(3) _handle_navigate_action must be callable with correct signature."""
def test_handler_imported_from_core_navigation(self):
from core.navigation import _handle_navigate_action
assert callable(_handle_navigate_action)
def test_handler_imported_in_api_stream(self):
from agent_v0.server_v1 import api_stream
handler = api_stream._handle_navigate_action
assert callable(handler)
def test_handler_signature(self):
"""Signature: (action: dict, replay_state: dict, session_id: str) -> bool."""
from core.navigation import _handle_navigate_action
import inspect
sig = inspect.signature(_handle_navigate_action)
params = list(sig.parameters.keys())
assert params == ["action", "replay_state", "session_id"]
assert sig.return_annotation == bool
class TestDispatchBlockExists:
"""Verify the navigate dispatch block is wired in api_stream."""
def test_navigate_dispatch_reference(self):
"""Source must contain the navigate dispatch elif block."""
import agent_v0.server_v1.api_stream as mod
source = inspect.getsource(mod)
assert "type_ == \"navigate\"" in source
import inspect

View File

@@ -0,0 +1,236 @@
"""Tests de l'assainissement PII des données capturées (titres, texte, OCR).
Couche 1 (sans modèle) : filet regex sur la PII structurée (IPP, NIR, TEL,
EMAIL, AGE) + règles structurelles cliniques (NOM (NAISSANCE) Prénom ;
[Nom Prénom] des fenêtres PACS), avec tokens TYPÉS et COHÉRENTS ([IPP_1]…).
Réutilise l'approche du projet `anonymisation` (placeholders + regex). La
couche NER (noms libres) viendra en complément. Cas réels remontés en clinique
le 28/06 (anonymisés ici par construction). Branche feat/push-log-dgx.
"""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def test_ipp_et_age_tokenises():
from agent_v0.server_v1.pii_sanitizer import anonymize_text
titre = "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Expert Sante - Mozilla Firefox"
out, ents = anonymize_text(titre)
assert "168246" not in out, out # IPP retiré
assert "[IPP_1]" in out
assert "90 ans" not in out # âge retiré
assert "[AGE_1]" in out
# le nom format clinique « NOM (NAISSANCE) Prénom » est tokenisé
assert "VIOLA" not in out and "Liliane" not in out, out
assert "[NOM_1]" in out
# le logiciel n'est pas pris pour de la PII
assert "Firefox" in out and "Expert Sante" in out
types = {e["type"] for e in ents}
assert {"IPP", "AGE", "NOM"} <= types
def test_nom_entre_crochets_pacs():
"""Le PACS met le patient entre crochets : `[DATTIN Alix]`."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
titre = "GXD5 Pacs 4.0.4.307 CIM ARES - [DATTIN Alix] - Mozilla Firefox"
out, _ = anonymize_text(titre)
assert "DATTIN" not in out and "Alix" not in out, out
assert "[NOM_1]" in out
assert "Pacs" in out and "Firefox" in out # contexte logiciel préservé
def test_coherence_meme_ipp_meme_token():
"""Même valeur PII -> même token (sur un mapping partagé de session)."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
mapping: dict = {}
o1, _ = anonymize_text("IPP: 168246 ouvert", mapping=mapping)
o2, _ = anonymize_text("dossier IPP: 168246 fermé", mapping=mapping)
o3, _ = anonymize_text("IPP: 270020 autre", mapping=mapping)
assert "[IPP_1]" in o1 and "[IPP_1]" in o2 # même patient -> même token
assert "[IPP_2]" in o3 # patient différent -> token différent
assert "270020" not in o3
def test_email_et_telephone():
from agent_v0.server_v1.pii_sanitizer import anonymize_text
out, _ = anonymize_text("contact j.dupont@chu.fr / 06 12 34 56 78")
assert "@chu.fr" not in out and "[EMAIL_1]" in out
assert "06 12 34 56 78" not in out and "[TEL_1]" in out
def test_texte_sans_pii_inchange():
from agent_v0.server_v1.pii_sanitizer import anonymize_text
t = "Expert Sante - Consultation - Mozilla Firefox"
out, ents = anonymize_text(t)
assert out == t
assert ents == []
# --- sanitize_event : assainissement au niveau event (option b pour text_input) ---
def test_sanitize_text_input_remplace_contenu_par_saisie():
"""Option b (Dom) : le contenu tapé n'est pas gardé -> [SAISIE]."""
from agent_v0.server_v1.pii_sanitizer import sanitize_event
ev = {
"type": "text_input",
"text": "hemorragie post-operatoire saignement", # contenu médical
"raw_keys": ["h", "e", "m"],
"window": {"title": "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Firefox",
"app_name": "firefox.exe"},
}
out = sanitize_event(ev)
assert out["text"] == "[SAISIE]"
assert out["raw_keys"] == "[SAISIE]"
# le titre de la fenêtre est assaini (identité tokenisée, app gardée)
assert "168246" not in out["window"]["title"]
assert "VIOLA" not in out["window"]["title"]
assert "[IPP_1]" in out["window"]["title"] and "Firefox" in out["window"]["title"]
# l'event d'origine n'est PAS muté
assert ev["text"].startswith("hemorragie")
def test_sanitize_heartbeat_titre_direct():
from agent_v0.server_v1.pii_sanitizer import sanitize_event
ev = {"type": "heartbeat",
"active_window_title": "GXD5 Pacs CIM ARES - [DATTIN Alix] - Firefox"}
out = sanitize_event(ev)
assert "DATTIN" not in out["active_window_title"]
assert "[NOM_1]" in out["active_window_title"] and "Pacs" in out["active_window_title"]
def test_sanitize_focus_change_to_from_window():
from agent_v0.server_v1.pii_sanitizer import sanitize_event
ev = {"type": "window_focus_change",
"from": None,
"to": {"title": "LAVAL (BARTHELEMY) Nicole 86 ans - Expert Sante", "app_name": "firefox.exe"},
"window": {"title": "LAVAL (BARTHELEMY) Nicole 86 ans - Expert Sante"}}
out = sanitize_event(ev)
assert out["from"] is None # null géré
assert "LAVAL" not in out["to"]["title"]
assert "[NOM_1]" in out["to"]["title"]
# cohérence : même patient dans to et window -> même token
assert out["window"]["title"] == out["to"]["title"]
def test_sanitize_action_result_inchange():
from agent_v0.server_v1.pii_sanitizer import sanitize_event
ev = {"type": "action_result", "base_shot_id": "shot_0003", "image": "x.png"}
assert sanitize_event(ev) == ev
def test_prenom_nom_inverse():
"""FN-1/2/3 (Qwen) : « Prénom NOM » inversé (sans parens/crochets)."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
m: dict = {}
for s, leak in [("Alix DATTIN - Mozilla Firefox", "DATTIN"),
("Agathe RONDOT - PACS CIM ARES", "RONDOT"),
("Marie FLANDINETTE - Mozilla Firefox", "FLANDINETTE")]:
out, _ = anonymize_text(s, mapping=m)
assert leak not in out, out
assert "[NOM_" in out
# pas de faux positif sur les logiciels (2e mot non capitalisé tout en majuscules)
out, ents = anonymize_text("Mozilla Firefox - Expert Sante - Consultation")
assert out == "Mozilla Firefox - Expert Sante - Consultation"
assert ents == []
def test_sanitize_event_titre_imbrique_vision_info():
"""FN-4 (Qwen) : titre PII imbriqué dans vision_info.window_capture (228 events)."""
from agent_v0.server_v1.pii_sanitizer import sanitize_event
titre = "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Firefox"
ev = {
"type": "mouse_click",
"window": {"title": titre, "app_name": "firefox.exe"},
"vision_info": {"window_capture": {"window_title": titre, "app_name": "firefox.exe"}},
}
out = sanitize_event(ev)
wc = out["vision_info"]["window_capture"]["window_title"]
assert "168246" not in wc and "VIOLA" not in wc, wc
assert "[IPP_1]" in wc
# cohérence : même titre dans window et vision_info -> même token
assert out["window"]["title"] == wc
def test_sanitize_workflow_dict_tokenise_by_text_garde_ui():
"""R1/PII : un workflow appris ne doit pas porter de PII brute dans ses cibles
(by_text) ni ses noms avant import en DB VWB ; l'interface est préservée."""
import json
from agent_v0.server_v1.pii_sanitizer import sanitize_workflow_dict
wf = {
"name": "Dossier patient",
"nodes": [{"node_id": "n1", "name": "VIOLA (VIOLA) Liliane 90 ans"}],
"edges": [{
"edge_id": "e1",
"action": {
"type": "mouse_click",
"target": {"by_text": "Valider", "by_role": "ocr"},
},
}],
}
out = sanitize_workflow_dict(wf)
s = json.dumps(out, ensure_ascii=False)
assert "VIOLA" not in s # nom clinique tokenisé (dans un node name)
assert "[NOM_1]" in s
assert "90 ans" not in s # âge tokenisé
assert "Valider" in s # cible UI préservée (by_text)
assert "VIOLA" in json.dumps(wf, ensure_ascii=False) # original non muté
def test_chevauchement_prefix_capitalise():
"""FN bloquant (Claude R1) : mot capitalisé avant NOM (NAISSANCE) Prénom
-> RE_PRENOM_NOM captait « Dossier VIOLA » et bloquait RE_NOM_NAISSANCE
« VIOLA (VIOLA) Liliane ». Fix : résolution par priorité détecteur + longueur."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
m: dict = {}
for titre, leak in [("Dossier VIOLA (VIOLA) Liliane", "VIOLA"),
("Patient ROSSIGNOL (SOUBIE) Pierrette", "ROSSIGNOL"),
("Fenetre LAVAL (BARTHELEMY) Nicole", "LAVAL")]:
out, _ = anonymize_text(titre, mapping=m)
assert leak not in out, f"FN: {leak} still visible in '{out}'"
# contrôle : sans préfixe, toujours OK
out, _ = anonymize_text("VIOLA (VIOLA) Liliane", mapping=m)
assert "VIOLA" not in out
def test_gxd5_diagnostics_numero_et_nom():
"""GXD5 Diagnostics — numéro de dossier + nom tout-majuscules (3 patients prod)."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
m: dict = {}
for titre, num_leak, nom_leak in [
("GXD5 Diagnostics - 128008 - BENVENISTE MARIE-LAURENCE", "128008", "BENVENISTE"),
("GXD5 Diagnostics - 272223 - LEMOINE ERIC", "272223", "LEMOINE"),
("GXD5 Diagnostics - 153442 - ROSELIER MATHEO", "153442", "ROSELIER"),
]:
out, ents = anonymize_text(titre, mapping=m)
assert num_leak not in out, f"FN: numéro {num_leak} visible dans '{out}'"
assert nom_leak not in out, f"FN: nom {nom_leak} visible dans '{out}'"
types = {e["type"] for e in ents}
assert "DOSSIER" in types, f"Pas de token DOSSIER dans {ents}"
assert "NOM" in types, f"Pas de token NOM dans {ents}"

View File

@@ -0,0 +1,75 @@
"""Tests unitaires pour _resolve_lea_zip_template (DETTE-024).
La fonction est injectable (full_path, legacy_path en paramètres)
→ testable sans instancier Flask ni lire le vrai deploy/.
Pattern anti-DETTE-013 : os.environ.setdefault avant l'import du module.
"""
import os
os.environ.setdefault("DASHBOARD_AUTH_DISABLED", "true")
import pytest # noqa: E402
from web_dashboard.app import _resolve_lea_zip_template # noqa: E402
class TestResolveLéaZipTemplate:
"""DETTE-024 — sélection du ZIP template pour le download fleet."""
def test_full_present_retourne_full(self, tmp_path):
"""Si le ZIP complet autoportant est présent, il est retourné."""
full = tmp_path / "Lea_full_v1.0.1.zip"
legacy = tmp_path / "Lea_v1.0.0.zip"
full.write_bytes(b"full-stub")
legacy.write_bytes(b"legacy-stub")
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
assert result == full, f"Attendu full ({full}), obtenu {result}"
def test_full_absent_retourne_legacy_avec_warning(self, tmp_path, caplog):
"""Si le ZIP complet est absent, le legacy est retourné + WARNING loggué.
Le WARNING est le signal observable en production (DETTE-024) :
sans lui, le fallback silencieux rendait le problème invisible.
"""
import logging
full = tmp_path / "Lea_full_v1.0.1.zip"
legacy = tmp_path / "Lea_v1.0.0.zip"
# full intentionnellement absent
legacy.write_bytes(b"legacy-stub")
with caplog.at_level(logging.WARNING):
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
assert result == legacy, f"Attendu legacy ({legacy}), obtenu {result}"
# Le WARNING DETTE-024 doit apparaître dans les logs
assert any(
"DETTE-024" in record.message for record in caplog.records
), (
"Un WARNING DETTE-024 doit être émis quand le ZIP complet est absent "
f"(logs: {[r.message for r in caplog.records]})"
)
def test_full_et_legacy_absents_retourne_none(self, tmp_path):
"""Si aucun ZIP n'existe, retourne None (la route renvoie 500)."""
full = tmp_path / "Lea_full_v1.0.1.zip"
legacy = tmp_path / "Lea_v1.0.0.zip"
# aucun des deux créés
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
assert result is None, f"Attendu None, obtenu {result}"
def test_full_prime_sur_legacy(self, tmp_path):
"""Le full est retourné même si le legacy existe aussi (priorité correcte)."""
full = tmp_path / "Lea_full_v1.0.1.zip"
legacy = tmp_path / "Lea_v1.0.0.zip"
full.write_bytes(b"full-stub")
legacy.write_bytes(b"legacy-stub")
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
assert result == full
assert result != legacy

View File

@@ -0,0 +1,296 @@
"""Tests du role_mapper : reconstruction de champs ANCRÉS sur l'OCR.
Principe cardinal (cf. gate vert 30/06) : le VLM ne fournit QUE des ids de tokens OCR
(value_ids) ; la valeur est reconstruite côté Python depuis l'OCR. Aucun texte produit
par le VLM ne doit pouvoir entrer dans une valeur -> 0 hallucination par construction.
"""
import pytest
from core.extraction.role_mapper import (
MappedField,
OcrToken,
assess_quality,
build_role_prompt,
map_roles,
reconstruct_fields,
tokens_from_grid,
)
def _tok(tid, text, conf=0.9, bbox=(0, 0, 10, 10)):
return OcrToken(id=tid, text=text, confidence=conf, bbox=bbox)
def test_reconstruit_value_concatene_tokens_dans_lordre():
tokens = [_tok(0, "DUPONT"), _tok(1, "Jean")]
fields = reconstruct_fields(tokens, [{"label": "Nom complet", "value_ids": [0, 1]}])
assert len(fields) == 1
assert fields[0].label == "Nom complet"
assert fields[0].value == "DUPONT Jean"
assert fields[0].anchored is True
def test_ignore_les_ids_hors_plage_et_les_liste():
tokens = [_tok(0, "DUPONT")]
fields = reconstruct_fields(tokens, [{"label": "Nom", "value_ids": [0, 99]}])
assert fields[0].value == "DUPONT"
assert fields[0].invalid_ids == [99]
assert fields[0].anchored is True
def test_value_ids_vide_donne_champ_non_ancre():
tokens = [_tok(0, "DUPONT")]
fields = reconstruct_fields(tokens, [{"label": "Poids", "value_ids": []}])
assert fields[0].value == ""
assert fields[0].anchored is False
def test_aucun_id_valide_donne_champ_non_ancre():
tokens = [_tok(0, "DUPONT")]
fields = reconstruct_fields(tokens, [{"label": "Poids", "value_ids": [7, 8]}])
assert fields[0].anchored is False
assert fields[0].value == ""
assert fields[0].invalid_ids == [7, 8]
def test_dedup_ids_en_preservant_lordre():
tokens = [_tok(0, "DUPONT"), _tok(1, "Jean")]
fields = reconstruct_fields(tokens, [{"label": "X", "value_ids": [1, 1, 0]}])
assert fields[0].value == "Jean DUPONT"
assert fields[0].value_ids == [1, 0]
def test_confidence_est_le_min_des_tokens_ancres():
tokens = [_tok(0, "A", conf=0.95), _tok(1, "B", conf=0.70)]
fields = reconstruct_fields(tokens, [{"label": "X", "value_ids": [0, 1]}])
assert fields[0].confidence == pytest.approx(0.70)
def test_bbox_englobante_des_tokens_ancres():
tokens = [_tok(0, "A", bbox=(0, 0, 10, 10)), _tok(1, "B", bbox=(20, 5, 40, 15))]
fields = reconstruct_fields(tokens, [{"label": "X", "value_ids": [0, 1]}])
assert fields[0].bbox == (0, 0, 40, 15)
def test_invariant_aucun_texte_hors_ocr():
# 'value' fournie par le VLM est ignorée : seul value_ids compte.
tokens = [_tok(0, "DUPONT")]
fields = reconstruct_fields(
tokens, [{"label": "Nom", "value_ids": [0], "value": "HALLUCINATION"}]
)
assert fields[0].value == "DUPONT"
def test_tokens_from_grid_indexe_et_normalise_bbox():
# grille extract_grid_from_image : bbox = 4 points EasyOCR
grid = [
[
{"text": "Nom", "bbox": [[0, 0], [10, 0], [10, 8], [0, 8]],
"confidence": 0.9, "row": 0, "col": 0},
{"text": "DUPONT", "bbox": [[20, 0], [60, 0], [60, 8], [20, 8]],
"confidence": 0.95, "row": 0, "col": 1},
],
]
tokens = tokens_from_grid(grid)
assert [t.id for t in tokens] == [0, 1]
assert tokens[0].text == "Nom"
assert tokens[1].bbox == (20, 0, 60, 8)
# --- map_roles : orchestrateur (client VLM injectable, donc testable hors-ligne) ---
def _fake_client(response, capture=None):
"""Faux client VLM : enregistre éventuellement le prompt reçu, renvoie une réponse fixe."""
def client(image_path, prompt):
if capture is not None:
capture["prompt"] = prompt
capture["image_path"] = image_path
return response
return client
def test_map_roles_reconstruit_via_client_injecte():
tokens = [_tok(0, "DUPONT"), _tok(1, "Jean")]
client = _fake_client('{"champs":[{"label":"Nom complet","value_ids":[0,1]}]}')
fields = map_roles("img.png", tokens, client)
assert len(fields) == 1
assert fields[0].label == "Nom complet"
assert fields[0].value == "DUPONT Jean"
def test_map_roles_tolere_les_fences_json():
tokens = [_tok(0, "DUPONT")]
client = _fake_client('```json\n{"champs":[{"label":"Nom","value_ids":[0]}]}\n```')
fields = map_roles("img.png", tokens, client)
assert fields[0].value == "DUPONT"
def test_map_roles_json_invalide_retourne_liste_vide():
# robustesse batch : une réponse VLM non-JSON ne doit pas crasher.
tokens = [_tok(0, "DUPONT")]
client = _fake_client("désolé, je n'ai pas compris")
fields = map_roles("img.png", tokens, client)
assert fields == []
def test_build_role_prompt_inclut_les_tokens_avec_ids():
tokens = [_tok(0, "Poids"), _tok(1, "72")]
prompt = build_role_prompt(tokens)
assert "Poids" in prompt and "72" in prompt
assert "value_ids" in prompt # on demande bien des ids, pas du texte recopié
def test_build_role_prompt_guide_liste_les_roles_attendus():
tokens = [_tok(0, "X")]
prompt = build_role_prompt(tokens, roles=["Nom", "IPP", "Poids"])
assert "Nom" in prompt and "IPP" in prompt and "Poids" in prompt
def test_map_roles_passe_les_roles_au_prompt():
tokens = [_tok(0, "X")]
cap = {}
client = _fake_client('{"champs":[]}', capture=cap)
map_roles("img.png", tokens, client, roles=["Diagnostic", "GEMSA"])
assert "Diagnostic" in cap["prompt"] and "GEMSA" in cap["prompt"]
# ---------------------------------------------------------------------------
# assess_quality — évaluation de la qualité d'extraction d'un dossier
# ---------------------------------------------------------------------------
def _field(label, value="val", anchored=True, confidence=0.9, value_ids=None, invalid_ids=None):
"""Helper : construit un MappedField directement (sans passer par OCR/VLM)."""
return MappedField(
label=label,
value=value if anchored else "",
value_ids=value_ids or ([0] if anchored else []),
confidence=confidence,
bbox=(0, 0, 10, 10) if anchored else None,
anchored=anchored,
invalid_ids=invalid_ids or [],
)
# --- failed ---
def test_assess_quality_failed_aucun_champ():
"""Liste vide → failed."""
assert assess_quality([]) == "failed"
def test_assess_quality_failed_aucun_champ_ancre():
"""Tous non ancrés → failed."""
fields = [_field("Nom", anchored=False), _field("IPP", anchored=False)]
assert assess_quality(fields) == "failed"
def test_assess_quality_failed_un_champ_value_vide():
"""Un seul champ, anchored=False, value vide → failed."""
fields = [_field("Nom", anchored=False, value_ids=[])]
assert assess_quality(fields) == "failed"
# --- needs_review ---
def test_assess_quality_needs_review_role_requis_absent():
"""Un rôle requis n'est pas dans fields → needs_review."""
fields = [_field("Nom", anchored=True)]
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "needs_review"
def test_assess_quality_needs_review_role_requis_non_ancre():
"""Rôle requis présent mais anchored=False → needs_review."""
fields = [_field("Nom", anchored=True), _field("IPP", anchored=False)]
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "needs_review"
def test_assess_quality_needs_review_matching_insensible_casse():
"""Matching label ↔ required_role insensible à la casse."""
fields = [_field("nom complet", anchored=True), _field("ipp", anchored=True)]
# required_roles en maj : doit quand même matcher
assert assess_quality(fields, required_roles=["Nom Complet", "IPP"]) != "needs_review"
def test_assess_quality_needs_review_matching_insensible_espaces():
"""Matching insensible aux espaces en trop (strip)."""
fields = [_field(" Nom ", anchored=True)]
assert assess_quality(fields, required_roles=["Nom"]) != "needs_review"
def test_assess_quality_needs_review_priorite_sur_partial():
"""needs_review > partial : role manquant + confidence basse → needs_review."""
fields = [
_field("Nom", anchored=True, confidence=0.4), # basse
# "IPP" absent → needs_review
]
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "needs_review"
# --- partial ---
def test_assess_quality_partial_confidence_basse():
"""Tous requis ancrés mais un champ ancré a confidence < min_confidence → partial."""
fields = [
_field("Nom", anchored=True, confidence=0.9),
_field("IPP", anchored=True, confidence=0.4), # < 0.6
]
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "partial"
def test_assess_quality_partial_champs_non_ancres_en_surplus():
"""Tous requis ancrés, confidence ok, mais il y a des champs non ancrés en plus → partial."""
fields = [
_field("Nom", anchored=True, confidence=0.9),
_field("Inconnu", anchored=False), # non ancré hors requis
]
assert assess_quality(fields, required_roles=["Nom"]) == "partial"
def test_assess_quality_partial_sans_required_roles_confidence_basse():
"""Sans required_roles, un champ ancré à confidence basse → partial."""
fields = [
_field("Nom", anchored=True, confidence=0.9),
_field("IPP", anchored=True, confidence=0.3),
]
assert assess_quality(fields) == "partial"
def test_assess_quality_partial_sans_required_roles_champ_non_ancre():
"""Sans required_roles, au moins un champ non ancré → partial."""
fields = [
_field("Nom", anchored=True, confidence=0.9),
_field("IPP", anchored=False),
]
assert assess_quality(fields) == "partial"
# --- complete ---
def test_assess_quality_complete_tous_requis_ancres_confidence_ok():
"""Tous requis ancrés, toutes confidences >= 0.6, aucun non ancré → complete."""
fields = [
_field("Nom", anchored=True, confidence=0.9),
_field("IPP", anchored=True, confidence=0.7),
]
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "complete"
def test_assess_quality_complete_sans_required_roles():
"""Sans required_roles, au moins un champ ancré, tous >= min_confidence, aucun non ancré → complete."""
fields = [
_field("Nom", anchored=True, confidence=0.8),
_field("IPP", anchored=True, confidence=0.95),
]
assert assess_quality(fields) == "complete"
def test_assess_quality_complete_seuil_exactement_min_confidence():
"""Confidence exactement égale à min_confidence (0.6) → complete (borne incluse)."""
fields = [_field("Nom", anchored=True, confidence=0.6)]
assert assess_quality(fields, required_roles=["Nom"]) == "complete"
def test_assess_quality_complete_min_confidence_personnalise():
"""Seuil personnalisé : confidence=0.7 >= min_confidence=0.7 → complete."""
fields = [_field("Nom", anchored=True, confidence=0.7)]
assert assess_quality(fields, min_confidence=0.7) == "complete"

View File

@@ -0,0 +1,163 @@
"""Tests TDD de sanitize_log_entries — assainissement PII des logs Léa reçus côté serveur.
Branche feat/push-log-dgx. N'importe QUE pii_sanitizer (pas api_stream, DETTE-013).
"""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
# ---------------------------------------------------------------------------
# 1. message avec PII → brut absent, tokens présents
# ---------------------------------------------------------------------------
def test_message_pii_tokenise():
"""Un nom clinique + numéro long disparaissent ; des tokens [...] les remplacent.
Couche 1 (regex, sans NER) : détecte le format « Prénom NOM » (RE_PRENOM_NOM)
et l'IPP structuré (RE_IPP). Le format inverse « NOM Prénom » relève de la
couche 2 NER — hors scope ici.
"""
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
entries = [
{
"ts": "2026-06-30T10:00:00Z",
"level": "INFO",
"logger": "lea.replay",
"message": "Ouverture dossier Catherine MOREL IPP: 295841",
}
]
result = sanitize_log_entries(entries)
assert len(result) == 1
msg = result[0]["message"]
assert "MOREL" not in msg, f"NOM toujours présent : {msg!r}"
assert "Catherine" not in msg, f"Prénom toujours présent : {msg!r}"
assert "295841" not in msg, f"IPP toujours présent : {msg!r}"
assert "[" in msg, f"Aucun token dans : {msg!r}"
# ---------------------------------------------------------------------------
# 2. ts / level préservés à l'identique
# ---------------------------------------------------------------------------
def test_ts_level_preserves():
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
entries = [
{"ts": "2026-06-30T10:00:00Z", "level": "WARNING",
"logger": "lea.core", "message": "simple message sans pii"}
]
result = sanitize_log_entries(entries)
assert result[0]["ts"] == "2026-06-30T10:00:00Z"
assert result[0]["level"] == "WARNING"
# ---------------------------------------------------------------------------
# 3. liste vide → liste vide
# ---------------------------------------------------------------------------
def test_liste_vide():
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
assert sanitize_log_entries([]) == []
# ---------------------------------------------------------------------------
# 4. entrée sans clé `message` → pas de crash, entrée conservée
# ---------------------------------------------------------------------------
def test_entree_sans_message():
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
entries = [{"ts": "2026-06-30T10:00:01Z", "level": "DEBUG", "logger": "lea.init"}]
result = sanitize_log_entries(entries)
assert len(result) == 1
assert "message" not in result[0] # champ absent → reste absent
# ---------------------------------------------------------------------------
# 5. cohérence : même PII dans 2 entrées → même token (mapping partagé)
# ---------------------------------------------------------------------------
def test_coherence_mapping_partage():
"""La même PII dans deux messages du batch reçoit le même token."""
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
entries = [
{"ts": "T1", "level": "INFO", "logger": "l", "message": "IPP: 295841 reçu"},
{"ts": "T2", "level": "INFO", "logger": "l", "message": "Relance IPP: 295841"},
]
result = sanitize_log_entries(entries)
msg1 = result[0]["message"]
msg2 = result[1]["message"]
# le brut est absent des deux
assert "295841" not in msg1
assert "295841" not in msg2
# le token est identique (mapping partagé)
import re
tokens1 = re.findall(r"\[IPP_\d+\]", msg1)
tokens2 = re.findall(r"\[IPP_\d+\]", msg2)
assert tokens1, f"Pas de token IPP dans msg1 : {msg1!r}"
assert tokens2, f"Pas de token IPP dans msg2 : {msg2!r}"
assert tokens1[0] == tokens2[0], (
f"Tokens différents pour la même PII : {tokens1[0]} vs {tokens2[0]}"
)
# ---------------------------------------------------------------------------
# 6. `message` non-str → skip proprement, pas de crash
# ---------------------------------------------------------------------------
def test_message_non_str():
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
entries = [
{"ts": "T1", "level": "INFO", "logger": "l", "message": None},
{"ts": "T2", "level": "INFO", "logger": "l", "message": 42},
{"ts": "T3", "level": "INFO", "logger": "l", "message": ["liste"]},
]
result = sanitize_log_entries(entries)
assert len(result) == 3
# les valeurs non-str sont préservées telles quelles
assert result[0]["message"] is None
assert result[1]["message"] == 42
assert result[2]["message"] == ["liste"]
# ---------------------------------------------------------------------------
# 7. champ `logger` str est aussi assaini si porteur de PII
# ---------------------------------------------------------------------------
def test_logger_pii_tokenise():
"""Si le champ logger contient de la PII (ex. chemin patient), il est assaini."""
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
entries = [
{
"ts": "T1",
"level": "INFO",
"logger": "lea.patient.MOREL_Catherine",
"message": "step start",
}
]
result = sanitize_log_entries(entries)
logger_out = result[0]["logger"]
# Le NOM doit être tokenisé (RE_PRENOM_NOM captera « Catherine MOREL » …
# mais « MOREL_Catherine » n'est pas le format clinique standard — le test
# vérifie surtout qu'il n'y a pas de crash et que le champ est traité.)
# On ne fixe pas d'assertion sur la valeur : juste pas de crash.
assert isinstance(logger_out, str)

View File

@@ -0,0 +1,68 @@
"""Non-régression sécurité : câblage PII au chokepoint ``stream_event``.
Invariant : un event contenant de la PII patient (titre de fenêtre + contenu
saisi) passé à ``stream_event`` ne doit JAMAIS écrire la PII brute dans le
journal ``live_events.jsonl``, ni la propager au worker ou au shadow observer.
L'assainissement a lieu une seule fois, en amont des chemins de
persistance/traitement (``api_stream.py``, hook ``sanitize_event``).
"""
import asyncio
import json
import os
# Le module serveur refuse de se charger sans token (sécurité prod) ;
# en test unitaire on désactive l'auth pour pouvoir importer le module.
os.environ.setdefault("RPA_AUTH_DISABLED", "true")
import agent_v0.server_v1.api_stream as api
def _event_avec_pii():
# PII captée par la couche 1 : IPP (structurel) + contenu saisi.
# Contexte = logiciel métier réel du POC (pas la maquette Easily abandonnée).
# (Les noms libres sans marqueur relèvent de la couche 2 NER — hors scope ici.)
return {
"type": "text_input",
"text": "anticoagulant 75mg matin",
"active_window_title": "Gxd5diag - Recherche dossier (IPP: 123456)",
}
def test_stream_event_assainit_et_propage_sur_les_chemins(tmp_path, monkeypatch):
"""Le chokepoint applique sanitize_event UNE fois et tous les chemins
(jsonl, worker, shadow) reçoivent la copie assainie — pas la valeur brute."""
captured = {}
monkeypatch.setattr(api, "_ensure_session_registered", lambda *a, **k: None)
monkeypatch.setattr(
api.worker,
"process_event_direct",
lambda sid, ev: (captured.__setitem__("worker", ev), {})[1],
)
monkeypatch.setattr(
api, "shadow_observe_event", lambda sid, ev: captured.__setitem__("shadow", ev)
)
monkeypatch.setattr(api, "LIVE_SESSIONS_DIR", tmp_path)
api._session_pii_mapping.pop("sess_pii", None)
se = api.StreamEvent(
session_id="sess_pii",
machine_id="lea-test",
timestamp=1000.0,
event=_event_avec_pii(),
)
asyncio.run(api.stream_event(se))
# 1. le journal sur disque ne contient ni l'IPP brut ni le contenu saisi
jsonl = (tmp_path / "lea-test" / "sess_pii" / "live_events.jsonl").read_text(
encoding="utf-8"
)
assert "123456" not in jsonl
assert "anticoagulant 75mg" not in jsonl
# 2. contenu saisi masqué + IPP tokenisé (preuve que le titre est traité)
assert "[SAISIE]" in jsonl
assert "[IPP_1]" in jsonl
# 3. worker et shadow reçoivent l'event assaini, pas la valeur brute
assert captured["worker"]["text"] == "[SAISIE]"
assert "123456" not in json.dumps(captured["worker"], ensure_ascii=False)
assert "123456" not in json.dumps(captured["shadow"], ensure_ascii=False)

View File

@@ -0,0 +1,101 @@
"""TDD — signature de trajectoire (Phase 0 ; primitive partagée SP-4 / SP-2 / compétences).
Propriété centrale : la signature identifie une TRAJECTOIRE (séquence d'actions sur des
cibles stables). Elle doit être **stable entre sessions** — donc indépendante des champs
session-spécifiques (IDs de nœuds, timestamps, coordonnées). C'est ce qui rend le
create-or-update (décision F1) possible : deux apprentissages du même parcours = même id.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from core.execution.trajectory_signature import trajectory_signature
def test_deterministic_same_sequence():
steps = [
{"action_type": "mouse_click", "target": "menu Fichier"},
{"action_type": "text_input", "target": "champ recherche"},
]
assert trajectory_signature(steps) == trajectory_signature(steps)
def test_ignores_session_specific_fields():
"""Deux sessions du MÊME parcours (mêmes action_type+target) mais IDs de nœuds /
timestamps / coords différents → MÊME signature."""
session_a = [
{"action_type": "mouse_click", "target": "menu Fichier",
"node_id": "n_abc", "timestamp": 1000, "x": 12, "y": 34},
{"action_type": "text_input", "target": "champ recherche",
"node_id": "n_def", "timestamp": 1100, "x": 50, "y": 60},
]
session_b = [
{"action_type": "mouse_click", "target": "menu Fichier",
"node_id": "n_zzz", "timestamp": 9000, "x": 99, "y": 88},
{"action_type": "text_input", "target": "champ recherche",
"node_id": "n_yyy", "timestamp": 9100, "x": 11, "y": 22},
]
assert trajectory_signature(session_a) == trajectory_signature(session_b)
def test_order_sensitive():
a = [{"action_type": "mouse_click", "target": "A"},
{"action_type": "text_input", "target": "B"}]
b = list(reversed(a))
assert trajectory_signature(a) != trajectory_signature(b)
def test_target_discriminates():
a = [{"action_type": "mouse_click", "target": "bouton Valider"}]
b = [{"action_type": "mouse_click", "target": "bouton Annuler"}]
assert trajectory_signature(a) != trajectory_signature(b)
def test_returns_sha256_hex():
sig = trajectory_signature([{"action_type": "mouse_click", "target": "x"}])
assert len(sig) == 64
assert all(c in "0123456789abcdef" for c in sig)
# ---------------------------------------------------------------------------
# R1/R2 amendés — verdict Qwen 2026-06-25 : normalisation déterministe + PII
# neutralisée par regex DÉDIÉES (pas de pii_blur, pas de NER). Stabilité
# labo/DGX = portabilité de la signature. Noms sans titre : stratégie (b)
# (impact 0 en labo, gate = audit agrégat DGX avant prod).
# ---------------------------------------------------------------------------
def test_target_normalized_case_and_accents():
"""Q2 : casse et accents ne changent pas la signature (même cible sémantique)."""
a = [{"action_type": "mouse_click", "target": "Valider"}]
b = [{"action_type": "mouse_click", "target": "VALIDER"}]
c = [{"action_type": "mouse_click", "target": "validér"}]
assert trajectory_signature(a) == trajectory_signature(b) == trajectory_signature(c)
def test_pii_ipp_neutralized():
"""R1 : deux IPP différents sur le même champ → MÊME signature (PII neutralisée).
Et une cible sans identifiant reste discriminée."""
a = [{"action_type": "mouse_click", "target": "Patient IPP 25012257"}]
b = [{"action_type": "mouse_click", "target": "Patient IPP 30045678"}]
assert trajectory_signature(a) == trajectory_signature(b)
c = [{"action_type": "mouse_click", "target": "Patient liste"}]
assert trajectory_signature(a) != trajectory_signature(c)
def test_pii_date_neutralized():
"""R1 : deux dates différentes → MÊME signature."""
a = [{"action_type": "mouse_click", "target": "RDV du 12/05/2026"}]
b = [{"action_type": "mouse_click", "target": "RDV du 03/11/2025"}]
assert trajectory_signature(a) == trajectory_signature(b)
def test_pii_phone_and_email_neutralized():
"""R1 : téléphone (FR) et email neutralisés (deux valeurs distinctes → même sig)."""
tel_a = [{"action_type": "text_input", "target": "tel 06 12 34 56 78"}]
tel_b = [{"action_type": "text_input", "target": "tel 07 98 76 54 32"}]
assert trajectory_signature(tel_a) == trajectory_signature(tel_b)
mail_a = [{"action_type": "text_input", "target": "mail jean.dupont@chu.fr"}]
mail_b = [{"action_type": "text_input", "target": "mail m.martin@chu.fr"}]
assert trajectory_signature(mail_a) == trajectory_signature(mail_b)

View File

@@ -0,0 +1,135 @@
"""TDD — DETTE-022 MAJ silencieuse v2 : logique PURE serveur de décision d'update.
Périmètre testé ICI = parties PURES, testables sans démarrer le serveur
(DETTE-013 : on N'IMPORTE PAS `api_stream` — on charge le module
`update_check.py` par chemin, comme test_agent_v1_log_shipper).
Couvre :
- R3 `parse_version()` : tuple d'entiers, "1.0.2" < "1.0.10", égalité,
"v1.2.3"/espaces tolérés, format invalide → fallback sans crash.
- R2 logique de décision PURE `decide_update()` : compare version courante
vs dernière dispo, choisit `update_type` (code-only/full), construit la
réponse `{update_available, latest_version, update_type, url}`.
Le NOYAU dangereux (swap fichiers / Lea.bat / restart) est HORS périmètre.
"""
import importlib.util
from pathlib import Path
import pytest
_MOD_PATH = (
Path(__file__).resolve().parents[2]
/ "agent_v0" / "server_v1" / "update_check.py"
)
def _load_module():
spec = importlib.util.spec_from_file_location("rpa_update_check", _MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def mod():
return _load_module()
# ---------------------------------------------------------------------------
# R3 — parse_version : tuple d'entiers (semver), pas comparaison lexicale
# ---------------------------------------------------------------------------
class TestParseVersion:
def test_parse_basique(self, mod):
assert mod.parse_version("1.0.2") == (1, 0, 2)
assert mod.parse_version("1.0.10") == (1, 0, 10)
def test_ordre_semver_pas_lexical(self, mod):
# Le bug classique : "1.0.2" < "1.0.10" est FAUX en lexicographique.
assert mod.parse_version("1.0.2") < mod.parse_version("1.0.10")
assert mod.parse_version("1.0.10") > mod.parse_version("1.0.2")
assert mod.parse_version("2.0.0") > mod.parse_version("1.99.99")
def test_egalite(self, mod):
assert mod.parse_version("1.0.1") == mod.parse_version("1.0.1")
def test_prefixe_v_et_espaces_toleres(self, mod):
assert mod.parse_version("v1.2.3") == mod.parse_version("1.2.3")
assert mod.parse_version(" 1.2.3 ") == (1, 2, 3)
assert mod.parse_version("V1.2.3") == (1, 2, 3)
def test_format_invalide_fallback_sans_crash(self, mod):
# Ne doit jamais lever — fallback (0,) (= la plus basse).
assert mod.parse_version("") == (0,)
assert mod.parse_version("abc") == (0,)
assert mod.parse_version(None) == (0,)
assert mod.parse_version("1.x.3") == (0,)
# Une version valide reste toujours > au fallback invalide.
assert mod.parse_version("0.0.1") > mod.parse_version("garbage")
def test_is_newer_helper(self, mod):
assert mod.is_newer("1.0.2", "1.0.1") is True
assert mod.is_newer("1.0.10", "1.0.2") is True
assert mod.is_newer("1.0.1", "1.0.1") is False
assert mod.is_newer("1.0.0", "1.0.1") is False
# ---------------------------------------------------------------------------
# R2 — decide_update : logique PURE de décision serveur
# ---------------------------------------------------------------------------
class TestDecideUpdate:
def test_pas_de_maj_si_a_jour(self, mod):
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.1")
assert resp["update_available"] is False
assert resp["latest_version"] == "1.0.1"
assert resp["update_type"] is None
assert resp["url"] is None
def test_pas_de_maj_si_client_plus_recent(self, mod):
# Client en avance (dev local) → jamais de downgrade.
resp = mod.decide_update(current_version="1.0.5", latest_version="1.0.2")
assert resp["update_available"] is False
def test_maj_disponible_code_only_par_defaut(self, mod):
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.2")
assert resp["update_available"] is True
assert resp["latest_version"] == "1.0.2"
# R2 : code-only = défaut (99% des cas, ~500 Ko).
assert resp["update_type"] == "code-only"
assert "1.0.2" in resp["url"]
assert "code-only" in resp["url"]
def test_maj_full_si_demande(self, mod):
resp = mod.decide_update(
current_version="1.0.1", latest_version="1.1.0", update_type="full"
)
assert resp["update_available"] is True
assert resp["update_type"] == "full"
assert "full" in resp["url"]
def test_update_type_invalide_retombe_sur_code_only(self, mod):
resp = mod.decide_update(
current_version="1.0.1", latest_version="1.0.2", update_type="banana"
)
assert resp["update_type"] == "code-only"
def test_ordre_semver_dans_decision(self, mod):
# 1.0.2 < 1.0.10 → MAJ dispo (pas de faux négatif lexical).
resp = mod.decide_update(current_version="1.0.2", latest_version="1.0.10")
assert resp["update_available"] is True
def test_url_inclut_machine_id_si_fourni(self, mod):
resp = mod.decide_update(
current_version="1.0.1", latest_version="1.0.2", machine_id="pc-7"
)
assert "pc-7" in resp["url"]
def test_versions_invalides_pas_de_crash_pas_de_maj(self, mod):
# latest illisible → on ne propose RIEN (prudence : pas de MAJ douteuse).
resp = mod.decide_update(current_version="1.0.1", latest_version="garbage")
assert resp["update_available"] is False
resp2 = mod.decide_update(current_version="", latest_version="")
assert resp2["update_available"] is False

View File

@@ -0,0 +1,162 @@
"""TDD — DETTE-022 v2 : CANARY server-side pour la MAJ silencieuse Léa.
Périmètre testé ICI = logique PURE de la POLITIQUE de déploiement canary,
testable sans démarrer le serveur (DETTE-013 : on N'IMPORTE PAS `api_stream`
— on charge `update_policy.py` par chemin, comme test_update_check_server).
Objectif SÉCURITÉ (10+ postes cliniques live) : une MAJ ne doit JAMAIS
partir sur toute la flotte d'un coup. Le canary résout la version cible
*par machine* :
- un poste dans la liste canary reçoit la version `canary` (Émilie d'abord) ;
- tous les autres restent sur la version `stable` (floor) tant que le canary
n'est pas promu.
`resolve_target_version(machine_id, ...)` est la brique PURE ; `decide_update`
côté serveur l'appelle pour choisir la version cible avant de comparer.
Le NOYAU dangereux (swap fichiers / Lea.bat / restart) reste HORS périmètre.
"""
import importlib.util
from pathlib import Path
import pytest
_MOD_PATH = (
Path(__file__).resolve().parents[2]
/ "agent_v0" / "server_v1" / "update_policy.py"
)
def _load_module():
spec = importlib.util.spec_from_file_location("rpa_update_policy", _MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def mod():
return _load_module()
# ---------------------------------------------------------------------------
# parse_canary_machines — liste d'allow-list (CSV / espaces tolérés)
# ---------------------------------------------------------------------------
class TestParseCanaryMachines:
def test_liste_csv(self, mod):
assert mod.parse_canary_machines("lea-4zbgwxty") == {"lea-4zbgwxty"}
assert mod.parse_canary_machines("a,b,c") == {"a", "b", "c"}
def test_espaces_et_vides_toleres(self, mod):
assert mod.parse_canary_machines(" a , b , ") == {"a", "b"}
assert mod.parse_canary_machines("") == set()
assert mod.parse_canary_machines(None) == set()
def test_supporte_separateurs_espace_et_point_virgule(self, mod):
# Tolérant : virgule, point-virgule, espace comme séparateurs.
assert mod.parse_canary_machines("a; b c") == {"a", "b", "c"}
# ---------------------------------------------------------------------------
# resolve_target_version — LE cœur canary (sécurité)
# ---------------------------------------------------------------------------
class TestResolveTargetVersion:
def test_machine_canary_recoit_version_canary(self, mod):
# Émilie (canary) reçoit la nouvelle version en premier.
target = mod.resolve_target_version(
machine_id="lea-4zbgwxty",
stable_version="1.0.1",
canary_version="1.0.2",
canary_machines={"lea-4zbgwxty"},
)
assert target == "1.0.2"
def test_machine_hors_canary_reste_sur_stable(self, mod):
# Tous les autres postes restent sur la version stable (floor).
target = mod.resolve_target_version(
machine_id="lea-autre-poste",
stable_version="1.0.1",
canary_version="1.0.2",
canary_machines={"lea-4zbgwxty"},
)
assert target == "1.0.1"
def test_pas_de_canary_configure_tout_le_monde_stable(self, mod):
# Aucun canary défini → personne ne monte (défaut ultra-prudent).
target = mod.resolve_target_version(
machine_id="lea-4zbgwxty",
stable_version="1.0.1",
canary_version="1.0.2",
canary_machines=set(),
)
assert target == "1.0.1"
def test_canary_version_absente_retombe_sur_stable(self, mod):
# Si canary_version n'est pas fournie, même un poste canary reste stable.
target = mod.resolve_target_version(
machine_id="lea-4zbgwxty",
stable_version="1.0.1",
canary_version=None,
canary_machines={"lea-4zbgwxty"},
)
assert target == "1.0.1"
def test_machine_id_none_reste_stable(self, mod):
# machine_id inconnu / non fourni → jamais canary (prudence).
target = mod.resolve_target_version(
machine_id=None,
stable_version="1.0.1",
canary_version="1.0.2",
canary_machines={"lea-4zbgwxty"},
)
assert target == "1.0.1"
def test_canary_ne_downgrade_jamais_en_dessous_de_stable(self, mod):
# GARDE-FOU : si le canary_version est PLUS ANCIEN que stable (erreur
# de config), on NE descend PAS le poste canary — on sert stable.
target = mod.resolve_target_version(
machine_id="lea-4zbgwxty",
stable_version="1.0.5",
canary_version="1.0.2", # plus ancien → config douteuse
canary_machines={"lea-4zbgwxty"},
)
assert target == "1.0.5"
# ---------------------------------------------------------------------------
# Lecture depuis l'environnement (pilotage sans rebuild) — défauts prudents
# ---------------------------------------------------------------------------
class TestEnvPolicy:
def test_defauts_prudents_aucune_maj(self, mod, monkeypatch):
# Aucune var positionnée → stable par défaut, pas de canary.
for var in (
"RPA_AGENT_STABLE_VERSION",
"RPA_AGENT_CANARY_VERSION",
"RPA_AGENT_CANARY_MACHINES",
):
monkeypatch.delenv(var, raising=False)
assert mod.stable_version_from_env() == "1.0.1"
assert mod.canary_version_from_env() is None
assert mod.canary_machines_from_env() == set()
# Un poste quelconque reste sur stable.
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.1"
def test_canary_actif_via_env_seul_le_poste_canary_monte(self, mod, monkeypatch):
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1")
monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2")
monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty")
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2"
assert mod.resolve_target_version_from_env("autre-poste") == "1.0.1"
def test_promotion_toute_la_flotte_suit(self, mod, monkeypatch):
# Promotion : on met stable = version canary, on vide la liste canary.
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.2")
monkeypatch.delenv("RPA_AGENT_CANARY_VERSION", raising=False)
monkeypatch.delenv("RPA_AGENT_CANARY_MACHINES", raising=False)
assert mod.resolve_target_version_from_env("autre-poste") == "1.0.2"
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2"

View File

@@ -0,0 +1,336 @@
"""Tests for core/navigation/visual_login.py — login form resolution + verification."""
import json
import pytest
from core.navigation.visual_login import (
LoginFormConfig,
LoginResolution,
dpi_urgences_login_config,
verify_login_visible,
verify_login_success,
resolve_login_form,
_ocr_detailed_to_simple,
)
from core.navigation.grounding import (
CoordsCache,
GroundedElement,
OcrTokenInfo,
OcrDetailedClient,
)
from core.navigation.visual_verifier import (
ScreenMatchResult,
VlmClient,
OcrClient,
)
# ── Mock factories ─────────────────────────────────────────────────────
def mock_ocr_detailed_client_factory(tokens: list):
"""Factory for mock OcrDetailedClient."""
def client(image_path: str) -> list:
return tokens
return client
def mock_ocr_simple_client_factory(tokens: list):
"""Factory for mock OcrClient (text-only)."""
def client(image_path: str) -> list:
return tokens
return client
def mock_vlm_client_factory(response_json: dict):
"""Factory for mock VlmClient."""
def client(image_path: str, prompt: str) -> str:
return json.dumps(response_json)
return client
# ── Default config tests ───────────────────────────────────────────────
class TestDpiUrgencesLoginConfig:
def test_default_config(self):
config = dpi_urgences_login_config()
assert config.login_field["role"] == "champ"
assert config.login_field["text"] == "Login"
assert config.password_field["text"] == "Mot de passe"
assert config.submit_button["text"] == "Connexion"
assert len(config.success_elements) >= 1
assert config.context != ""
def test_config_fields_are_dicts(self):
config = dpi_urgences_login_config()
assert isinstance(config.login_field, dict)
assert isinstance(config.password_field, dict)
assert isinstance(config.submit_button, dict)
# ── _ocr_detailed_to_simple tests ────────────────────────────────────
class TestOcrDetailedToSimple:
def test_conversion(self):
tokens = [
OcrTokenInfo(text="Login", bbox=(100, 50, 200, 90)),
OcrTokenInfo(text="Password", bbox=(100, 100, 200, 140)),
]
detailed = mock_ocr_detailed_client_factory(tokens)
simple = _ocr_detailed_to_simple(detailed)
result = simple("/tmp/test.png")
assert result == ["Login", "Password"]
def test_empty_tokens(self):
detailed = mock_ocr_detailed_client_factory([])
simple = _ocr_detailed_to_simple(detailed)
result = simple("/tmp/test.png")
assert result == []
# ── verify_login_visible tests ────────────────────────────────────────
class TestVerifyLoginVisible:
def test_form_visible(self):
"""All 3 fields found by OCR + roles confirmed → match."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
context="DPI login",
)
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe", "Connexion"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
{"index": 2, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
{"index": 3, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
],
"overall_confidence": 0.9,
})
result = verify_login_visible("/tmp/login.png", config, ocr, vlm)
assert result.match == True
def test_form_missing_button(self):
"""Connexion button not found by OCR → mismatch."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
)
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe"]) # missing Connexion
vlm = mock_vlm_client_factory({})
result = verify_login_visible("/tmp/login.png", config, ocr, vlm)
assert result.match == False
def test_form_wrong_role(self):
"""OCR finds text but VLM says button is a label → mismatch."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
)
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe", "Connexion"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
{"index": 2, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
{"index": 3, "role_confirmed": False, "actual_role": "label", "confidence": 0.5},
],
"overall_confidence": 0.5,
})
result = verify_login_visible("/tmp/login.png", config, ocr, vlm)
assert result.match == False
# ── verify_login_success tests ────────────────────────────────────────
class TestVerifyLoginSuccess:
def test_dashboard_visible(self):
"""Dashboard found by OCR + role confirmed → success."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
success_elements=[{"role": "page", "text": "Dashboard"}],
)
ocr = mock_ocr_simple_client_factory(["Dashboard", "Accueil"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "page", "confidence": 0.92},
],
"overall_confidence": 0.92,
})
result = verify_login_success("/tmp/dashboard.png", config, ocr, vlm)
assert result.match == True
def test_no_success_elements(self):
"""Config has no success_elements → can't verify."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
success_elements=[], # empty!
)
ocr = mock_ocr_simple_client_factory(["Dashboard"])
vlm = mock_vlm_client_factory({})
result = verify_login_success("/tmp/page.png", config, ocr, vlm)
assert result.match == False
assert "no success_elements" in result.reason
def test_still_on_login_page(self):
"""After login, still seeing login form → mismatch."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
success_elements=[{"role": "page", "text": "Dashboard"}],
)
# OCR sees login form texts, not Dashboard
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe", "Connexion"])
vlm = mock_vlm_client_factory({})
result = verify_login_success("/tmp/still_login.png", config, ocr, vlm)
assert result.match == False
# ── resolve_login_form tests ──────────────────────────────────────────
class TestResolveLoginForm:
def test_all_fields_ocr_anchor(self):
"""All 3 fields found by OCR with bbox → full resolution."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
)
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140)),
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190)),
])
vlm = mock_vlm_client_factory({})
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
assert result.all_resolved == True
assert result.login_field is not None
assert result.login_field.method == "ocr_anchor"
assert result.password_field is not None
assert result.submit_button is not None
assert result.method == "ocr_anchor"
def test_partial_ocr_vlm_fallback(self):
"""Login + password by OCR, button by VLM → mixed method."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Password"},
submit_button={"role": "bouton", "text": "Connexion"},
)
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
OcrTokenInfo(text="Password", bbox=(100, 100, 250, 140)),
# Connexion not in OCR → VLM fallback
])
vlm = mock_vlm_client_factory({
"found": True,
"bbox": [0.2, 0.4, 0.4, 0.5],
"confidence": 0.85,
})
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
assert result.all_resolved == True
assert result.login_field.method == "ocr_anchor"
assert result.submit_button.method == "vlm_grounder"
assert result.method == "mixed"
def test_incomplete_resolution(self):
"""Button not found by OCR or VLM → incomplete."""
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Password"},
submit_button={"role": "bouton", "text": "Connexion"},
)
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
OcrTokenInfo(text="Password", bbox=(100, 100, 250, 140)),
])
vlm = mock_vlm_client_factory({"found": False, "bbox": [], "confidence": 0.0})
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
assert result.all_resolved == False
assert result.submit_button is None
def test_cache_hit(self):
"""All fields cached → returned directly."""
cache = CoordsCache()
cache.put("champ:login", (100, 50, 250, 90), (175, 70), "ocr_anchor")
cache.put("champ:mot de passe", (100, 100, 250, 140), (175, 120), "ocr_anchor")
cache.put("bouton:connexion", (100, 150, 250, 190), (175, 170), "ocr_anchor")
config = LoginFormConfig(
login_field={"role": "champ", "text": "Login"},
password_field={"role": "champ", "text": "Mot de passe"},
submit_button={"role": "bouton", "text": "Connexion"},
)
ocr = mock_ocr_detailed_client_factory([])
vlm = mock_vlm_client_factory({})
result = resolve_login_form(
"/tmp/login.png", config, ocr, vlm, coords_cache=cache,
)
assert result.all_resolved == True
assert result.method == "cache"
assert result.login_field.center == (175, 70)
def test_with_dpi_default_config(self):
"""Full flow with dpi_urgences_login_config."""
config = dpi_urgences_login_config()
ocr = mock_ocr_detailed_client_factory([
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140)),
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190)),
])
vlm = mock_vlm_client_factory({})
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
assert result.all_resolved == True
# ── LoginResolution describe tests ────────────────────────────────────
class TestLoginResolutionDescribe:
def test_all_resolved(self):
resolution = LoginResolution(
login_field=GroundedElement(
role="champ", text="Login",
bbox=(100, 50, 250, 90), center=(175, 70),
confidence=0.9, method="ocr_anchor",
),
password_field=GroundedElement(
role="champ", text="Mot de passe",
bbox=(100, 100, 250, 140), center=(175, 120),
confidence=0.9, method="ocr_anchor",
),
submit_button=GroundedElement(
role="bouton", text="Connexion",
bbox=(100, 150, 250, 190), center=(175, 170),
confidence=0.9, method="ocr_anchor",
),
all_resolved=True,
method="ocr_anchor",
)
desc = resolution.describe()
assert "OK" in desc
assert "login@" in desc
assert "button@" in desc
def test_incomplete(self):
resolution = LoginResolution(
login_field=None,
password_field=None,
submit_button=None,
all_resolved=False,
method="",
)
desc = resolution.describe()
assert "INCOMPLETE" in desc
assert "NOT FOUND" in desc

View File

@@ -0,0 +1,490 @@
"""Tests for core/navigation/visual_verifier.py — OCR-anchored architecture.
Tests pure functions (normalize_text, fuzzy_match, ocr_presence_check,
build_role_confirm_prompt, parse_role_confirm_response) offline,
then verifies verify_screen_match with mock OcrClient + VlmClient.
"""
import json
import pytest
from core.navigation.visual_verifier import (
normalize_text,
fuzzy_match,
ocr_presence_check,
build_role_confirm_prompt,
parse_role_confirm_response,
verify_screen_match,
verify_before,
verify_after,
ScreenMatchResult,
OcrPresenceResult,
)
# ── Mock factories ─────────────────────────────────────────────────────
def mock_ocr_client_factory(tokens: list):
"""Factory that creates a mock OcrClient returning the given tokens."""
def client(image_path: str) -> list:
return tokens
return client
def mock_vlm_client_factory(response_json: dict):
"""Factory that creates a mock VlmClient returning the given JSON."""
def client(image_path: str, prompt: str) -> str:
return json.dumps(response_json)
return client
# ── normalize_text tests ──────────────────────────────────────────────
class TestNormalizeText:
def test_lowercase(self):
assert normalize_text("RECHERCHER") == "rechercher"
def test_strip_accents(self):
assert normalize_text("Recherché") == "recherche"
def test_collapse_whitespace(self):
assert normalize_text(" hello world ") == "hello world"
def test_combined(self):
assert normalize_text(" Nom Prénom ") == "nom prenom"
def test_empty(self):
assert normalize_text("") == ""
def test_numbers_preserved(self):
assert normalize_text("IPP 12345") == "ipp 12345"
# ── fuzzy_match tests ─────────────────────────────────────────────────
class TestFuzzyMatch:
def test_exact_match(self):
assert fuzzy_match("Rechercher", "Rechercher") == True
def test_case_insensitive(self):
assert fuzzy_match("rechercher", "RECHERCHER") == True
def test_accent_match(self):
assert fuzzy_match("Recherché", "Recherche") == True
def test_substring_containment(self):
# Short text contained in longer OCR token
assert fuzzy_match("Rechercher", "Bouton Rechercher") == True
def test_reverse_containment(self):
# OCR token contained in expected text
assert fuzzy_match("Nom Prénom Patient", "Nom") == True
def test_fuzzy_ratio(self):
# Similar but not exact/substring — ratio ~0.90
assert fuzzy_match("Connexion", "Connection", threshold=0.8) == True
def test_no_match(self):
assert fuzzy_match("Dashboard", "Login", threshold=0.8) == False
def test_custom_threshold(self):
# "Connection" vs "Connexion" ratio ~0.90, passes at 0.8 but fails at 0.95
assert fuzzy_match("Connexion", "Connection", threshold=0.95) == False
# ── ocr_presence_check tests ──────────────────────────────────────────
class TestOcrPresenceCheck:
def test_all_found(self):
tokens = ["Rechercher", "Connexion", "Nom Patient"]
elements = [
{"role": "bouton", "text": "Rechercher"},
{"role": "bouton", "text": "Connexion"},
]
result = ocr_presence_check(tokens, elements)
assert result.all_found == True
assert result.presence_ratio == 1.0
assert len(result.missing) == 0
assert result.found_texts["Rechercher"] == "Rechercher"
def test_partial_found(self):
tokens = ["Rechercher"]
elements = [
{"role": "bouton", "text": "Rechercher"},
{"role": "bouton", "text": "Connexion"},
]
result = ocr_presence_check(tokens, elements)
assert result.all_found == False
assert result.presence_ratio == 0.5
assert "bouton: Connexion" in result.missing
def test_none_found(self):
tokens = ["Accueil", "Paramètres"]
elements = [
{"role": "bouton", "text": "Rechercher"},
]
result = ocr_presence_check(tokens, elements)
assert result.all_found == False
assert result.presence_ratio == 0.0
assert "bouton: Rechercher" in result.missing
def test_fuzzy_match_in_presence(self):
tokens = ["Rechércher"] # OCR with accent variation
elements = [{"role": "bouton", "text": "Rechercher"}]
result = ocr_presence_check(tokens, elements)
assert result.all_found == True
def test_empty_tokens(self):
result = ocr_presence_check([], [{"role": "bouton", "text": "Login"}])
assert result.all_found == False
assert result.presence_ratio == 0.0
def test_empty_elements(self):
result = ocr_presence_check(["Login", "Password"], [])
assert result.all_found == True
assert result.presence_ratio == 1.0
def test_no_text_key(self):
elements = [{"role": "page"}] # no text key
result = ocr_presence_check(["Dashboard"], elements)
assert result.all_found == True # no text to check → trivially found
def test_multiple_elements_same_text(self):
tokens = ["Connexion"]
elements = [
{"role": "bouton", "text": "Connexion"},
{"role": "label", "text": "Connexion"},
]
result = ocr_presence_check(tokens, elements)
assert result.all_found == True
# ── build_role_confirm_prompt tests ───────────────────────────────────
class TestBuildRoleConfirmPrompt:
def test_basic_prompt(self):
found = [
{"text": "Rechercher", "expected_role": "bouton", "matched_ocr": "Rechercher"},
]
expected = [{"role": "bouton", "text": "Rechercher"}]
prompt = build_role_confirm_prompt(found, expected)
assert "Text \"Rechercher\"" in prompt
assert "expected role: bouton" in prompt
assert "role_confirmed" in prompt
def test_with_context(self):
found = [
{"text": "Connexion", "expected_role": "bouton", "matched_ocr": "Connexion"},
]
expected = [{"role": "bouton", "text": "Connexion"}]
prompt = build_role_confirm_prompt(found, expected, context="page login DPI")
assert "Context: page login DPI" in prompt
def test_multiple_elements(self):
found = [
{"text": "Login", "expected_role": "champ", "matched_ocr": "Login"},
{"text": "Password", "expected_role": "champ", "matched_ocr": "Password"},
{"text": "Connexion", "expected_role": "bouton", "matched_ocr": "Connexion"},
]
expected = [
{"role": "champ", "text": "Login"},
{"role": "champ", "text": "Password"},
{"role": "bouton", "text": "Connexion"},
]
prompt = build_role_confirm_prompt(found, expected)
assert "1." in prompt
assert "2." in prompt
assert "3." in prompt
def test_no_self_declaration(self):
"""Prompt must NOT ask VLM to declare presence — only role."""
found = [
{"text": "Login", "expected_role": "champ", "matched_ocr": "Login"},
]
expected = [{"role": "champ", "text": "Login"}]
prompt = build_role_confirm_prompt(found, expected)
assert "present" not in prompt.lower() or "confirmed" in prompt.lower()
# ── parse_role_confirm_response tests ─────────────────────────────────
class TestParseRoleConfirmResponse:
def test_valid_json(self):
data = json.dumps({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.92},
],
"overall_confidence": 0.92,
})
result = parse_role_confirm_response(data)
assert len(result["confirmed"]) == 1
assert result["overall_confidence"] == 0.92
def test_json_in_markdown(self):
vlm_text = "```json\n{\"confirmed\": [], \"overall_confidence\": 0.0}\n```"
result = parse_role_confirm_response(vlm_text)
assert result["overall_confidence"] == 0.0
def test_garbled_response(self):
result = parse_role_confirm_response("I cannot determine the roles")
assert result["overall_confidence"] == 0.0
assert len(result["confirmed"]) == 0
def test_confidence_as_string(self):
data = json.dumps({"confirmed": [], "overall_confidence": "0.85"})
result = parse_role_confirm_response(data)
assert result["overall_confidence"] == 0.85
# ── verify_screen_match (OCR-anchored) tests ─────────────────────────
class TestVerifyScreenMatchOcrAnchored:
def test_full_match(self):
ocr = mock_ocr_client_factory(["Rechercher", "Connexion", "Dashboard"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.92},
],
"overall_confidence": 0.92,
})
result = verify_screen_match(
"/tmp/test.png",
[{"role": "bouton", "text": "Rechercher"}],
ocr_client=ocr,
vlm_client=vlm,
)
assert result.match == True
assert result.confidence >= 0.7
def test_ocr_presence_fail(self):
"""OCR doesn't find expected text → mismatch (deterministic, no VLM needed)."""
ocr = mock_ocr_client_factory(["Accueil", "Paramètres"])
vlm = mock_vlm_client_factory({})
result = verify_screen_match(
"/tmp/test.png",
[{"role": "bouton", "text": "Rechercher"}],
ocr_client=ocr,
vlm_client=vlm,
)
assert result.match == False
assert "OCR presence" in result.reason
assert len(result.mismatches) > 0
def test_role_not_confirmed(self):
"""OCR finds text, VLM says it's a label not a button → mismatch."""
ocr = mock_ocr_client_factory(["Rechercher"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": False, "actual_role": "label", "confidence": 0.6},
],
"overall_confidence": 0.6,
})
result = verify_screen_match(
"/tmp/test.png",
[{"role": "bouton", "text": "Rechercher"}],
ocr_client=ocr,
vlm_client=vlm,
)
assert result.match == False
def test_ocr_error(self):
"""OCR engine fails → fail-safe mismatch."""
def failing_ocr(image_path):
raise RuntimeError("OCR engine down")
vlm = mock_vlm_client_factory({})
result = verify_screen_match(
"/tmp/test.png",
[{"role": "bouton", "text": "Rechercher"}],
ocr_client=failing_ocr,
vlm_client=vlm,
)
assert result.match == False
assert "OCR error" in result.reason
def test_vlm_error_partial_match(self):
"""OCR finds texts, VLM fails → partial match (presence OK, role unknown)."""
ocr = mock_ocr_client_factory(["Rechercher"])
def failing_vlm(image_path, prompt):
raise RuntimeError("VLM service down")
result = verify_screen_match(
"/tmp/test.png",
[{"role": "bouton", "text": "Rechercher"}],
ocr_client=ocr,
vlm_client=failing_vlm,
)
# Presence confirmed by OCR → partial match, confidence=0.5
assert result.match == True
assert result.confidence == 0.5
assert "VLM role confirm failed" in result.reason
def test_no_expected_elements(self):
ocr = mock_ocr_client_factory(["Login"])
vlm = mock_vlm_client_factory({})
result = verify_screen_match("/tmp/test.png", [], ocr_client=ocr, vlm_client=vlm)
assert result.match == True
assert result.confidence == 1.0
def test_describe_match(self):
result = ScreenMatchResult(match=True, confidence=0.92)
assert "OK" in result.describe()
def test_describe_mismatch(self):
result = ScreenMatchResult(
match=False, confidence=0.3,
mismatches=["bouton: Rechercher"],
)
assert "mismatch" in result.describe()
def test_multiple_elements_mixed(self):
"""2 elements: 1 found+role OK, 1 not found in OCR → mismatch."""
ocr = mock_ocr_client_factory(["Connexion"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
],
"overall_confidence": 0.9,
})
result = verify_screen_match(
"/tmp/test.png",
[
{"role": "bouton", "text": "Connexion"},
{"role": "champ", "text": "Nom Patient"},
],
ocr_client=ocr,
vlm_client=vlm,
)
assert result.match == False # "Nom Patient" not found by OCR
def test_fuzzy_ocr_match(self):
"""OCR reads 'Rechércher' (accent), expected 'Rechercher' → still found."""
ocr = mock_ocr_client_factory(["Rechércher"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
],
"overall_confidence": 0.9,
})
result = verify_screen_match(
"/tmp/test.png",
[{"role": "bouton", "text": "Rechercher"}],
ocr_client=ocr,
vlm_client=vlm,
)
assert result.match == True
def test_no_text_elements_trivially_match(self):
"""Elements without text key → no presence check needed → trivially OK."""
ocr = mock_ocr_client_factory(["Dashboard"])
vlm = mock_vlm_client_factory({})
result = verify_screen_match(
"/tmp/test.png",
[{"role": "page"}],
ocr_client=ocr,
vlm_client=vlm,
)
assert result.match == True
# ── verify_before / verify_after tests ────────────────────────────────
class TestVerifyBeforeAfter:
def test_verify_before_match(self):
ocr = mock_ocr_client_factory(["Login", "Password", "Connexion"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.85},
],
"overall_confidence": 0.85,
})
result = verify_before(
"/tmp/login.png",
[{"role": "champ", "text": "Login"}],
ocr_client=ocr,
vlm_client=vlm,
context="page login",
)
assert result.match == True
def test_verify_after_higher_threshold(self):
"""verify_after uses min_confidence=0.8. VLM returns 0.75 → mismatch."""
ocr = mock_ocr_client_factory(["Dashboard"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "page", "confidence": 0.75},
],
"overall_confidence": 0.75,
})
result = verify_after(
"/tmp/dashboard.png",
[{"role": "page", "text": "Dashboard"}],
ocr_client=ocr,
vlm_client=vlm,
)
# 0.75 < 0.8 threshold → role mismatch
assert result.match == False
def test_verify_after_passes_at_0_8(self):
ocr = mock_ocr_client_factory(["Dashboard"])
vlm = mock_vlm_client_factory({
"confirmed": [
{"index": 1, "role_confirmed": True, "actual_role": "page", "confidence": 0.85},
],
"overall_confidence": 0.85,
})
result = verify_after(
"/tmp/dashboard.png",
[{"role": "page", "text": "Dashboard"}],
ocr_client=ocr,
vlm_client=vlm,
)
assert result.match == True
def test_verify_before_ocr_missing(self):
"""Pre-action: expected text not on screen → mismatch (can't proceed)."""
ocr = mock_ocr_client_factory(["Accueil"])
vlm = mock_vlm_client_factory({})
result = verify_before(
"/tmp/page.png",
[{"role": "bouton", "text": "Connexion"}],
ocr_client=ocr,
vlm_client=vlm,
context="pre-login",
)
assert result.match == False
assert "OCR presence" in result.reason
# ── OcrPresenceResult dataclass tests ─────────────────────────────────
class TestOcrPresenceResult:
def test_presence_ratio_all_found(self):
result = OcrPresenceResult(
found_texts={"Login": "Login", "Password": "Password"},
missing=[],
all_found=True,
)
assert result.presence_ratio == 1.0
def test_presence_ratio_half_found(self):
result = OcrPresenceResult(
found_texts={"Login": "Login", "Password": ""},
missing=["champ: Password"],
all_found=False,
)
assert result.presence_ratio == 0.5
def test_presence_ratio_empty(self):
result = OcrPresenceResult(
found_texts={},
missing=[],
all_found=True,
)
assert result.presence_ratio == 1.0

View File

@@ -0,0 +1,65 @@
"""Tests du client vLLM serveur (image + prompt -> texte).
Le POST réseau est injectable (`post_fn`) → testable sans vLLM. Sert de
`vlm_client` à `extract_dossier_from_image` dans le handler runtime.
"""
import pytest
from core.extraction.vlm_client import build_chat_body, img_data_url, make_vllm_client
def _png(tmp_path, w=2000, h=1000):
from PIL import Image
p = tmp_path / "x.png"
Image.new("RGB", (w, h), (255, 255, 255)).save(p)
return str(p)
class _Resp:
def __init__(self, code, payload=None, text=""):
self.status_code = code
self._p = payload or {}
self.text = text
def json(self):
return self._p
def test_img_data_url_downscale(tmp_path):
url = img_data_url(_png(tmp_path), max_w=1280)
assert url.startswith("data:image/png;base64,")
def test_build_chat_body_structure(tmp_path):
body = build_chat_body(_png(tmp_path), "PROMPT", model="M", max_tokens=1500, max_w=1280)
assert body["model"] == "M"
assert body["max_tokens"] == 1500
# thinking désactivé (vérifié hier : think=on -> vide/lent)
assert body["chat_template_kwargs"]["enable_thinking"] is False
content = body["messages"][0]["content"]
assert any(c["type"] == "image_url" for c in content)
assert any(c["type"] == "text" and c["text"] == "PROMPT" for c in content)
def test_client_retourne_content(tmp_path):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
captured["url"] = url
captured["body"] = json
return _Resp(200, {"choices": [{"message": {"content": "REPONSE"}}]})
client = make_vllm_client(model="M", post_fn=fake_post)
out = client(_png(tmp_path), "PROMPT")
assert out == "REPONSE"
assert "/v1/chat/completions" in captured["url"]
assert captured["body"]["messages"][0]["content"][1]["text"] == "PROMPT"
def test_client_erreur_status_leve(tmp_path):
def fake_post(url, json=None, headers=None, timeout=None):
return _Resp(500, text="boom")
client = make_vllm_client(post_fn=fake_post)
with pytest.raises(RuntimeError):
client(_png(tmp_path), "PROMPT")

View File

@@ -0,0 +1,44 @@
"""
Test de non-régression : conservation du machine_id au round-trip to_dict/from_dict.
Bug : les workflows listés via /api/v1/traces/stream/workflows étaient tous
attribués à machine_id="default" alors que les sessions portaient le bon
machine_id (lea-*). Cause : 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']). list_workflows tombait alors sur le fallback "default".
"""
from datetime import datetime
from core.models.workflow_graph import Workflow
def _make_minimal_workflow(machine_id: str) -> Workflow:
"""Construit un workflow minimal portant un machine_id dans ses métadonnées."""
now = datetime.now().isoformat()
return Workflow.from_dict({
"workflow_id": "wf-test",
"name": "wf-test",
"nodes": [],
"edges": [],
"safety_rules": {},
"stats": {},
"learning": {},
"entry_nodes": [],
"end_nodes": [],
"created_at": now,
"updated_at": now,
"metadata": {"machine_id": machine_id},
})
def test_machine_id_preserved_after_to_dict_from_dict_round_trip():
"""Un workflow doit conserver son machine_id après un round-trip de (dé)sérialisation."""
wf = _make_minimal_workflow("lea-poste-3")
# Simule l'étiquetage runtime fait par le stream_processor
wf._machine_id = "lea-poste-3"
restored = Workflow.from_dict(wf.to_dict())
# Invariant : le machine_id survit au round-trip (comme le fait list_workflows)
assert getattr(restored, "_machine_id", "default") == "lea-poste-3"

View File

@@ -0,0 +1,86 @@
"""TDD — adaptateur Workflow → signature de trajectoire (Phase 0, lot 2).
Branche la primitive `trajectory_signature` sur un vrai workflow core (dict).
Doit : traverser les edges dans l'ordre du parcours (BFS depuis entry_nodes), et
n'extraire que des descripteurs de cible **stables** (by_role/by_text/window),
en ignorant coords (`by_position`) et IDs de nœuds session-spécifiques.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from core.execution.trajectory_signature import workflow_trajectory_signature
def _edge(from_node, to_node, action_type, *, by_role="", by_text="", by_position=None):
target = {"by_role": by_role, "by_text": by_text}
if by_position is not None:
target["by_position"] = by_position
return {
"from_node": from_node,
"to_node": to_node,
"action": {"type": action_type, "target": target},
}
def test_signature_stable_across_sessions():
"""Même parcours, IDs de nœuds + coords différents → même signature."""
session_a = {
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1"}, {"node_id": "n2"}, {"node_id": "n3"}],
"edges": [
_edge("n1", "n2", "mouse_click", by_role="button", by_text="Fichier", by_position=[0.1, 0.2]),
_edge("n2", "n3", "text_input", by_text="recherche", by_position=[0.5, 0.6]),
],
}
session_b = {
"entry_nodes": ["a1"],
"nodes": [{"node_id": "a1"}, {"node_id": "a2"}, {"node_id": "a3"}],
"edges": [
_edge("a1", "a2", "mouse_click", by_role="button", by_text="Fichier", by_position=[0.9, 0.8]),
_edge("a2", "a3", "text_input", by_text="recherche", by_position=[0.05, 0.04]),
],
}
assert workflow_trajectory_signature(session_a) == workflow_trajectory_signature(session_b)
def test_signature_differs_on_different_target():
base = {
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="button", by_text="Valider")],
}
other = {
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="button", by_text="Annuler")],
}
assert workflow_trajectory_signature(base) != workflow_trajectory_signature(other)
def test_signature_follows_edge_chain_not_list_order():
"""L'ordre vient de la chaîne from→to (BFS), pas de l'ordre brut de la liste."""
e1 = _edge("n1", "n2", "mouse_click", by_text="A")
e2 = _edge("n2", "n3", "text_input", by_text="B")
ordered = {"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}, {"node_id": "n3"}],
"edges": [e1, e2]}
scrambled = {"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}, {"node_id": "n3"}],
"edges": [e2, e1]} # liste inversée, même chaîne
assert workflow_trajectory_signature(ordered) == workflow_trajectory_signature(scrambled)
def test_signature_stable_despite_grounding_role_difference():
"""`by_role` peut porter le moteur de grounding (yolo/ocr/vlm) — instable entre
sessions. La signature doit rester identique si seul `by_role` change → elle
s'appuie sur le texte sémantique `by_text`, pas sur la méthode de détection."""
wf_yolo = {
"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="yolo", by_text="Fichier")],
}
wf_ocr = {
"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="ocr", by_text="Fichier")],
}
assert workflow_trajectory_signature(wf_yolo) == workflow_trajectory_signature(wf_ocr)

118
tools/anonymize_demo.py Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""ZIP de démo (Amina + Dom) : capture + JSON de ce que Léa récupère.
Règle d'anonymisation (décision Dom 30/06) : on garde TOUT lisible — interface,
menus, libellés, valeurs cliniques — et on ne masque QUE l'identité directe du
patient, qui se trouve dans le BANDEAU DU HAUT (titre du dossier / onglets).
- Capture : floutage CIBLÉ de la bande supérieure uniquement (top_frac). Le reste
(menus de navigation, formulaire, valeurs) reste lisible — c'est l'interface
qu'on apprend et ce qui sert à naviguer.
- JSON : vraies valeurs des champs (lisibles), + une section `patient` où nom /
prénom / date de naissance sont remplacés par des tokens.
Tourne sur le DGX. Le détail (vraies valeurs) n'est pas affiché par le script —
seuls des compteurs et la plage Y floutée le sont (pas de PID dans les logs).
"""
import argparse
import json
import sys
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core.llm.ocr_extractor import extract_grid_from_image # noqa: E402
from core.extraction.role_mapper import tokens_from_grid # noqa: E402
from PIL import Image, ImageFilter # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--image", required=True)
ap.add_argument("--extraction-json", required=True)
ap.add_argument("--out", default="/tmp/demo_lecture_ecran.zip")
ap.add_argument("--top-frac", type=float, default=0.15,
help="fraction haute de l'écran à flouter (bandeau identité patient)")
a = ap.parse_args()
grid = extract_grid_from_image(a.image)
tokens = tokens_from_grid(grid)
fields = json.loads(Path(a.extraction_json).read_text())
img = Image.open(a.image).convert("RGB")
H = img.height
seuil = int(a.top_frac * H)
# Floutage CIBLÉ : uniquement les tokens texte de la bande supérieure
# (bandeau d'identité patient). Tout le reste reste lisible.
blurred = 0
ys = []
PAD = 2
for t in tokens:
if not t.bbox:
continue
x0, y0, x1, y1 = t.bbox
if y0 < seuil: # token dans le bandeau du haut
xx0 = max(0, x0 - PAD); yy0 = max(0, y0 - PAD)
xx1 = min(img.width, x1 + PAD); yy1 = min(img.height, y1 + PAD)
if xx1 > xx0 and yy1 > yy0:
region = img.crop((xx0, yy0, xx1, yy1)).filter(ImageFilter.GaussianBlur(12))
img.paste(region, (xx0, yy0))
blurred += 1
ys.append(y0)
# JSON démo : vraies valeurs des champs + identité patient tokenisée
demo = {
"ecran": "Dossier patient — Urgences (DPI réel)",
"note": "Données cliniques réelles. Identité directe du patient remplacée par des tokens ; le reste est ce que Léa lit tel quel.",
"patient": {
"nom": "[nom]",
"prenom": "[prenom]",
"date_naissance": "[date de naissance]",
},
"champs": [
{"label": f.get("label"),
"valeur": f.get("value"),
"confiance_ocr": round(float(f.get("confidence", 0)), 2),
"ancre_ocr": bool(f.get("anchored"))}
for f in fields
],
}
tmp = Path("/tmp/_demo_build"); tmp.mkdir(exist_ok=True)
for old in tmp.glob("*"):
old.unlink()
cap = tmp / "capture.png"
img.save(cap)
js = tmp / "ce_que_lea_recupere.json"
js.write_text(json.dumps(demo, ensure_ascii=False, indent=2))
readme = tmp / "LISEZMOI.txt"
readme.write_text(
"DÉMO — Lecture d'écran par Léa (RPA 100% vision)\n"
"================================================\n\n"
"1) capture.png : un vrai écran de dossier patient (Urgences). Tout est\n"
" lisible (interface, menus, libellés, valeurs cliniques) ; SEUL le\n"
" bandeau d'identité du patient (en haut) est flouté.\n\n"
"2) ce_que_lea_recupere.json : ce que Léa extrait de cet écran. L'OCR fournit\n"
" les valeurs exactes (vérité), le modèle de vision identifie le RÔLE de\n"
" chaque champ. Valeurs cliniques réelles ; identité patient = tokens\n"
" [nom]/[prenom]/[date de naissance]. 0 hallucination (valeur = OCR).\n\n"
f" {len(demo['champs'])} champs reconnus sur cet écran.\n"
)
with zipfile.ZipFile(a.out, "w", zipfile.ZIP_DEFLATED) as z:
z.write(cap, cap.name)
z.write(js, js.name)
z.write(readme, readme.name)
plage = f"{min(ys)}..{max(ys)}px" if ys else ""
print(f"# Hauteur image : {H}px | seuil bandeau = {seuil}px (top {a.top_frac:.0%})")
print(f"# Tokens floutés (bandeau haut) : {blurred} | plage Y : {plage}")
print(f"# Tokens TOTAL : {len(tokens)} (le reste reste lisible)")
print(f"# Champs JSON (vraies valeurs) : {len(demo['champs'])}")
print(f"# ZIP : {a.out}")
if __name__ == "__main__":
main()

112
tools/e2e_map_roles.py Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""E2E — valide le MODULE `core.extraction.role_mapper` en conditions réelles.
Remplace le POC ad hoc (`poc_lecture_ecran.py`) : au lieu de logique inline, on
appelle la brique TESTÉE `map_roles` avec un vrai client vLLM. Prouve la parité
module ↔ POC sur un vrai écran DGX.
Pipeline : extract_grid_from_image (OCR) → tokens_from_grid → map_roles(client réel).
Sortie masquée (PII) ; détail complet dumpé dans /tmp (reste sur le DGX).
"""
import argparse
import base64
import json
import re
import sys
import time
from io import BytesIO
from pathlib import Path
import requests
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core.llm.ocr_extractor import extract_grid_from_image # noqa: E402
from core.extraction.role_mapper import tokens_from_grid, map_roles # noqa: E402
VLLM_URL = "http://localhost:8001/v1/chat/completions"
MODEL = "Qwen/Qwen3-VL-4B-Instruct"
def _img_data_url(path, max_w=1280):
img = Image.open(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 make_client(max_tokens=1500, max_w=1280):
"""Construit un client VLM (image_path, prompt) -> texte, branché sur vLLM:8001."""
def client(image_path, prompt):
body = {
"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},
}
r = requests.post(VLLM_URL, json=body, timeout=120)
if r.status_code != 200:
raise RuntimeError(f"vLLM {r.status_code}: {r.text[:300]}")
return r.json()["choices"][0]["message"]["content"]
return client
def _mask(v):
v = str(v)
if not v:
return "<vide>"
if re.fullmatch(r"[\d .,/:%€-]+", v):
k = "num/date"
elif len(v.split()) >= 4:
k = "texte"
else:
k = "court"
return f"<{k}:{len(v)}c>"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--extract", required=True)
ap.add_argument("--roles", default="", help="rôles attendus, séparés par des virgules (mode guidé)")
a = ap.parse_args()
roles = [r.strip() for r in a.roles.split(",") if r.strip()] or None
t0 = time.time()
grid = extract_grid_from_image(a.extract)
t_ocr = time.time() - t0
tokens = tokens_from_grid(grid)
confs = sorted(t.confidence for t in tokens)
med = confs[len(confs) // 2] if confs else 0.0
client = make_client()
t1 = time.time()
fields = map_roles(a.extract, tokens, client, roles)
t_vlm = time.time() - t1
out = Path(f"/tmp/e2e_{Path(a.extract).stem}.json")
out.write_text(json.dumps(
[{"label": f.label, "value": f.value, "confidence": f.confidence,
"anchored": f.anchored, "value_ids": f.value_ids} for f in fields],
ensure_ascii=False, indent=2))
anc = sum(1 for f in fields if f.anchored)
print(f"# Image : {Path(a.extract).name}")
print(f"# Mode : {'guidé ' + str(roles) if roles else 'libre'}")
print(f"# OCR : {len(tokens)} tokens, conf médiane {med:.2f}, {t_ocr:.1f}s")
print(f"# VLM : {t_vlm:.1f}s | via map_roles (module testé)")
print(f"# Champs : {len(fields)} (ancrés OCR: {anc})")
for f in fields:
flag = "·" if f.anchored else ""
print(f" {flag} {str(f.label)[:28]:28s} = {_mask(f.value)}")
print(f"# Ancrage strict : {anc}/{len(fields)} | détail PII -> {out} (DGX, NE PAS rapatrier)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Éval ENRICHISSEMENT multi-modèles : qualité de la description d'un élément UI.
Au runtime, le worker enrichit chaque action avec un `target_spec` (by_text,
by_role, vlm_description) via le VLM. On mesure ici cette capacité : on montre
un crop autour de la cible et on demande au modèle de l'identifier. On compare
le `by_text` généré au texte réel (ground-truth OCR du cas).
Dimensions : exactitude by_text, plausibilité by_role, latence.
Usage:
venv_v3/bin/python3 tools/enrichment_eval_multi.py \
--cases benchmarks/computer_use/cases/leabench_easily_clean_2026-06-12.jsonl \
--models gemma4:26b qwen2.5vl:7b-rpa qwen3-vl:8b \
--out benchmarks/computer_use/predictions/easily_enrich
"""
import argparse
import base64
import io
import json
import os
import re
import time
import unicodedata
import requests
from PIL import Image
ROLES = ("bouton", "onglet", "champ", "lien", "liste", "menu", "icône", "texte", "case")
def norm(s):
s = unicodedata.normalize("NFKD", s or "")
return "".join(c for c in s if not unicodedata.combining(c)).lower().strip()
def crop_b64(path, xp, yp, half_w=0.10, half_h=0.045):
im = Image.open(path).convert("RGB"); W, H = im.size
cx, cy = xp * W, yp * H
box = (max(0, int(cx - half_w * W)), max(0, int(cy - half_h * H)),
min(W, int(cx + half_w * W)), min(H, int(cy + half_h * H)))
crop = im.crop(box)
if max(crop.size) < 320: # upscale pour lisibilité
r = 320 / max(crop.size)
crop = crop.resize((int(crop.width * r), int(crop.height * r)), Image.LANCZOS)
buf = io.BytesIO(); crop.save(buf, format="JPEG", quality=92)
return base64.b64encode(buf.getvalue()).decode()
def call(endpoint, model, b64, timeout):
prompt = ("Voici un gros plan d'un élément d'interface (logiciel médical). "
"Identifie-le. Réponds UNIQUEMENT par un JSON: "
'{"by_text": "<texte exact visible>", "by_role": '
'"bouton|onglet|champ|lien|liste|menu|icône|texte|case", '
'"description": "<courte description>"}.')
# think=False OBLIGATOIRE pour gemma4 même en enrichissement : avec le mode
# thinking + format JSON, Ollama (>=0.20) renvoie des "tokens vides" → by_text
# manquant (vérifié : 10/18 vides avec thinking). Doc 2026-06-08.
payload = {"model": model, "stream": False, "format": "json",
"think": False,
"messages": [{"role": "user", "content": prompt, "images": [b64]}],
"options": {"temperature": 0.0}}
t0 = time.time()
r = requests.post(f"{endpoint}/api/chat", json=payload, timeout=timeout)
dt = time.time() - t0
r.raise_for_status()
return r.json().get("message", {}).get("content", ""), dt
def text_score(gen, real):
"""0..1 : correspondance du by_text généré au texte réel."""
g, t = norm(gen), norm(real)
if not t:
return None
if not g:
return 0.0
if g == t:
return 1.0
if t in g or g in t:
return min(len(g), len(t)) / max(len(g), len(t))
gt, tt = set(g.split()), set(t.split())
inter = gt & tt
return round(len(inter) / max(1, len(tt)), 2) if inter else 0.0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cases", required=True)
ap.add_argument("--models", nargs="+", required=True)
ap.add_argument("--endpoint", default="http://127.0.0.1:11434")
ap.add_argument("--timeout", type=int, default=120)
ap.add_argument("--out", required=True)
args = ap.parse_args()
# ne garder que les cas avec un texte cible réel exploitable
cases = [c for c in (json.loads(l) for l in open(args.cases))
if c["task"]["target_text"] and len(c["task"]["target_text"]) >= 3
and any(ch.isalpha() for ch in c["task"]["target_text"])]
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
summary = []
for model in args.models:
rows = []
print(f"\n===== ENRICH {model} =====", flush=True)
for c in cases:
reg = c["expectation"]["click_region"]
b64 = crop_b64(c["screenshot_path"], reg["x_pct"], reg["y_pct"])
try:
text, dt = call(args.endpoint, model, b64, args.timeout)
j = json.loads(re.search(r"\{.*\}", text, re.S).group(0))
by_text = j.get("by_text", ""); by_role = norm(j.get("by_role", ""))
except Exception as e:
text, dt, by_text, by_role = f"ERR:{e}", None, "", ""
sc = text_score(by_text, c["task"]["target_text"])
role_ok = by_role in ROLES
rows.append({"case_id": c["case_id"], "model": model,
"real": c["task"]["target_text"], "gen_by_text": by_text,
"by_role": by_role, "text_score": sc, "role_valid": role_ok,
"latency_s": round(dt, 2) if dt else None})
print(f" réel={c['task']['target_text'][:18]!r:22s} gén={by_text[:22]!r:26s} "
f"score={sc} role={by_role[:8]}", flush=True)
with open(f"{args.out}_{model.replace(':','_').replace('/','_')}.jsonl", "w") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
scored = [r["text_score"] for r in rows if r["text_score"] is not None]
lats = [r["latency_s"] for r in rows if r["latency_s"]]
summary.append({"model": model, "n": len(rows),
"text_acc_mean": round(sum(scored) / len(scored), 3) if scored else None,
"exact": sum(1 for s in scored if s == 1.0),
"role_valid": sum(r["role_valid"] for r in rows),
"latency_med": round(sorted(lats)[len(lats)//2], 1) if lats else None})
print("\n\n========== SYNTHÈSE ENRICHISSEMENT ==========")
print(f"{'modèle':22s} {'by_text_acc':>11} {'exact':>6} {'role_ok':>8} {'lat_méd':>8}")
for s in summary:
print(f"{s['model']:22s} {str(s['text_acc_mean']):>11} {s['exact']:>6} "
f"{s['role_valid']:>8} {str(s['latency_med'])+'s':>8}")
with open(f"{args.out}_summary.json", "w") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Extracteur de cas LeaBench à partir des replay_failures Easily.
Ground-truth obtenue par OCR (docTR) : on localise le `by_text` du target_spec
sur le screenshot réel → centre de sa bbox = (x_pct, y_pct). Les cas sans
`by_text` exploitable (ou texte introuvable) sont marqués `needs_human_check`
pour validation/annotation visuelle.
Usage:
venv_v3/bin/python3 tools/extract_easily_bench_cases.py \
--files /tmp/ez_files.txt \
--out benchmarks/computer_use/cases/leabench_easily_2026-06-12.jsonl
"""
import argparse
import json
import os
import unicodedata
def norm(s: str) -> str:
s = unicodedata.normalize("NFKD", s or "")
s = "".join(c for c in s if not unicodedata.combining(c))
return s.lower().strip()
def ocr_lines(model, shot):
"""Retourne [(texte_ligne, (cx, cy))] en coords normalisées 0-1."""
from doctr.io import DocumentFile
doc = DocumentFile.from_images(shot)
res = model(doc)
out = []
for page in res.pages:
for block in page.blocks:
for line in block.lines:
txt = " ".join(w.value for w in line.words)
xs, ys = [], []
for w in line.words:
(x0, y0), (x1, y1) = w.geometry
xs += [x0, x1]
ys += [y0, y1]
if not xs:
continue
cx = (min(xs) + max(xs)) / 2.0
cy = (min(ys) + max(ys)) / 2.0
out.append((txt, (cx, cy)))
# aussi par mot pour cibles courtes
for w in line.words:
(x0, y0), (x1, y1) = w.geometry
out.append((w.value, ((x0 + x1) / 2, (y0 + y1) / 2)))
return out
def best_match(bytext, lines):
"""Trouve la ligne/mot OCR couvrant le mieux by_text. Retourne (cx,cy,score)."""
nb = norm(bytext)
if not nb:
return None
best = None
for txt, (cx, cy) in lines:
nt = norm(txt)
if not nt:
continue
if nb == nt:
score = 1.0
elif nb in nt or nt in nb:
score = min(len(nb), len(nt)) / max(len(nb), len(nt))
else:
# recouvrement de tokens
tb, tt = set(nb.split()), set(nt.split())
inter = tb & tt
score = len(inter) / max(1, len(tb)) * 0.8 if inter else 0.0
if best is None or score > best[2]:
best = (round(cx, 4), round(cy, 4), round(score, 3))
return best
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--files", default="/tmp/ez_files.txt")
ap.add_argument("--out", required=True)
args = ap.parse_args()
from doctr.models import ocr_predictor
print("chargement docTR…", flush=True)
model = ocr_predictor(pretrained=True)
files = [l.strip() for l in open(args.files) if l.strip()]
cases, report = [], []
for fp in files:
sess = os.path.basename(os.path.dirname(fp))
for line in open(fp):
try:
o = json.loads(line)
except Exception:
continue
ts = o.get("target_spec", {}) or {}
shot = o.get("screenshot_path", "")
if not shot or not os.path.exists(shot):
continue
bytext = (ts.get("by_text") or "").strip()
vlmd = (ts.get("vlm_description") or "").strip()
m = None
try:
if bytext:
m = best_match(bytext, ocr_lines(model, shot))
except Exception as e:
report.append((sess, os.path.basename(shot), "ocr_err", str(e)[:40]))
if m and m[2] >= 0.6:
x_pct, y_pct, score = m
needs = False
else:
x_pct, y_pct, score = 0.5, 0.5, (m[2] if m else 0.0)
needs = True
base = os.path.splitext(os.path.basename(shot))[0]
cases.append({
"case_id": f"easily_{sess}_{base}"[:70],
"screenshot_path": shot,
"task": {
"intent": (o.get("intent") or "").strip() or (
f"cliquer sur « {bytext} »" if bytext else "cliquer sur la cible"),
"target_text": bytext,
"current_window": "Easily Assure (maquette POC)",
"expected_next_window": "",
"question": (
f"La cible « {bytext} » est-elle visible ? Clique uniquement dessus."
if bytext else f"Cible : {vlmd[:120]}. Clique uniquement dessus."),
},
"expectation": {
"decision": "click",
"click_region": {"x_pct": x_pct, "y_pct": y_pct, "radius_pct": 0.06},
"accepted_reasons": ["ocr_text_match"],
},
"metadata": {
"source": "easily_replay_failure",
"session": sess,
"ocr_match_score": score,
"by_text_source": ts.get("by_text_source"),
"needs_human_check": needs,
},
})
flag = " ⚠CHECK" if needs else ""
report.append((sess, os.path.basename(shot), f"score={score}",
f"({x_pct},{y_pct}) text={bytext!r}{flag}"))
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
for c in cases:
f.write(json.dumps(c, ensure_ascii=False) + "\n")
low = sum(1 for c in cases if c["metadata"]["needs_human_check"])
print(f"\n{len(cases)} cas écrits → {args.out}")
print(f" auto (OCR ok): {len(cases)-low} | à valider visuellement: {low}\n")
for r in report:
print(" ", *r)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Extracteur de cas LeaBench depuis une session RECORD (clics humains réels).
G1 (2026-06-13) : ground-truth = position du clic humain. Instruction = la LIGNE
OCR (docTR) qui CONTIENT le clic (= l'onglet/élément complet, pas un mot isolé).
Les cibles AMBIGUËS (texte présent plusieurs fois à l'écran) et PARASITES
(scrollbar/barre des tâches, charabia) sont filtrées et tracées.
Usage:
venv_v3/bin/python3 tools/extract_record_bench_cases.py \
--session /tmp/easily_session \
--out benchmarks/computer_use/cases/leabench_easily_clean_v2.jsonl
"""
import argparse
import ast
import json
import math
import os
import unicodedata
from PIL import Image
def parse_event(o):
e = o.get("event")
if isinstance(e, dict):
return e
if isinstance(e, str):
try:
return ast.literal_eval(e)
except Exception:
return None
return None
def norm(s):
s = unicodedata.normalize("NFKD", s or "")
return "".join(c for c in s if not unicodedata.combining(c)).lower().strip()
def ocr_lines(model, path):
"""[(text, x0, y0, x1, y1)] par MOT (docTR fusionne les onglets adjacents sur
une même ligne ; le mot est la bonne granularité pour une cible d'onglet)."""
from doctr.io import DocumentFile
res = model(DocumentFile.from_images(path))
out = []
for page in res.pages:
for block in page.blocks:
for line in block.lines:
for w in line.words:
(x0, y0), (x1, y1) = w.geometry
if w.value.strip():
out.append((w.value, x0, y0, x1, y1))
return out
def pick_target(lines, xp, yp):
"""Retourne (text, n_occurrences, contained).
- text : la ligne contenant le clic (sinon la plus proche dans 0.04).
- n_occurrences : combien de lignes ont ce même texte (ambiguïté si >1).
- contained : True si le clic est dans la bbox de la ligne.
"""
contained = [(t, x0, y0, x1, y1) for (t, x0, y0, x1, y1) in lines
if x0 <= xp <= x1 and y0 <= yp <= y1 and t.strip()]
if contained:
# la plus petite ligne contenant le clic (la plus spécifique)
contained.sort(key=lambda r: (r[3] - r[1]) * (r[4] - r[2]))
text = contained[0][0].strip()
else:
best = None
for (t, x0, y0, x1, y1) in lines:
if not t.strip():
continue
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
d = math.hypot(cx - xp, cy - yp)
if d <= 0.04 and (best is None or d < best[0]):
best = (d, t.strip())
if not best:
return None, 0, False
text = best[1]
n_occ = sum(1 for (t, *_b) in lines if norm(t) == norm(text))
return text, n_occ, bool(contained)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--session", required=True)
ap.add_argument("--out", required=True)
args = ap.parse_args()
from doctr.models import ocr_predictor
print("chargement docTR…", flush=True)
model = ocr_predictor(pretrained=True)
ev = os.path.join(args.session, "live_events.jsonl")
shots = os.path.join(args.session, "shots")
kept, dropped = [], []
shot_cache = {}
for line in open(ev):
try:
o = json.loads(line)
except Exception:
continue
e = parse_event(o)
if not e or e.get("type") not in ("mouse_click", "double_click"):
continue
pos = e.get("pos")
sid = e.get("screenshot_id")
if not pos or not sid:
continue
if isinstance(pos, str):
try: pos = ast.literal_eval(pos)
except Exception: continue
full = os.path.join(shots, f"{sid}_full.png")
if not os.path.exists(full):
continue
if full not in shot_cache:
shot_cache[full] = (Image.open(full).size, ocr_lines(model, full))
(W, H), lines = shot_cache[full]
xp, yp = pos[0] / W, pos[1] / H
cid = f"easily_{sid}_{int(pos[0])}_{int(pos[1])}"
# --- filtres ---
if not (0 <= xp <= 1 and 0 <= yp <= 1):
dropped.append((cid, "click_out_of_shot")); continue
if xp > 0.95 or yp > 0.92:
dropped.append((cid, "parasite_zone (scrollbar/barre tâches)")); continue
text, n_occ, contained = pick_target(lines, xp, yp)
if not text:
dropped.append((cid, "no_text_under_click")); continue
n_alpha = sum(c.isalpha() for c in text)
is_id = text.replace(" ", "").isdigit() and len(text.replace(" ", "")) >= 6
if n_alpha < 3 and not is_id:
dropped.append((cid, f"charabia/court {text!r}")); continue
if len(text) > 18: # onglet/bouton court ; long = OCR cassé/texte collé
dropped.append((cid, f"trop long (OCR cassé) {text[:24]!r}")); continue
if n_occ > 1:
dropped.append((cid, f"AMBIGU {text!r} ×{n_occ}")); continue
win = e.get("window")
wtitle = win.get("title", "") if isinstance(win, dict) else (str(win)[:80] if win else "")
kept.append({
"case_id": cid,
"screenshot_path": os.path.abspath(full),
"task": {
"intent": f"cliquer sur « {text} »",
"target_text": text,
"current_window": wtitle[:80],
"expected_next_window": "",
"question": f"L'élément « {text} » est-il visible ? Clique uniquement dessus.",
},
"expectation": {
"decision": "click",
"click_region": {"x_pct": round(xp, 4), "y_pct": round(yp, 4),
"radius_pct": 0.05},
"accepted_reasons": ["human_click_groundtruth"],
},
"metadata": {"source": "easily_record", "session": os.path.basename(args.session),
"click_type": e.get("type"), "contained_in_line": contained,
"ocr_occurrences": n_occ},
})
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
with open(args.out, "w") as f:
for c in kept:
f.write(json.dumps(c, ensure_ascii=False) + "\n")
print(f"\n{len(kept)} cas PROPRES → {args.out}")
print(f"{len(dropped)} cas écartés (tracés) :")
for cid, why in dropped:
print(f" - {cid}: {why}")
print("\nCibles retenues :")
for c in kept:
print(f" {c['task']['target_text']!r:30s} @ ({c['expectation']['click_region']['x_pct']},{c['expectation']['click_region']['y_pct']})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""E2E grounding via le VRAI chemin de résolution `_resolve_by_grounding`.
Contrairement à `grounding_eval_multi.py` (qui fait son propre appel vLLM direct
avec son prompt/parser = chemin parallèle « unit-mocké »), ce harness exerce le
chemin de production réel : `agent_v0.server_v1.resolve_engine._resolve_by_grounding`
en mode `RPA_GROUNDING_ENGINE=qwen3vl_vllm`.
But : confirmer que le 0.933 du bench (tuple modèle+moteur+prompt+parse+think) se
reproduit quand c'est le code de prod qui construit le prompt, encode l'image et
parse la réponse — pas un script de bench séparé.
Scoring identique au bench original (distance euclidienne au click_region humain).
Usage (env + tunnel vLLM 8001 requis) :
RPA_GROUNDING_ENGINE=qwen3vl_vllm .venv/bin/python3 \
tools/grounding_e2e_resolve_engine.py \
--cases benchmarks/computer_use/cases/leabench_easily_clean_v2.jsonl
"""
import argparse
import json
import logging
import math
import time
from PIL import Image
from agent_v0.server_v1.resolve_engine import _resolve_by_grounding
logging.basicConfig(level=logging.WARNING)
def score(case, resolved):
"""(status, correct, dangerous, x_pct, y_pct).
resolved = dict de _resolve_by_grounding ou None (abstention).
Règle identique à grounding_eval_multi.score : clic dans le rayon = juste,
hors rayon = dangereux, abstention = raté non-dangereux.
"""
reg = case["expectation"]["click_region"]
if not resolved or resolved.get("x_pct") is None:
return "abstain", False, False, None, None
xp, yp = float(resolved["x_pct"]), float(resolved["y_pct"])
d = math.hypot(xp - reg["x_pct"], yp - reg["y_pct"])
if d <= reg["radius_pct"]:
return "in_region", True, False, xp, yp
return "outside_region", False, True, xp, yp
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cases", required=True)
ap.add_argument("--out", default="benchmarks/computer_use/predictions/easily_e2e_resolve_engine.jsonl")
args = ap.parse_args()
cases = [json.loads(line) for line in open(args.cases)]
rows = []
print(f"\n===== E2E _resolve_by_grounding (qwen3vl_vllm) — {len(cases)} cas =====", flush=True)
for c in cases:
W, H = Image.open(c["screenshot_path"]).size
target_spec = {"by_text": c["task"]["target_text"]}
t0 = time.time()
try:
resolved = _resolve_by_grounding(c["screenshot_path"], target_spec, W, H)
err = None
except Exception as e: # noqa: BLE001
resolved, err = None, f"{type(e).__name__}: {e}"
dt = time.time() - t0
status, ok, dang, xp, yp = score(c, resolved)
method = resolved.get("method") if resolved else None
rows.append({
"case_id": c["case_id"], "target": c["task"]["target_text"],
"status": status, "correct": ok, "dangerous": dang,
"x_pct": xp, "y_pct": yp, "method": method,
"latency_s": round(dt, 2), "error": err,
})
flag = "OK " if ok else ("DANGER" if dang else "abst")
print(f" {c['case_id'][:30]:30s} {flag:6s} {status:14s} {dt:5.1f}s "
f"{(c['task']['target_text'][:20]):20s} "
f"{('('+str(xp)+','+str(yp)+')') if xp is not None else (err or '-')}",
flush=True)
with open(args.out, "w") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
n = len(rows)
correct = sum(r["correct"] for r in rows)
dang = sum(r["dangerous"] for r in rows)
abst = sum(1 for r in rows if r["status"] == "abstain")
lats = [r["latency_s"] for r in rows if r["latency_s"]]
lat_med = sorted(lats)[len(lats) // 2] if lats else None
print("\n========== SYNTHÈSE E2E (vrai chemin resolve_engine) ==========")
print(f" n={n} accuracy={correct/n:.3f} justes={correct} "
f"DANGEREUX={dang} abstentions={abst} lat_méd={lat_med}s")
print(f" prédictions → {args.out}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Éval grounding multi-modèles avec prompt+parser ADAPTÉS par modèle.
Chaque famille de modèle a sa convention native de coordonnées (vérifié 2026-06):
- qwen2.5-vl : pixels absolus (bbox_2d / point en px de l'image envoyée)
- qwen3-vl : normalisé 0-1000 (résolution-indépendant)
- gemma 3 : pas de grounding natif → on demande du 0-1 best-effort
Toutes les sorties sont reconverties en (x_pct, y_pct) ∈ [0,1] pour un scoreur commun.
Mesure : grounding (clic juste / dangereux), latence par appel, abstentions.
Usage:
venv_v3/bin/python3 tools/grounding_eval_multi.py \
--cases benchmarks/computer_use/cases/leabench_easily_clean_2026-06-12.jsonl \
--models gemma4:26b qwen2.5vl:7b-rpa qwen3-vl:8b \
--endpoint http://127.0.0.1:11434 --engine ollama \
--out benchmarks/computer_use/predictions/easily_multi
"""
import argparse
import base64
import io
import json
import math
import os
import re
import time
import requests
from PIL import Image
INSTR = ("Tu localises une cible sur une capture d'écran d'interface. "
"Si la cible n'est pas clairement visible, réponds par une abstention.")
def profile(model: str):
m = model.lower()
if "qwen3" in m or "qwen3.5" in m:
return "qwen3"
if "qwen2" in m or "qwen2.5" in m or "qwenvl" in m:
return "qwen25"
return "gemma" # gemma et défaut générique
def build_prompt(prof, case, W, H):
"""Prompt universel : on demande du 0-1 à tous (le plus robuste au resize).
Le parser rattrape si un modèle sort quand même son format natif."""
q = case["task"]["question"]
tgt = case["task"]["target_text"]
win = case["task"].get("current_window", "")
ctx = f"Fenêtre: {win}. Cible: « {tgt} ». {q}\n"
fmt = ("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.')
return ctx + fmt
def parse_pred(prof, text, W, H):
"""(decision, x_pct, y_pct). Accepte 0-1 ; sinon désambiguïse selon le profil
(filet si le modèle a ignoré la consigne et sorti son format natif)."""
if not text:
return "parse_error", None, None
if re.search(r'"?abstain"?\s*:\s*true', text, re.I):
return "abstain", None, None
try:
j = json.loads(re.search(r"\{.*\}", text, re.S).group(0))
except Exception:
nums = re.findall(r"-?\d+\.?\d*", text)
if len(nums) < 2:
return "parse_error", None, None
j = {"x": float(nums[0]), "y": float(nums[1])}
if "x" in j and "y" in j:
x, y = float(j["x"]), float(j["y"])
else:
pt = (j.get("point") or j.get("point_2d") or j.get("bbox_2d")
or j.get("click") or j.get("coordinate"))
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
x, y = float(pt[0]), float(pt[1]) # bbox → coin = approx point
else:
return "parse_error", None, None
def rescale(v, dim):
if 0 <= v <= 1.0:
return v # déjà 0-1 (consigne respectée)
if v <= 1000 and prof == "qwen3":
return v / 1000.0 # qwen3 natif 0-1000
if v > 1.0 and prof == "qwen25":
return v / dim # qwen2.5 natif pixels (de l'image envoyée)
if v <= 1000:
return v / 1000.0 # filet générique 0-1000
return v / dim # filet pixels
xp, yp = rescale(x, W), rescale(y, H)
if not (0 <= xp <= 1 and 0 <= yp <= 1):
return "parse_error", None, None
return "click", round(xp, 4), round(yp, 4)
def img_b64(path, max_edge=1280):
im = Image.open(path).convert("RGB")
W0, H0 = im.size
if max(im.size) > max_edge:
r = max_edge / max(im.size)
im = im.resize((int(im.width * r), int(im.height * r)), Image.LANCZOS)
buf = io.BytesIO(); im.save(buf, format="JPEG", quality=90)
return base64.b64encode(buf.getvalue()).decode(), W0, H0, im.size
def call_ollama(endpoint, model, prompt, b64, timeout):
payload = {"model": model, "stream": False, "format": "json",
"think": False, # désactive le raisonnement (grounding : réponse directe)
"messages": [{"role": "system", "content": INSTR},
{"role": "user", "content": prompt, "images": [b64]}],
"options": {"temperature": 0.0}}
t0 = time.time()
r = requests.post(f"{endpoint}/api/chat", json=payload, timeout=timeout)
dt = time.time() - t0
r.raise_for_status()
return r.json().get("message", {}).get("content", ""), dt
def call_vllm(endpoint, model, prompt, b64, timeout):
"""API OpenAI-compatible (vLLM) : image en data-URI base64."""
payload = {"model": model, "temperature": 0.0, "max_tokens": 256,
"chat_template_kwargs": {"enable_thinking": False}, # pas de raisonnement
"messages": [{"role": "system", "content": INSTR},
{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}}]}]}
t0 = time.time()
r = requests.post(f"{endpoint}/v1/chat/completions", json=payload, timeout=timeout)
dt = time.time() - t0
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"], dt
def call_model(engine, endpoint, model, prompt, b64, timeout):
if engine == "vllm":
return call_vllm(endpoint, model, prompt, b64, timeout)
return call_ollama(endpoint, model, prompt, b64, timeout)
def score(case, decision, xp, yp):
reg = case["expectation"]["click_region"]
if decision != "click":
return "abstain", False, False # ni correct ni dangereux (sur cas click attendu = raté non-dangereux)
d = math.hypot(xp - reg["x_pct"], yp - reg["y_pct"])
if d <= reg["radius_pct"]:
return "in_region", True, False
return "outside_region", False, True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cases", required=True)
ap.add_argument("--models", nargs="+", required=True)
ap.add_argument("--endpoint", default="http://127.0.0.1:11434")
ap.add_argument("--engine", default="ollama", choices=["ollama", "vllm"])
ap.add_argument("--timeout", type=int, default=120)
ap.add_argument("--out", required=True)
args = ap.parse_args()
cases = [json.loads(l) for l in open(args.cases)]
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
summary = []
for model in args.models:
prof = profile(model)
rows = []
print(f"\n===== {model} (profil={prof}) =====", flush=True)
for c in cases:
b64, W0, H0, sent = img_b64(c["screenshot_path"])
Ws, Hs = sent # taille réellement envoyée (pour le filet pixels)
prompt = build_prompt(prof, c, Ws, Hs)
try:
text, dt = call_model(args.engine, args.endpoint, model, prompt, b64, args.timeout)
dec, xp, yp = parse_pred(prof, text, Ws, Hs)
except Exception as e:
text, dt, dec, xp, yp = f"ERR:{e}", None, "error", None, None
status, ok, dang = score(c, dec, xp, yp)
rows.append({"case_id": c["case_id"], "model": model, "profile": prof,
"decision": dec, "x_pct": xp, "y_pct": yp,
"latency_s": round(dt, 2) if dt else None,
"status": status, "correct": ok, "dangerous": dang,
"target": c["task"]["target_text"]})
print(f" {c['case_id'][:34]:34s} {dec:11s} {status:14s} "
f"{(str(round(dt,1))+'s') if dt else '-':>6} {c['task']['target_text'][:18]!r}",
flush=True)
pred_path = f"{args.out}_{model.replace(':','_').replace('/','_')}.jsonl"
with open(pred_path, "w") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
n = len(rows)
correct = sum(r["correct"] for r in rows)
dang = sum(r["dangerous"] for r in rows)
abst = sum(1 for r in rows if r["decision"] in ("abstain", "parse_error", "error"))
lats = [r["latency_s"] for r in rows if r["latency_s"]]
summary.append({"model": model, "profile": prof, "n": n,
"accuracy": round(correct / n, 3), "correct": correct,
"dangerous": dang, "abstain_or_err": abst,
"latency_med": round(sorted(lats)[len(lats)//2], 1) if lats else None,
"latency_max": round(max(lats), 1) if lats else None})
print("\n\n========== SYNTHÈSE GROUNDING (Easily réel) ==========")
print(f"{'modèle':22s} {'prof':7s} {'acc':>5} {'just':>5} {'DANG':>5} {'abst':>5} {'lat_méd':>8} {'lat_max':>8}")
for s in summary:
print(f"{s['model']:22s} {s['profile']:7s} {s['accuracy']:>5} "
f"{s['correct']:>5} {s['dangerous']:>5} {s['abstain_or_err']:>5} "
f"{str(s['latency_med'])+'s':>8} {str(s['latency_max'])+'s':>8}")
with open(f"{args.out}_summary.json", "w") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()

View File

@@ -321,6 +321,70 @@ class ExecutionStep(db.Model):
}
# ---------------------------------------------------------------------------
# Extraction — « dossier patient extrait » (brique 2)
#
# ⚠️ CANAL EXTRACTION ≠ canal apprentissage. Ces tables conservent les
# VRAIES données patient (patient_ref, ExtractedField.value) : c'est le but,
# constituer le dossier. Elles NE doivent PAS être anonymisées/tokenisées
# (à l'inverse du canal apprentissage, cf. pii_sanitizer). Aucun appel
# d'assainissement PII ne doit cibler ces colonnes.
#
# Sémantique de preuve réutilisée de contracts/evidence.py (VWBEvidence) :
# screenshot_ref ≈ screenshot, screen_bbox/bbox ≈ highlight_box, confidence
# ≈ confidence_score, created_at ≈ timestamp.
# ---------------------------------------------------------------------------
class ExtractionJob(db.Model):
"""Dossier patient extrait — racine d'une session d'extraction."""
__tablename__ = 'extraction_jobs'
id = db.Column(db.String(64), primary_key=True)
patient_ref = db.Column(db.String(255), nullable=True) # donnée patient EN CLAIR (volontaire)
source_session_id = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# status: 'needs_review' (revue humaine requise) | 'complete' (validé)
status = db.Column(db.String(32), default='needs_review')
tables = db.relationship('ExtractedTable', backref='job', lazy='dynamic',
cascade='all, delete-orphan')
def __repr__(self):
return f'<ExtractionJob {self.id}: {self.status}>'
class ExtractedTable(db.Model):
"""Tableau extrait d'un écran (preuve : screenshot_ref + screen_bbox)."""
__tablename__ = 'extracted_tables'
id = db.Column(db.String(64), primary_key=True)
job_id = db.Column(db.String(64), db.ForeignKey('extraction_jobs.id'), nullable=False)
screen_bbox = db.Column(db.JSON, nullable=True) # {x, y, width, height}
screenshot_ref = db.Column(db.String(512), nullable=True)
fields = db.relationship('ExtractedField', backref='table', lazy='dynamic',
cascade='all, delete-orphan')
def __repr__(self):
return f'<ExtractedTable {self.id}>'
class ExtractedField(db.Model):
"""Cellule extraite (donnée patient EN CLAIR) + preuve bbox/confidence."""
__tablename__ = 'extracted_fields'
id = db.Column(db.String(64), primary_key=True)
table_id = db.Column(db.String(64), db.ForeignKey('extracted_tables.id'), nullable=False)
row = db.Column(db.Integer, nullable=True)
col = db.Column(db.Integer, nullable=True)
value = db.Column(db.Text, nullable=True) # valeur patient EN CLAIR (volontaire)
bbox = db.Column(db.JSON, nullable=True) # {x, y, width, height}
confidence = db.Column(db.Float, nullable=True)
def __repr__(self):
return f'<ExtractedField {self.id}: r{self.row}c{self.col}>'
# Session active (en mémoire, pas en DB)
class SessionState:
"""État de la session utilisateur (en mémoire)"""

View File

@@ -295,6 +295,175 @@ def convert_learned_to_vwb_steps(
return workflow_meta, steps, warnings
# ---------------------------------------------------------------------------
# Pont R1 — import IDEMPOTENT d'un workflow core en DB VWB (create-or-update)
# ---------------------------------------------------------------------------
# Marqueur stable de signature de trajectoire embarqué dans `Workflow.description`.
# Le modèle `Workflow` n'a PAS (encore) de colonne dédiée ; on réutilise donc le
# même mécanisme que la route GET /learned-workflows existante, qui détecte les
# imports via `description.contains(...)`. La clé d'idempotence est la SIGNATURE
# DE TRAJECTOIRE (cf. core.execution.trajectory_signature), pas le workflow_id de
# session (qui change à chaque ré-apprentissage du même parcours).
_TRAJ_SIG_MARKER = "[traj_sig:"
def _trajectory_signature_marker(signature: str) -> str:
"""Marqueur texte stable à embarquer dans la description."""
return f"{_TRAJ_SIG_MARKER}{signature}]"
def _find_existing_learned_workflow(db_session, signature: str):
"""Cherche un Workflow `source='learned_import'` de MÊME signature de trajectoire.
Ne considère QUE les imports appris : les workflows `source='manual'`
(démo Urgence_aiva, etc.) sont volontairement exclus du filtre et donc
jamais candidats à la mise à jour.
"""
from db.models import Workflow # import paresseux (modèles liés au runtime VWB)
marker = _trajectory_signature_marker(signature)
return (
db_session.query(Workflow)
.filter(
Workflow.source == "learned_import",
Workflow.description.contains(marker),
)
.first()
)
def import_core_workflow_to_db(
core_dict: Dict[str, Any],
*,
machine_id: str,
source_session_id: str,
db_session,
) -> Dict[str, Any]:
"""Importe un workflow core (JSON appris par Léa) en DB VWB, de façon IDEMPOTENTE.
Fusion par **signature de trajectoire** (décision produit Dom 23/06) :
1. calcule `sig = workflow_trajectory_signature(core_dict)` ;
2. cherche un `Workflow` `source='learned_import'` de même signature ;
3. si trouvé → **skip** (pas de doublon, le workflow existant fait foi) ;
sinon → crée `Workflow` + `Step`(s) via `convert_learned_to_vwb_steps`.
Le nouveau workflow est marqué `source='learned_import'`,
`review_status='pending_review'`. Les workflows `source='manual'` ne sont
JAMAIS touchés (cf. `_find_existing_learned_workflow`).
Args:
core_dict: workflow core (dict JSON) tel qu'appris/sauvegardé.
machine_id: poste d'origine (traçabilité, stocké en tag/description).
source_session_id: session ayant produit ce workflow (traçabilité).
db_session: session SQLAlchemy (l'app appelante détient le contexte).
Returns:
dict {created: bool, workflow_id: str, signature: str, warnings: list}.
`created=False` quand un workflow de même trajectoire existait déjà.
Note (non-wiring) : cette unité n'est PAS branchée au worker live ni à la
route HTTP existante ; voir le rapport de câblage R1.
"""
# Imports paresseux : garde le module léger et évite un import core/DB au load.
from core.execution.trajectory_signature import workflow_trajectory_signature
from db.models import Workflow, Step
signature = workflow_trajectory_signature(core_dict)
# --- Idempotence : même trajectoire déjà importée ? → skip (pas de doublon) ---
existing = _find_existing_learned_workflow(db_session, signature)
if existing is not None:
logger.info(
"Workflow appris déjà présent (signature %s…) → import ignoré, "
"réutilisation de %s",
signature[:12],
existing.id,
)
return {
"created": False,
"workflow_id": existing.id,
"signature": signature,
"warnings": [],
}
# --- Création : conversion core → steps VWB, puis écriture DB ---
wf_meta, steps_list, warnings = convert_learned_to_vwb_steps(core_dict)
current_name = (wf_meta.get("name") or "").strip()
if current_name.lower() in {"", "unnamed workflow", "workflow importé"}:
# Réutilise la dérivation de nom de la route HTTP si disponible.
try:
from api_v3.learned_workflows import _derive_default_name
wf_meta["name"] = _derive_default_name(core_dict)
except Exception: # pragma: no cover - fallback minimal
wf_meta["name"] = f"Léa import — {datetime.now():%Y-%m-%d %H:%M}"
wf_id = f"wf_{uuid.uuid4().hex[:12]}"
# La signature est embarquée dans la description (clé d'idempotence) + une
# ligne de traçabilité (workflow core d'origine).
base_desc = (wf_meta.get("description") or "").strip()
description = "\n\n".join(
part
for part in (
base_desc,
f"[Importé depuis workflow appris: {core_dict.get('workflow_id', '')}]",
_trajectory_signature_marker(signature),
)
if part
)
workflow = Workflow(
id=wf_id,
name=wf_meta["name"],
description=description,
source="learned_import",
review_status="pending_review",
)
# Tags : conserver ceux du workflow + traçabilité machine/session.
tags = list(wf_meta.get("tags") or [])
tags.extend([f"machine:{machine_id}", f"session:{source_session_id}"])
workflow.tags = tags
db_session.add(workflow)
for step_data in steps_list:
step = Step(
id=f"step_{uuid.uuid4().hex[:12]}",
workflow_id=wf_id,
action_type=step_data["action_type"],
order=step_data["order"],
position_x=step_data.get("position_x", 0),
position_y=step_data.get("position_y", 0),
label=step_data.get("label", step_data["action_type"]),
)
params = dict(step_data.get("parameters", {}))
# L'image d'ancre (_anchor_image_base64) est laissée dans params : la
# persistance d'ancre (VisualAnchor + fichier) reste pilotée par la route
# HTTP existante. Cette unité se concentre sur l'idempotence Workflow/Step.
step.parameters = params
db_session.add(step)
db_session.commit()
logger.info(
"Workflow appris importé (R1) : %s (signature %s…, %d étapes, machine %s)",
wf_id,
signature[:12],
len(steps_list),
machine_id,
)
return {
"created": True,
"workflow_id": wf_id,
"signature": signature,
"warnings": warnings,
}
def _convert_compound_substep(
sub_type: str, sub: Dict[str, Any], parent_target: Dict[str, Any]
) -> Tuple[str, Dict[str, Any]]:

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