125 Commits

Author SHA1 Message Date
Dom
0a02a6ec9c feat(qw4): bench rigoureux LLM safety_checks → gemma4:latest par défaut
Some checks failed
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Bench 5 modèles × 5 scénarios × cold+warm sur RTX 5070 :
- gemma4:latest : warm 2.9s, JSON 92%, détection 46% → gagnant
- qwen2.5vl:7b : warm 6.6s, détection 23% (trop lent)
- qwen2.5vl:3b : warm 2.0s, détection 8% (vérifie pour vérifier)
- medgemma:4b : warm 0.5s, détection 0% (refuse de signaler) → mauvais
  défaut initial, corrigé
- qwen3-vl:8b : 0% JSON valide (ignore format=json Ollama) → écarté

Modifications safety_checks_provider.py :
- RPA_SAFETY_CHECKS_LLM_MODEL défaut: medgemma:4b → gemma4:latest
- RPA_SAFETY_CHECKS_LLM_TIMEOUT_S défaut: 5 → 7 (warm 2.9s + marge)

Doc complète : docs/BENCH_SAFETY_CHECKS_2026-05-06.md
Script : tools/bench_safety_checks_models.py (reproductible, ~10-15 min)

Limite assumée : 46% de détection. À présenter en démo comme aide médecin,
pas certification. Amélioration V2 = prompt plus dirigé sur champs à vérifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:23:09 +02:00
Dom
83be93e121 chore(qw): cleanup post-review (préfixes BUS, événements monitor, import io)
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
- safety_checks_provider : tous les logger.warning d'échec LLM préfixés
  [BUS] lea:safety_checks_llm_failed avec une raison spécifique
  (exception, http_status, timeout, network, json_decode).
- monitor_router : émission [BUS] lea:monitor_invalid_index si l'index
  explicite passé dans l'action est hors limites de monitors_geometry,
  et [BUS] lea:monitor_unavailable si focus actif demandé mais introuvable.
  Ces deux events permettent au bus de tracer chaque fallback de la cascade
  de routage QW1.
- safety_checks_provider : import io supprimé (inutilisé).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:08:22 +02:00
Dom
f5c33477f0 fix(qw4): câblage polling frontend → streaming pour PauseDialog
Avant ce fix, le frontend VWB ne savait pas qu'un replay Agent V1 (Windows)
était en pause supervisée : le seul polling (App.tsx) interrogeait
/execute/status (exécution locale Linux) et n'avait jamais l'info
safety_checks / pause_message du replay distant.

Côté backend (dag_execute.py) :
- ajout du proxy GET /api/v3/replay/state/<replay_id> qui forward vers
  /api/v1/traces/stream/replay/<id> avec Bearer token.

Côté frontend :
- ExecutionControls : nouvelle prop onWindowsReplayStarted, appelée avec
  le replay_id retourné par /api/v3/execute-windows.
- App.tsx : nouveau state streamingReplayId + useEffect qui poll
  /api/v3/replay/state/<id> toutes les secondes et fusionne status,
  pause_message, pause_reason, safety_checks dans appState.execution.
  Le PauseDialog existant s'affiche donc automatiquement quand
  status = paused_need_help.

Le polling s'arrête quand le replay est completed/error/cancelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:06:20 +02:00
Dom
b1a3aa16f1 fix(qw1): enrichir heartbeat Windows avec monitor_index + monitors_geometry
Avant ce fix, le _heartbeat_loop côté Agent V1 deploy Windows
n'enrichissait pas son payload, donc QW1 multi-écran ne s'activait sur Windows
que via les events window_capture (déclenchés par les clics), pas en continu.

La source agent_v0/agent_v1/main.py portait déjà l'enrichissement (commit 2d71e2a24)
mais le snapshot deploy/windows_client/agent_v1/main.py n'avait pas été synchronisé.

Désormais chaque heartbeat porte monitor_index + monitors_geometry, le serveur
peut donc résoudre l'écran cible en permanence, même sans clic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:02:11 +02:00
Dom
0bcfddbbc4 docs(qw): plan de smoke tests manuels pour validation 2026-05-06
Some checks failed
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Plan exécutable seul par Dom : 9 sections (préflight, QW1 mono/multi-écran,
QW2 boucle, QW4 backward/déclaratif/medical_critical, bus events, kill-switches,
rollback) avec checklist OK/KO et procédures d'urgence en pleine démo.

Validation pour démo GHT (1ère sem mai 2026).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:01:21 +02:00
Dom
aa47172f0f docs(qw): synthèse de livraison QW suite mai 2026
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Doc condensée des 3 quick wins livrés (QW1 multi-écrans, QW2 LoopDetector,
QW4 safety_checks hybrides) avec :
- procédures kill-switch et rollback
- table des env vars
- smoke tests manuels à effectuer avant démo GHT
- statut composant par composant

Pointe vers spec et plan d'exécution complets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:48:26 +02:00
Dom
65da557310 feat(qw4): hook safety_checks_provider + extension /replay/resume avec acquittements
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
replay_state enrichi de safety_checks, checks_acknowledged, pause_reason,
pause_payload (audit trail).

Branche supervisée pause_for_human :
- appel build_pause_payload() avant bascule paused_need_help
- log [BUS] lea:safety_checks_generated (count, sources)
- fallback safe sur exception (pause sans checks plutôt que crash)
- déclenchement si safety_level/safety_checks déclarés OU execution_mode != autonomous
- sinon comportement legacy (skip silencieux)

POST /replay/resume :
- accepte body { acknowledged_check_ids: [...] }
- vérifie tous les checks required acquittés, sinon 400 required_checks_missing
- stocke checks_acknowledged comme audit trail
- nettoie safety_checks/pause_payload après reprise

Proxy VWB /api/v3/replay/resume → streaming /replay/{id}/resume (forward bearer
token + acknowledged_check_ids).

Backward 100% : workflows sans safety_checks → resume sans acquittement requis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:45:22 +02:00
Dom
af13cd80ff feat(vwb): PauseDialog + ChecklistPanel + extension PropertiesPanel pour safety_checks
PauseDialog (composant nouveau) :
- 2 modes selon payload : bulle simple legacy si safety_checks vide,
  ChecklistPanel sinon
- Continuer désactivé tant que required non cochés
- Badge [obligatoire] et [Léa] (avec evidence en tooltip)
- POST /api/v3/replay/resume avec acknowledged_check_ids quand replay_id
  présent, fallback api.resumeExecution() pour la voie locale

types.ts : SafetyCheck, SafetyLevel, extension Execution
(pause_reason, pause_message, safety_checks, replay_id, status
'paused_need_help'). Action pause_for_human enrichie de safety_level
et safety_checks dans le catalogue ACTIONS.

PropertiesPanel : éditeur safety_level (dropdown standard/medical_critical)
+ liste éditable de safety_checks (id/label/required + ajout/suppression).

App.tsx : rendu conditionnel du PauseDialog en overlay quand
status == paused_need_help, ou paused avec safety_checks. Backward 100% :
workflows existants sans safety_checks affichent la bulle legacy.

CSS : .pause-dialog-overlay/.pause-dialog-checks/.checklist-panel/
.check-item/.badge-required/.badge-lea/.check-editor-row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:33:04 +02:00
Dom
7c6945171e feat(qw4): SafetyChecksProvider hybride déclaratif + LLM contextuel
build_pause_payload(action, state, last_screenshot) → PausePayload
- Toujours inclure les checks déclaratifs (workflow.parameters.safety_checks)
- Si safety_level=medical_critical ET RPA_SAFETY_CHECKS_LLM_ENABLED=1 :
    appel LLM (medgemma:4b par défaut) en format=json strict, timeout 5s,
    max 3 checks ajoutés (configurables via env vars)
- Tous les chemins d'erreur (timeout, HTTP, JSON parse, exception) loggent
  et retournent [] (fallback safe : déclaratifs seuls)

Tests : 7 cas (déclaratif seul, hybride OK, timeout, LLM invalide,
kill-switch, max_checks, déclaratif vide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:29:38 +02:00
Dom
ca0b436a61 feat(qw2): hook LoopDetector dans api_stream + extension replay_state
Some checks failed
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 17s
tests / Tests sécurité (critique) (push) Has been skipped
replay_state enrichi de _screenshot_history (5 dernières images PIL) et
_action_history (5 dernières signatures action).

report_action_result :
- met à jour les deux anneaux après chaque action
- évalue le LoopDetector (singleton lazy avec _clip_embedder serveur)
- si detected → bascule paused_need_help avec pause_reason="loop_detected"
  et bus event lea:loop_detected (signal + evidence)

Tous les chemins d'erreur (embedder absent, OOM, exception) loggent et
laissent le replay continuer — aucun blocage par la couche détection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:25:04 +02:00
Dom
fc01afa59c fix(qw1): bus event lea:monitor_routed + cablage offset côté executor Agent V1
Cleanup post-review QW1 :
- Émission bus lea:monitor_routed dans /replay/next (idx, source, replay_id, action_id, offset, wh)
  via logger.info "[BUS] lea:monitor_routed ..." (le serveur streaming n'a pas
  de SocketIO local, agent_chat émet déjà lea:* sur 5004 ; ici on logge en INFO
  bien lisible, prêt pour un parser/pont futur)
- Executor Agent V1 (deploy/windows_client) lit action.monitor_resolution.{offset_x, offset_y, idx}
  et applique l'offset aux coords absolues du clic/type/scroll/popup quand idx >= 0
- composite_fallback (idx=-1) : pas d'offset appliqué (backward compat mono-écran)
- Log INFO "QW1 monitor cible idx=N source=X offset=(dx,dy) — appliqué aux coords"
  émis une fois par action quand un offset non nul s'applique

Tests : baseline 95 passed (e2e + phase0_integration + stream_processor + monitor_router + grounding_offset)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:16:06 +02:00
Dom
2a51a844b9 feat(qw2): LoopDetector composite (screen_static + action_repeat + retry)
Module isolé, 3 signaux indépendants :
- screen_static : CLIP similarity > 0.99 sur N captures consécutives
- action_repeat : N actions identiques (type+coords)
- retry_threshold : retried_actions >= seuil

Premier signal positif → LoopVerdict.detected=True (caller responsable de
la bascule en paused_need_help).

Configurable env vars : RPA_LOOP_DETECTOR_ENABLED (kill-switch),
RPA_LOOP_SCREEN_STATIC_N/THRESHOLD, RPA_LOOP_ACTION_REPEAT_N,
RPA_LOOP_RETRY_THRESHOLD.

Tests : 8 cas (chaque signal isolé, kill-switch, embedder absent, exception).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:09:43 +02:00
Dom
2d71e2a249 feat(qw1): enrichissement Agent V1 (monitor_index + monitors_geometry) + hook serveur
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Côté client Agent V1 :
- helpers _get_monitors_geometry() / _get_active_monitor_index() via screeninfo
  (fallback gracieux [] / None si screeninfo absent)
- _enrich_with_monitor_info() ajouté aux payloads dict de capture_dual,
  capture_active_window, et heartbeat_event poussé par main.py
- screeninfo>=0.8 ajouté aux requirements (source + deploy Windows)
- Deploy capturer.py reçoit l'enrichissement de manière additive (pas de
  copie verbatim qui aurait introduit BLUR_SENSITIVE absent côté deploy)

Côté serveur :
- import resolve_target_monitor depuis monitor_router (créé en QW1.1)
- /replay/next : enrichissement action.monitor_resolution avant envoi
  au client (idx, offset_x/y, w, h, source de la décision)
- live_session_manager.add_event : propagation monitor_index +
  monitors_geometry depuis window_capture ET depuis le payload event
  brut (cas heartbeat enrichi sans window/window_title)

Cascade de résolution (cf monitor_router.py) :
1. action.monitor_index (hérité de la session source)
2. session.last_focused_monitor (focus actif vu en dernier heartbeat)
3. composite_fallback (offset 0,0) — backward compat strict

Backward 100% : si geometry vide, fallback composite identique au
comportement actuel mss.monitors[0].

Tests : baseline 89/89 préservée, monitor_router 4/4 OK (total 93/93).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:05:44 +02:00
Dom
fae95c5366 feat(qw1): capture par monitor + propagation offsets dans grounding cascade
_capture_screen() accepte un monitor_idx optionnel (None = composite legacy).
Index logique 0..N-1 mappé sur mss.monitors[idx+1] (mss[0] = composite).

Les 3 niveaux de grounding (OCR, UI-TARS, VLM) propagent l'offset retourné
par la capture pour traduire les coordonnées locales monitor en coordonnées
absolues écran (correct pour pyautogui.click).

find_element_on_screen() accepte monitor_idx et le forwarde aux 3 niveaux.

Backward 100% : monitor_idx=None partout → comportement strictement actuel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:55:04 +02:00
Dom
6582a69d31 feat(qw1): MonitorRouter — résolution de l'écran cible pour le replay
Module isolé qui choisit l'écran cible avec stratégie en cascade :
1. action.monitor_index (session source) → cible explicite
2. session.last_focused_monitor → fallback focus actif
3. composite (offset 0,0) → backward compat (comportement actuel)

Backward 100% : actions sans monitor_index → fallback composite identique
au comportement mss.monitors[0] actuel.

Tests : 4 cas (cible OK, fallback focus, fallback composite, index invalide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:50:22 +02:00
Dom
5543e25f9d docs(qw): plan d'implémentation QW suite mai 2026 (~30 tasks bite-sized TDD)
Some checks failed
tests / Lint (ruff + black) (push) Successful in 18s
tests / Tests unitaires (sans GPU) (push) Failing after 17s
tests / Tests sécurité (critique) (push) Has been skipped
Plan d'exécution détaillé pour le sprint QW1+QW2+QW4 :
- Section 0 (preflight) : backup branche+tag Gitea, baseline E2E, smoke démo
- Section 1 (QW1 multi-écrans) : tests + monitor_router + input_handler + Agent V1
- Section 2 (QW2 LoopDetector) : tests + module + hooks api_stream/replay_engine
- Section 3 (QW4 safety_checks) : tests + provider + endpoint + frontend VWB
- Section 4 (docs) : QW_SUITE_MAI.md + maj MEMORY

Chaque task = 4-7 steps de 2-5 min, code complet par step (modules nouveaux),
diffs ciblés (modifs ciblées), commands exactes avec output attendu.

Discipline TDD légère : test rouge → implem → test vert → re-run baseline → commit.

Référence spec : docs/superpowers/specs/2026-05-05-qw-suite-mai-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:34:13 +02:00
Dom
2a07d8084b docs(qw): spec design QW suite mai 2026 (multi-écrans + LoopDetector + safety_checks hybrides)
Spec issu d'un brainstorming structuré (7 questions clarifiantes,
décisions tranchées) inspiré par l'exploration comparative de 5 frameworks
computer-use (Simular Agent-S, browser-use, OpenAI CUA sample, Coasty
open-cu, Showlab OOTB).

3 quick wins ciblés :
- QW1 multi-écrans : capture/grounding par monitor_index avec fallbacks
- QW2 LoopDetector composite : screen_static (CLIP) + action_repeat + retry
- QW4 safety_checks hybrides : déclaratif workflow + LLM contextuel
  (medgemma:4b, timeout 5s, fallback safe, kill-switch env)

Contraintes inviolables : 100% vision, 100% local Ollama, backward compat.
Plan livraison : QW1+QW2 avant démo GHT, QW4 enchaîné dès validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:23:10 +02:00
Dom
35b27ae492 fix(stream+vwb): chaîne replay robuste — auth, anchor type_text, lock async, drift, prompt LLM
Six modifications structurelles côté serveur, non destructives, aboutissant à un
pipeline replay bien plus stable pour la démo GHT Sud 95 (Urgences UHCD).

1. visual_workflow_builder/backend/app.py
   load_dotenv() chargeait .env (cwd) au lieu de .env.local racine projet.
   Conséquence : RPA_API_TOKEN absent après chaque restart manuel du backend
   et tous les proxies VWB→streaming échouaient en 401 « Token API invalide ».
   Charge maintenant explicitement .env.local du project root.

2. visual_workflow_builder/backend/api_v3/learned_workflows.py
   Quatre appels proxy /api/v1/traces/stream/* ne portaient pas le Bearer.
   Helper _stream_headers() factorisé et appliqué (workflows list/detail,
   workflow detail, reload-workflows).

3. visual_workflow_builder/backend/api_v3/dag_execute.py
   _ANCHOR_CLICK_TYPES excluait type_text/type_secret : pas de pre-click de
   focus avant la frappe → texte tapé sans focus → textareas vides au replay.
   Helper _inject_anchor_targeting() factorisé (centre bbox + visual_mode +
   target_spec) appliqué aux click_anchor* ET aux type_text/type_secret dès
   qu'un anchor_id est présent. Workflows historiques sans anchor sur
   type_text → comportement inchangé.

4. agent_v0/server_v1/api_stream.py — endpoint /replay/next
   _replay_lock (threading.Lock global) tenu pendant les actions serveur
   lentes (extract_text OCR ~5s, t2a_decision LLM ~8-13s). Comme le handler
   est async def, l'event loop FastAPI était bloqué : les polls clients
   timeout à 5s, leurs actions étaient popped serveur sans destinataire,
   perdues silencieusement. Mesure : 8 actions/25 perdues sur replay Urgence.

   acquire(timeout=4.5) puis run_in_executor pour libérer l'event loop
   pendant l'attente du lock ET pendant les handlers serveur synchrones.
   Pendant un t2a_decision en cours, les polls concurrents reçoivent
   immédiatement {action: null, server_busy: true} → l'agent ne timeout
   plus, aucune action n'est popped sans destinataire.

5. agent_v0/server_v1/resolve_engine.py — _validate_resolution_quality
   Drift > 0.20 par rapport aux coords enregistrées → fallback aux coords
   enregistrées même quand le template matching trouve l'image avec un
   score quasi parfait. Or un score >= 0.95 signifie que l'image EST
   visuellement à l'écran à l'endroit indiqué, le drift reflète juste
   un changement de layout (scroll, F11, redimensionnement), pas une
   erreur. Exception ajoutée : score >= 0.95 sur template_matching →
   ignore drift check, utilise position visuelle.

6. core/llm/t2a_decision.py — prompt T2A/PMSI
   Ancien prompt autorisait « Critère non validé » en fallback creux.
   Nouveau prompt impose au moins une CITATION LITTÉRALE entre « ... »
   du DPI dans chaque preuve_critereN, qu'elle soutienne ou infirme le
   critère. Si non validé : factualisation explicite (« Aucune ... »,
   « Sortie à H+2 ») citée du dossier. Sortie = preuves cliniques
   traçables et professionnelles, pas du remplissage.

État DB : aucun changement net (bbox patchés puis revertés depuis backup
visual_anchors_backup_20260501 ; by_text re-aligné sur 25003284). Le
re-enregistrement du workflow Urgence en conditions bureau standard
(Chrome normal, taille fenêtre standard) est l'étape suivante côté Dom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:32:57 +02:00
Dom
b584bbabc3 fix(stream): robustesse proxy VWB→streaming + ciblage textuel pour démo UHCD
dag_execute.py /execute-windows :
- Bearer token sur appels VWB→streaming (machines, replay/raw).
  Sans cela : 401 Unauthorized et le workflow ne démarre pas.
- Auto-injection session_id='agent_demo_user' si absent.
  Sans cela : /replay/raw bascule sur l'auto-détection sess_* et lève
  "Aucune session Agent V1 active" après tout restart du streaming server.
- Propagation by_text dans target_spec pour ciblage textuel
  (résolution hybrid_text_direct côté executor) — utile quand
  deux numéros se ressemblent visuellement (ex 25003284 vs 2500341).

t2a_decision.py : prompt enrichi avec decision_court (UHCD / Forfait
Urgences) + 3 critères PMSI (preuve_critereN + critereN_valide booléen)
pour piloter case-à-cocher dans l'arbre décisionnel. num_predict=1500,
num_ctx=16384.

resolve_engine.py : un drift trop grand bascule sur les coords
enregistrées (fallback_recorded_coords, resolved=True) au lieu de
rejeter la résolution. Permet au replay de continuer en cas de scroll
plutôt que de s'arrêter net.

workflows.db : by_text='25003284' sur le step de sélection patient
du workflow Urgence (démo GHT Sud 95).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:52:22 +02:00
Dom
8817f527e7 feat(deploy): service systemd pour la maquette Easily Assure (démo GHT)
Sert le statique de docs/clients/ght_sud_95/mockup_easily_assure/
sur le port 8765 (auto-restart, démarre au boot). Proxifié en
HTTPS via NPM sur urgence.labs.laurinebazin.design avec Basic Auth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:52:27 +02:00
Dom
964856ab30 feat(workflow): variables runtime + extract_text serveur + t2a_decision LLM
Pipeline streaming étendu pour supporter des actions exécutées entièrement
côté serveur (jamais transmises à l'Agent V1) qui produisent des variables
réutilisables dans les steps suivants via templating {{var}} ou {{var.field}}.

== Variables d'exécution ==
- replay_state["variables"] : Dict[str, Any] initialisé vide à la création
- _resolve_runtime_vars() : résout {{var}} et {{var.field}} récursivement
  dans str/dict/list. Variables absentes laissées intactes.
- /replay/next applique la résolution sur l'action AVANT toute interception
  ou envoi à l'Agent V1.

== Boucle d'exécution serveur ==
- _SERVER_SIDE_ACTION_TYPES = {"extract_text", "t2a_decision"}
- /replay/next pop+execute en boucle ces actions jusqu'à trouver une action
  visuelle (à transmettre Agent V1) ou un pause_for_human (qui bloque).
- Latence acceptable : t2a_decision = 5-10s côté serveur, l'Agent V1 attend
  la réponse HTTP.

== Action extract_text ==
- Handler côté serveur réutilisant le dernier heartbeat (max 5s d'âge)
- core/llm/ocr_extractor.py : EasyOCR fr+en singleton + extract_text_from_image
- Stockage dans replay_state["variables"][output_var]
- Robuste : pas de heartbeat → variable = "" + log warning, pipeline continue

== Action t2a_decision ==
- core/llm/t2a_decision.py : refactor de demo_app.py query_model en module
  importable. Prompt expert DIM T2A/PMSI, qwen2.5:7b par défaut (100% bench).
- Handler côté serveur appelle analyze_dpi(input_template_resolved)
- Stockage du JSON décision dans replay_state["variables"][output_var]
- Erreurs (Ollama down, parse) → variable = INDETERMINE + _error, pipeline continue

== VWB UI ==
- types.ts : nouveau type 't2a_decision' (icône 🧠 catégorie logic)
- extract_text refondu : needsAnchor=false, paramètre output_var (au lieu de
  variable_name legacy — bridge accepte les deux pour compat)
- Bridge VWB→core : passthrough des deux types + paramètres préservés

== Tests ==
- tests/integration/test_t2a_extract.py : 25 tests verts
  - templating runtime (8 tests)
  - handler extract_text (3 tests, OCR mocké)
  - handler t2a_decision (3 tests, analyze_dpi mocké)
  - edge → action normalisée (2 tests)
  - bridge VWB → core (5 tests)
  - workflow chain extract→t2a→pause→clic (1 test)

Total branche : 82/82 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:47:31 +02:00
Dom
a67d896104 fix(vwb): bibliothèque de capture restait vide après 'Capturer'
Cause racine : le useEffect d'ajout à la bibliothèque écoutait la prop
'capture' venant du parent. Le path 'agent Windows distant' (doSmartCapture
quand l'agent V1 répond) faisait setCurrentCapture(state local) mais ne
déclenchait jamais la prop parente — donc useEffect [capture] ne tirait pas,
donc addCaptureToLibrary jamais appelé. La capture s'affichait, mais rien
n'était persisté côté backend.

Fix :
- Factorisation de l'ajout dans un useCallback addToLibrary(cap)
- Appel explicite après setCurrentCapture dans doSmartCapture
- Le path fallback local (via prop capture) garde le useEffect [capture]
  qui appelle aussi addToLibrary

Erreurs d'upload (réseau, backend down) avalées silencieusement avec
console.warn — la capture locale reste utilisable même si le backend
de bibliothèque est indisponible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:13:56 +02:00
Dom
90c1d8036f ux(vwb): timer capture — default 5s, label dynamique, log diagnostic
Bug terrain : le bouton 'Timer' déclenchait toujours une capture immédiate
même après sélection d'un délai dans le menu déroulant. Le retour utilisateur
'le bouton ne change pas' a confirmé qu'il n'y avait aucun feedback visuel
sur le délai sélectionné, donc impossible de diagnostiquer.

Changements :
- timerSeconds default 5s (préférence Dom) au lieu de 0 (Immediat)
- Label dynamique du bouton :
    countdown actif → '5…' '4…' etc.
    délai 0 → 'Timer' (capture immédiate)
    délai > 0 → 'Capturer dans 5s'
- Select préfixé par 'Délai :' pour clarifier
- Conversion explicite String(timerSeconds) sur value du select pour éviter
  toute ambiguïté number/string
- console.log temporaire au changement de select pour faciliter le diagnostic
  si le bug persiste (à retirer après validation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:20:16 +02:00
Dom
6261002039 ux(vwb): tooltip enrichi sur les outils de la palette
Le tooltip natif HTML montrait juste le label ('Clic'). Maintenant il affiche :
- Le label
- La description complète (existait déjà dans types.ts mais non exposée)
- L'indication 'ancre requise' si applicable
- La liste des paramètres configurables

Le badge 🎯 a aussi son propre tooltip explicatif.

Aide à la prise en main du VWB pour la construction de workflows démo
(retour terrain Dom : 'il y a des outils dont je ne sais pas à quoi ils servent').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:42:55 +02:00
Dom
0e6e61f2b1 feat(workflow): action 'pause_for_human' — pause supervisée scriptée dans VWB
Nouvelle action native VWB qui force le replay à basculer en paused_need_help
avec un message custom. Quand Léa atteint cette étape, elle ne tente pas
d'exécuter — elle pose immédiatement le state, ce qui déclenche la bulle
interactive ChatWindow (J3.5) avec boutons Continuer / Annuler.

Asset démo majeur GHT Sud 95 : permet de scénariser le moment "Léa doute"
au bon endroit dans le workflow, sans dépendre d'un échec aléatoire.

Chaîne complète :
- VWB UI (types.ts) : nouvelle entrée ACTIONS catégorie 'logic', icône ⏸,
  paramètre 'message' éditable (textarea).
- Bridge VWB → core (learned_workflow_bridge.py) : passthrough du type +
  préservation du message dans parameters.
- Pipeline replay (replay_engine.py) : type ajouté à _ALLOWED_ACTION_TYPES,
  conversion edge → action normalisée préserve le message.
- Streaming server (api_stream.py /replay/next) : interception avant envoi
  à l'Agent V1 → bascule state en paused_need_help avec pause_message,
  retourne {action: None, replay_paused: True}.
- L'action n'est jamais transmise à l'Agent V1 — pure logique serveur.

10 nouveaux tests pytest. Total branche : 57/57 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:37:46 +02:00
Dom
41c1250c99 feat(lea): bulles 'Léa exécute' stylisées + templates par event
J3.4 — distinction visuelle entre :
- Bulles chat normales (fond bleu clair, prefixe 💬, taille standard)
- Bulles d'action Léa (fond gris clair, encadré subtil, icône sémantique
  en couleur, libellé court, métadonnées discrètes en pied)
- Bulle paused supervisée (jaune, boutons interactifs — déjà en J3.5)

Templates de libellés volontairement neutres : le contexte métier (UHCD,
peakflow, J12.1, IPP 25003284…) provient des payloads émis par le pipeline
côté serveur, pas de hardcoding dans le client.

Mappage events → bulles :
  lea:action_started   ▶ bleu  "Démarrage : {workflow}"
  lea:action_progress  ⋯ bleu  "{step}" ou "Étape {current}/{total}"
  lea:done             ✓ vert / ✗ rouge selon success
  lea:need_confirm     ?  bleu  "{action.description}"
  lea:step_result      ✓ / ✗ / · selon status
  lea:resumed          → vert  "Reprise"
  lea:resume_acked     (silencieux côté UI)
  lea:abort_acked      (silencieux côté UI)
  événement inconnu    · gris  fallback neutre

18 nouveaux tests pytest (templates + extract_meta).
Total branche : 47/47 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:18:52 +02:00
Dom
2af3bc3b93 feat(lea): bulle paused_need_help interactive — asset démo majeur
Quand Léa bascule en pause supervisée (event 'lea:paused'), affichage d'une
bulle dédiée dans ChatWindow avec encadré orangé, raison de la pause, et deux
boutons Continuer/Annuler. C'est le moment qui incarne la différence RPA classique
vs Léa devant Carvella : Léa SAIT qu'elle ne sait pas et demande de l'aide.

Architecture (canal SocketIO bidirectionnel, pas de nouvel endpoint streaming) :

  ChatWindow ──[lea:replay_resume]──> agent_chat ──POST /resume──> streaming
  ChatWindow ──[lea:replay_abort ]──> agent_chat (running=False local)

Composants ajoutés :
- agent_chat/app.py : handlers 'lea:replay_resume' / 'lea:replay_abort' +
  acks 'lea:resume_acked' / 'lea:abort_acked' pour feedback côté client
- network/feedback_bus.py : méthodes resume_replay() / abort_replay() avec
  helper _safe_emit (silencieux + retourne bool succès)
- ui/chat_window.py : palette PAUSED_*, _add_paused_bubble(),
  _render_paused_bubble(), _close_active_paused_bubble() (auto-fermeture
  sur lea:resumed/done), _on_paused_resume/abort

8 nouveaux tests pytest (4 handlers serveur + 4 méthodes client).
Total branche : 29/29 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:08:32 +02:00
Dom
6154423a91 feat(agent_v1): brancher FeedbackBusClient dans ChatWindow tkinter
- Import fail-safe : si python-socketio manquant (ancienne install Pauline),
  _HAS_FEEDBACK_BUS=False, ChatWindow tourne normalement sans bus
- Bus démarré à la fin de _run_tk_loop si LEA_FEEDBACK_BUS=1 dans l'env
- Callback _on_lea_event → _add_lea_message (thread-safe via root.after)
- Cleanup : _bus.stop() ajouté dans _do_destroy avant la destruction tkinter

Formatage des bulles minimal pour J3.3 (texte brut "[event] key=value").
Le style mixte métier+tech viendra en J3.4. La bulle paused interactive J3.5.

Aucun crash si bus indisponible. Aucun changement de comportement si flag off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:19:41 +02:00
Dom
41eba898c0 feat(agent_v1): FeedbackBusClient — client SocketIO pour bus 'lea:*'
Consomme les events 'lea:*' émis par agent_chat (port 5004) et les dispatche
vers un callback fourni par ChatWindow (J3.3 à venir).

Caractéristiques :
- Connexion en thread daemon (non-bloquant pour la mainloop tkinter)
- Reconnect auto illimité (delay 2s → 30s exponentiel)
- Auth Bearer Token via header HTTP au handshake
- Fail-safe : connect échoué, callback qui raise, disconnect qui raise
  → tout silencieusement loggé, ChatWindow continue normalement

13 tests pytest verts (tests/integration/test_feedback_bus_client.py).
Pas de connexion réseau réelle dans les tests (python-socketio mocké).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:43:26 +02:00
Dom
9452e86fd1 deps(agent_v1): python-socketio[client] pour bus feedback Léa
Compatible Flask-SocketIO 5.3.x côté serveur. Ajouté aux deux requirements
client (agent_v1/ et deploy/windows_client/) — le second est utilisé par
l'installeur Pauline (setup_v1.bat).

ATTENTION : redéploiement client requis (PC Windows + VM Linux) avant la démo
GHT Sud 95. La dep ne sert à rien tant que J3.2 (FeedbackBusClient) n'est pas en
place ; aucun impact runtime sur l'agent V1 actuel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:53:40 +02:00
Dom
5e31cdf666 feat(agent_chat): bus feedback Léa 'lea:*' derrière flag LEA_FEEDBACK_BUS
Surface d'observation pour bulles temps réel ChatWindow (J2 démo GHT Sud 95).

- Helper _emit_lea(event, payload): no-op silencieux si flag off
- Helper _emit_dual(legacy, lea, payload): émet event existant + alias 'lea:*'
- Détection paused_need_help dans _poll_replay_progress → lea:paused
- Détection sortie de pause → lea:resumed
- Timeout étendu (120s→600s) pendant pause supervisée
- 12 emits SocketIO existants aliasés (execution_started/progress/completed,
  copilot_step/step_result/complete) — payloads identiques, zéro régression

Flag LEA_FEEDBACK_BUS=0 par défaut. Comportement legacy strictement préservé.
8 tests pytest verts (tests/integration/test_feedback_bus.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:48:38 +02:00
Dom
487bcb8618 feat(execution): cascade post-raccourci pilotée par DialogHandler/OCR
Le pHash global 8x8 sur écran 1920x1080 ne détecte pas l'ouverture d'un
dialog modal dans une VM QEMU (un dialog 800x500 couvre ~3 pixels pHash,
distance Hamming typique = 1-2, sous le seuil de 3). Découvert sur Win11/
Notepad : Ctrl+Shift+S ouvrait bien le dialog mais Léa abortait à tort.

_handle_post_shortcut() poll désormais DialogHandler.handle_if_dialog()
toutes les 500ms (EasyOCR + KNOWN_DIALOGS). 8s pour le premier dialog,
3s de stabilité entre dialogs successifs, 60s budget total.

KNOWN_DIALOGS réordonné : popups modaux (confirmer/remplacer/écraser)
prioritaires sur fenêtres parents (enregistrer sous/save as) car l'OCR
full-screen capte les deux simultanément.

DialogHandler bascule sur UITarsGrounder subprocess one-shot (au lieu
du serveur HTTP localhost:8200 qui n'existait plus). InfiGUI worker,
think_arbiter et ui_tars_grounder alignés sur le même contrat.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-26 20:19:39 +02:00
Dom
3d6868f029 docs: cartographie complète d'exécution + fix target_text ORA + worker InfiGUI fichiers
docs/CARTOGRAPHY.md :
- Carte complète des 2 chemins d'exécution (Legacy vs ORA)
- 12 systèmes de grounding identifiés dont 3 morts
- Trace du champ target_text de la capture au clic
- Fonctions existantes non branchées (verify, recovery, ShadowLearningHook)
- Budget VRAM, fichiers critiques, règles de modification

Fix target_text ORA (observe_reason_act.py:217) :
- Détecte les target_text absurdes ("click_anchor")
- Appelle _describe_anchor_image() (VLM) pour décrire le crop
- Même logique que le legacy execute.py:893

Worker InfiGUI via fichiers /tmp :
- Communication par fichiers (pas subprocess pipes, pas HTTP)
- Process indépendant lancé avant le backend
- Résout le crash CUDA dans Flask/FastAPI/uvicorn

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:37:43 +02:00
Dom
f73a2a59a9 feat(réflexes): patterns overwrite/dont_save + handler EasyOCR + prints diagnostic
Nouveaux patterns :
- dialog_overwrite : "voulez-vous remplacer/écraser", "fichier existe déjà" → Oui
- dialog_dont_save : "ne pas enregistrer", "quitter sans enregistrer" → Ne pas enregistrer

Handler amélioré (handle_detected_pattern) :
- EasyOCR au lieu de docTR (meilleure lecture des boutons GUI)
- Match par inclusion (pas seulement exact)
- Suppression fallback VLM (Ollama n'a plus de VRAM)
- Prints visibles pour diagnostic

28 patterns au total, testés sur 6 dialogues types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 04:26:32 +02:00
Dom
77faa03ec9 feat(grounding): InfiGUI-G1-3B remplace UI-TARS 7B — 3.5x moins de VRAM
Serveur de grounding (server.py) :
- InfiGUI-G1-3B au lieu de UI-TARS-1.5-7B
- VRAM : 2.25 GB au lieu de 8.4 GB (6.6 GB libres)
- Prompt officiel InfiGUI (system <think> + user point_2d JSON)
- max_new_tokens=512, parsing JSON point_2d
- 4/4 éléments trouvés : Demo 5px, Chrome 98px, Corbeille 15px, Search 66px
- Fallback UI-TARS via env GROUNDING_MODEL=ByteDance-Seed/UI-TARS-1.5-7B

EasyOCR : retour sur GPU (assez de VRAM maintenant) → 192ms au lieu de 2.5s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 04:07:00 +02:00
Dom
343d6fbe95 perf(ocr): EasyOCR remplace docTR dans FastDetector + TitleVerifier
FastDetector : EasyOCR GPU en singleton (~192ms vs 1300ms docTR = 6.8x)
- "Corbeille" lu correctement (docTR lisait "Gorbeille")
- "Google Chrome" en deux mots propres
- Détection complète (RF-DETR + OCR) en 313ms à chaud
- Fallback docTR si EasyOCR non disponible

TitleVerifier : EasyOCR pour le crop titre (fallback docTR)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 03:32:43 +02:00
Dom
cc64439738 feat(grounding): vérification titre OCR post-action (non-bloquante)
TitleVerifier (core/grounding/title_verifier.py) :
- Crop 45px barre de titre → OCR → compare avant/après (~280ms)
- Titres < 3 chars ignorés (bruit OCR sur VM)
- Non-bloquant : échec = warning, pas stop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 03:22:50 +02:00
Dom
90007cc7c1 perf(grounding): réflexe pHash-only + max_new_tokens 64
Réflexe check : déclenché uniquement si pHash change (popup inattendu),
plus d'OCR full screen systématique à chaque step. Gain ~9s/workflow.

Serveur grounding : max_new_tokens 256→64 (la réponse fait ~20 tokens).

Validé : 5+ tests consécutifs 7/7, apprentissage actif
(CR_patient en fast_exact_text 2.2s, Feuille calcul en template 83ms).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 03:07:35 +02:00
Dom
73cea2385e feat(grounding): Phase 6 — Shadow Learning Hook
ShadowLearningHook (core/grounding/shadow_learning_hook.py) :
- Hook optionnel pour le ShadowObserver
- Chaque clic humain observé → FastDetector détecte l'élément sous le clic
- SignatureStore enrichie avec texte, type, position, voisins (conf=1.0)
- Au replay : SmartMatcher utilise la signature apprise → matching < 1ms

Validé : 3 clics simulés → 3 signatures créées avec les bonnes métadonnées.
Module standalone — ne modifie pas le ShadowObserver existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:00:11 +02:00
Dom
e2046837cf feat(grounding): Phase 5 — intégration pipeline FAST→SMART→THINK dans ORA
_act_click() utilise maintenant le pipeline FAST→SMART→THINK :
- Feature flag RPA_USE_FAST_PIPELINE=1 (activé par défaut)
- RPA_USE_FAST_PIPELINE=0 pour rollback sur l'ancien pipeline
- Si le nouveau pipeline échoue → fallback automatique template→OCR→static
- Pre-check VLM désactivé (le pipeline valide visuellement)
- Capture unique de l'écran partagée entre tous les layers

Rollback instantané : unset RPA_USE_FAST_PIPELINE
Tests : 37 passed, 0 régression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:57:56 +02:00
Dom
b30d4b6656 feat(grounding): Phase 4 — Pipeline orchestré FAST→SMART→THINK
FastSmartThinkPipeline (core/grounding/fast_pipeline.py) :
- Cascade : FAST detect (120ms) → SMART match (<1ms) → THINK VLM si doute (3s)
- Seuils : ≥0.90 action directe, 0.60-0.90 VLM confirme, <0.60 VLM cherche
- Apprentissage automatique : SignatureStore enrichie à chaque succès
- Ancien pipeline en fallback (safety net)
- Singleton via get_instance()

Validé sur 5 éléments :
- 1ère exécution : 5/5 OK via smart_think_confirmed (24.5s total)
- 2ème exécution : 4/5 en FAST direct, 1/5 en THINK (10.5s total)
- L'apprentissage réduit le temps de 20x par élément connu

Module standalone — aucun impact sur le système existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:54:40 +02:00
Dom
e4a48e78bf feat(grounding): Phase 3 — ThinkArbiter + SignatureStore
ThinkArbiter (core/grounding/think_arbiter.py) :
- Client HTTP vers le serveur UI-TARS (port 8200)
- Appelé uniquement si SmartMatcher score < 0.60
- Vérifie la disponibilité du serveur avant appel
- Validé : Demo trouvé à (1479, 183) en 3.6s

SignatureStore (core/grounding/element_signature.py) :
- Stockage SQLite des signatures d'éléments UI apprises
- record_success() enrichit la signature (texte, type, position, voisins)
- record_failure() incrémente le compteur d'échecs
- lookup() avec fallback (contexte exact → toutes variantes)
- Validé : 3 succès → conf_moy=0.917, voisins enrichis

Modules standalone — aucun impact sur le système existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:44:12 +02:00
Dom
ea36bba5cc feat(grounding): Phase 1-2 pipeline FAST→SMART — détection + matching
Phase 1 — FastDetector (core/grounding/fast_detector.py) :
- Détection RF-DETR de tous les éléments UI (~120ms à chaud)
- Enrichissement OCR (texte, voisins, position relative)
- Cache pHash (même écran → résultat instantané)
- 23 éléments détectés sur le benchmark, positions correctes

Phase 2 — SmartMatcher (core/grounding/smart_matcher.py) :
- Matching déterministe : texte exact (score 0.95) puis fuzzy (0.70+)
- Matching probabiliste : type, position, voisins contextuels
- Score combiné pondéré → seuil de confiance
- 5/5 éléments trouvés en < 1ms, 0 faux positif
- "Gorbeille" matche "Corbeille" par fuzzy (score 0.678)

Structures (core/grounding/fast_types.py) :
- DetectedUIElement, ScreenSnapshot, MatchCandidate, LocateResult
- Compatible GroundingResult via to_grounding_result()

Modules standalone — aucun impact sur le système existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:37:14 +02:00
Dom
9da589c8c2 feat(grounding): pipeline centralisé + serveur UI-TARS transformers + nettoyage code mort
Architecture grounding complète :
- core/grounding/server.py : serveur FastAPI (port 8200) avec UI-TARS-1.5-7B en 4-bit NF4
  Process séparé avec son propre contexte CUDA (résout le crash Flask/CUDA)
- core/grounding/pipeline.py : orchestrateur cascade template→OCR→UI-TARS→static
- core/grounding/template_matcher.py : TemplateMatcher centralisé (remplace 5 copies)
- core/grounding/ui_tars_grounder.py : client HTTP vers le serveur de grounding
- core/grounding/target.py : GroundingTarget + GroundingResult

ORA modifié :
- _act_click() : capture unique de l'écran envoyée au serveur de grounding
- Pre-check VLM skippé pour ui_tars (redondant, et Ollama n'a plus de VRAM)
- verify_level='none' par défaut (vérification titre OCR prévue en Phase 2)
- Détection réponses négatives UI-TARS ("I don't see it" → fallback OCR)

Nettoyage :
- 9 fichiers morts archivés dans _archive/ (~6300 lignes supprimées)
- 21 tests ajoutés pour TemplateMatcher

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 17:48:18 +02:00
Dom
16ff396dbf chore: sauvegarde pré-stabilisation — audit 66/66 tests OK
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
security-audit / Bandit (scan statique) (push) Successful in 1m7s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
Audit qualité : 0 bug critique, 5 points dette technique (post-démo).
Boucle ORA fonctionnelle : UI-TARS + pré-vérification + recovery Win+D.
Script test_instruction.sh ajouté.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 09:14:56 +02:00
Dom
e44fd7b328 fix(ORA): double-clic fiable + vérification stricte
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Double-clic : moveTo + 2 clics explicites (pyautogui.doubleClick ne
traverse pas toujours la VM). Délai 80ms entre les clics.

Vérification : un double-clic DOIT produire un changement majeur
(ouverture fichier/dossier). Changement mineur = échec → retry.
Les clics simples et hotkeys gardent la tolérance actuelle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 08:45:40 +02:00
Dom
66815b7a1a fix(ORA): pattern None quand overlay est une fenêtre (pas un dialogue)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
pattern.get() crashait car pattern=None quand l'overlay n'est pas
un dialogue connu. Ajout de guard None.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 08:22:12 +02:00
Dom
c6b695eca8 fix(ORA): Win+D via xdotool key au lieu de pyautogui.hotkey
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
pyautogui.hotkey('super','d') ne traverse pas la VM.
xdotool key super+d avec setxkbmap fr fonctionne.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 08:15:47 +02:00
Dom
99d2083dea fix(ORA): moveTo + pause + click + pause + Win+D (séquence validée par Dom)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:55 +02:00
Dom
a718086140 fix(ORA): xdotool windowactivate QEMU + key super+d pour focus VM
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 10s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
pyautogui.click cliquait SUR Chrome. xdotool search --name QEMU
trouve la fenêtre VM et la force au premier plan avant Win+D.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 18:08:10 +02:00
Dom
c82979e72b fix(ORA): clic centre écran pour focus VM avant Win+D
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:45:05 +02:00
Dom
2185c41cc1 fix(ORA): Win+D au lieu de Alt+Tab pour le recovery overlay
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 13s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Alt+Tab bascule entre fenêtres. Win+D affiche le bureau Windows.
Plus fiable quand l'élément cible est sur le bureau.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:19:06 +02:00
Dom
26804eb123 fix(ORA): Alt+Tab au lieu de windowminimize pour le recovery overlay
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
windowminimize minimisait en boucle toutes les fenêtres (VM incluse).
Alt+Tab bascule juste le focus sans rien fermer/minimiser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:09:38 +02:00
Dom
d71d5df4a8 fix(ORA): overlay = minimiser la fenêtre devant, pas juste chercher OK
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Quand la pré-vérification dit NO et qu'aucun pattern de dialogue n'est
détecté, c'est une fenêtre quelconque qui masque la cible (Chrome, etc).
xdotool windowminimize pour la dégager.

Classification améliorée : pré-check rejeté → OVERLAY_BLOCKING
(avant c'était ELEMENT_NOT_FOUND → scroll inutile).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:03:18 +02:00
Dom
6829ad8e79 feat(ORA): classification erreurs + recovery intelligent
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 13s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
4 types d'erreurs : ELEMENT_NOT_FOUND, OVERLAY_BLOCKING,
WRONG_SCREEN, ACTION_NO_EFFECT.

Recovery spécialisé par type :
- Element introuvable → attente + scroll + retry UI-TARS élargi
- Overlay bloquant → détection pattern + fermeture auto + retry
- Mauvais écran → description VLM + Alt+Tab + recherche taskbar
- Pas d'effet → double-clic + délai + coordonnées décalées

Intégré dans run_workflow() : classification → recovery → re-vérif.
Échec total → pause supervisée (pas de stop brutal).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 16:44:31 +02:00
Dom
8903f35433 feat(ORA): vérification pré-action — VLM confirme avant chaque clic
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Avant de cliquer, crop 200x100 autour de la position cible envoyé
au VLM (qwen2.5vl:3b) : "Is this UI element 'CR_patient_demo'? YES/NO"

Si NO → abandon du clic, évite les clics erronés.
Si erreur VLM → laisse passer (pas bloquant).
Skippé pour le template matching (confiance pixel suffisante).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 16:22:37 +02:00
Dom
4ab2c15e5c fix(ORA): logger.info→print pour que les logs apparaissent dans nohup
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Le logging Python ne traverse pas le nohup de Flask. Tous les autres
modules (execute.py, intelligent_executor.py) utilisent print().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 16:16:25 +02:00
Dom
eba6fea779 refactor(ORA): UI-TARS en PREMIER pour les clics
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 15s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Ordre : UI-TARS (3s, 94%) → Template (80ms) → OCR (1s)

UI-TARS dit "click on CR_patient_demo" et trouve les coordonnées
comme un humain. Le template matching échoue sur les icônes Windows
(micro-différences visuelles → score 0.38 au lieu de 0.95).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:59:45 +02:00
Dom
f04398d5a7 fix: VLM décrit TOUJOURS l'ancre à la capture, pas seulement si OCR échoue
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
L'OCR seul donnait du bruit (\"- C\", \"emo\"). Le VLM (qwen2.5vl:3b)
est maintenant appelé systématiquement pour décrire l'ancre en 5 mots
(\"folder icon named Demo\", \"search bar with magnifier icon\").

Le target_text utilise l'OCR si lisible, sinon la description VLM.
La description VLM est toujours stockée dans ocr_description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:30:19 +02:00
Dom
4ce9c47f45 fix(ORA): logs stdout + vérification pHash tolérante pour clics
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Logs : forcer le handler stdout pour que les logs ORA apparaissent
dans nohup (logger.info n'écrivait nulle part).

Vérification : un clic avec confiance >= 0.7 est accepté même si
l'écran ne change pas (pHash same). Un clic sur un champ de saisie
ne modifie quasi pas l'écran mais est légitime.
Changement mineur toujours accepté (plus de condition confiance > 0.9).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:04:13 +02:00
Dom
9dfcdb5fb0 fix: ajouter 'verified' dans la liste des modes du toggle
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 19s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 14:48:41 +02:00
Dom
3efe15d2c7 feat(vwb): ajout mode 'Vérifié' dans le sélecteur d'exécution
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 14:36:06 +02:00
Dom
9d87ed64c5 fix: corrections audit qualité — stop/pause ORA + nettoyage debug
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
CRITIQUE : ajout should_continue callback dans ORALoop pour supporter
les boutons Stop/Pause du frontend en mode verified et instruction.

HAUTE : suppression sys.stdout.write de debug, logger.warning→debug
dans _grounding_ocr.

BASSE : suppression import mort 'field' dans observe_reason_act.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 11:17:20 +02:00
Dom
00134963e5 test: 16 tests unitaires pour la boucle ORA
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Tests ORALoop init, Decision, reason_workflow_step (click, type,
hotkey, wait, passthrough), verify (none, wait, done), run_workflow
(empty, too_many), run_instruction (méthodes existent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 10:43:28 +02:00
Dom
0ec5e2a25b feat: instructions en langage naturel via boucle ORA
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 11s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
reason_instruction() : le VLM regarde l'écran, décide la prochaine
action atomique (click/type/hotkey/scroll/done), retourne un Decision
avec expected_after pour la vérification.

run_instruction() : boucle ORA complète pour instructions texte.
CognitiveContext mis à jour à chaque étape (objectif, historique,
faits appris, confiance).

POST /api/v3/execute/instruction : endpoint API pour lancer une
instruction en langage naturel. Thread daemon, polling du résultat
via GET /api/v3/execute/instruction/result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 09:09:53 +02:00
Dom
0c5fffe951 feat: boucle ORA (observe→raisonne→agit) avec vérification post-action
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Nouveau module core/execution/observe_reason_act.py (794 lignes) :
- ORALoop : boucle unifiée pour workflow VWB et instructions
- observe() : capture écran + pHash + titre fenêtre
- reason_workflow_step() : mappe step VWB → Decision (sans VLM)
- act() : template matching → find_element → pyautogui
- verify() : Level 1 pHash + Level 2 VLM conditionnel
- run_workflow() : boucle complète avec retries et callbacks

Nouveau mode execution_mode='verified' dans execute.py :
- run_workflow_verified() utilise ORALoop
- Modes basic/intelligent/debug inchangés (zéro risque)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 09:02:54 +02:00
Dom
5027ed9a23 chore: sauvegarde workflows.db après 23 tests de fiabilité réussis
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
23/24 tests du workflow Demo PMSI réussis (1 échec = main sur souris).
Template matching en premier (~80ms), CLIP batch en fallback (~4.5s).
Total workflow : ~20s (était 131s il y a 24h).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 20:15:22 +02:00
Dom
6caab2c600 perf: boucle fermée pHash (2s→150ms) + batch CLIP (90 appels→1)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Boucle fermée : time.sleep(2.0) remplacé par _wait_for_screen_change()
qui poll le pHash toutes les 150ms. Sort dès que l'écran change.
4 occurrences remplacées.

Batch CLIP : filtre par distance AVANT le CLIP (90→~20 éléments),
puis embed_image_batch() en un seul appel GPU + np.dot vectorisé.

Estimé : 42s→~20s total workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 19:33:42 +02:00
Dom
552e66dbf6 fix: import io manquant dans template matching
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 19:21:15 +02:00
Dom
de1026ee2e perf: template matching direct en PREMIER (~1-10ms)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
cv2.matchTemplate cherche l'ancre directement dans le screenshot.
Pas de RF-DETR, pas de CLIP, pas de 90 comparaisons.
Seuil 0.75 pour éviter les faux positifs.

Ordre : template (1ms) → CLIP (fallback) → OCR/UI-TARS (dernier recours)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 19:17:08 +02:00
Dom
7b50725bf8 perf: RF-DETR sur GPU (cuda) — était sur CPU = 28s par étape
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
RF-DETR détecte 90+ éléments UI par screenshot. Sur CPU = 28s.
Sur GPU RTX 5070 = devrait être 1-3s.

CLIP auto-GPU déjà en place (vérifie 1.5 Go VRAM libre).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 18:54:19 +02:00
Dom
7feef3b6a9 fix: CLIP en premier, suppression vérification OCR croisée, fix indentation
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 18:36:20 +02:00
Dom
0b06db222d fix: activer la fenêtre cible après minimisation du navigateur VWB
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Après minimisation du navigateur, xdotool active la fenêtre suivante
(VM QEMU, app cible). Avant, le terminal restait au premier plan →
mss capturait le terminal au lieu de la VM.

Cause racine de tous les échecs de matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 18:21:55 +02:00
Dom
74ee0dadee perf: pré-chargement docTR au démarrage + nettoyage debug logs
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
docTR se chargeait au premier appel OCR (~30s). Maintenant pré-chargé
au démarrage du backend → premier clic rapide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 17:25:35 +02:00
Dom
0b452f975a fix: pénaliser matchs OCR partiels trop courts (demo dans CR_patient_demo)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:49:22 +02:00
Dom
6ab385d671 fix(grounding): OCR collecte TOUS les matchs + choisit le plus proche de l'ancre
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Avant : OCR retournait le premier match → cliquait sur la barre de titre
("CR_patient_demo" dans le path) au lieu du fichier dans la liste.

Après : collecte tous les matchs, choisit le plus proche de la position
originale de l'ancre (anchor_bbox). Si pas de bbox, prend le plus central.

Élimine les clics sur les barres de titre, breadcrumbs, menus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:40:15 +02:00
Dom
b3eab83a0f fix: variable 'result' non définie quand grounding réussit sans CLIP
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:26:45 +02:00
Dom
27490849a8 refactor: OCR/UI-TARS en PREMIER, CLIP en fallback
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Le grounding par texte (OCR → UI-TARS) est maintenant la méthode
PRINCIPALE. CLIP n'est appelé que si le grounding échoue.

Avant : CLIP (faux positifs confiants) → cascade grounding (rarement atteinte)
Après : OCR 1s → UI-TARS 3s → CLIP (fallback visuel pur)

C'est comme ça que font UI-TARS, Agent-S3 et AppAgent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:40:38 +02:00
Dom
cebbf0809a fix: timeout VLM 15→60s + OCR zone élargie autour de l'ancre
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:05:38 +02:00
Dom
3e227d28ad fix(vwb): image plein écran — calcul dimensions JS explicite (fix définitif)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Cause racine : max-width/max-height CSS ne font pas GRANDIR une image.
Fix : calcul explicite width/height en JS via Math.min(ratio).
min-height:0 sur le conteneur flex.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 12:19:30 +02:00
Dom
8ce63fcba2 fix(vwb): CSS max-height 100% → calc(100vh-70px) — cause racine du timbre poste
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 17s
tests / Tests sécurité (critique) (push) Has been skipped
Le fichier CSS avait max-height:100% sur .fullscreen-content img
qui écrasait le style inline calc(100vh-70px). 100% d'un conteneur
flex sans hauteur explicite = taille naturelle de l'image = minuscule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 12:10:24 +02:00
Dom
4202431421 fix(vwb): image plein écran maxHeight calc(100vh-70px) basé sur viewport
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 17s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:58:58 +02:00
Dom
4923623dd4 fix(vwb): bibliothèque ne s'écrase plus au chargement
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Le useEffect(saveLibrary) se déclenchait avec library=[] avant que
loadLibraryAsync ait fini → écrasait le fichier serveur avec un
tableau vide. Ajout d'un flag libraryLoaded pour ne sauvegarder
qu'après le chargement initial.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:54:16 +02:00
Dom
84181cc982 feat: analyse OCR+VLM de l'ancre à la capture (pas à l'exécution)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Quand l'utilisateur sélectionne une ancre dans le VWB :
1. OCR docTR extrait le texte du crop → target_text
2. Si texte < 3 chars → VLM qwen2.5vl:3b décrit en 5 mots
3. Stocké en BDD (VisualAnchor.target_text + ocr_description)
4. Injecté automatiquement dans les params à l'exécution

L'exécution sait maintenant QUOI chercher dès le départ :
- CLIP vérifie par OCR que le texte correspond
- Le grounding cascade a un vrai target_text
- Plus besoin de deviner à chaque run

Migration SQLite gracieuse (ALTER TABLE si colonnes absentes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:26:30 +02:00
Dom
7355d315a3 fix: vérification croisée CLIP+OCR + description ancre avant exécution
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Quand CLIP dit "trouvé", on vérifie par OCR que le texte à cette
position correspond au target. Si CLIP clique sur "Ce PC" au lieu
de "CR_patient_demo", l'OCR le rejette → fallback sur la cascade.

Description VLM de l'ancre AVANT le CLIP quand le label est un
type d'action (double_click_anchor → "text file icon CR_patient").
Le target_text enrichi sert à la vérification croisée ET au grounding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:10:01 +02:00
Dom
c50adab3a1 fix: aligner capture monitors[0] partout (cause de la régression)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
La capture VWB utilisait monitors[0] (composite) mais l'exécution
utilisait monitors[1] (premier écran). Images incompatibles → CLIP
retournait 0.00 sur un écran identique.

Tous les fichiers alignés sur monitors[0].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:52:13 +02:00
Dom
2fbb305f65 fix: remonter seuil CLIP à 0.45 — le 0.20 créait des faux positifs
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Le seuil 0.20 faisait que CLIP cliquait sur Chrome au lieu du dossier
Demo (score 0.25 accepté = faux positif). Le seuil 0.45 rejette les
matchs faibles et la cascade OCR/UI-TARS prend le relais proprement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:39:02 +02:00
Dom
ff581be397 perf: seuil CLIP 0.45→0.20 + cache singleton IntelligentExecutor
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Seuil CLIP abaissé pour les icônes génériques (dossier, fichier)
qui obtenaient 0.25 au lieu de 0.45.

IntelligentExecutor en singleton — CLIP et RF-DETR chargés une
seule fois et réutilisés entre les étapes. Élimine le rechargement
de ~40s par étape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:29:15 +02:00
Dom
203e5cc6c1 fix(grounding): désactiver orchestrateur VRAM pendant exécution + qwen2.5vl:3b pour description
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 16s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
L'orchestrateur VRAM redémarrait Ollama en pleine exécution → timeout.
Désactivé pendant le workflow. L'orchestrateur reste disponible pour
bascule manuelle avant/après.

Description ancre via qwen2.5vl:3b (3 Go) au lieu de 7b — tient en VRAM
sans décharger CLIP ni RF-DETR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:16:27 +02:00
Dom
d1b556b6cd fix(grounding): supprimer SeeClick cassé + log description ancre
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
SeeClick supprimé : modèle HF incompatible (QWenConfig non reconnu),
crashait à chaque exécution et polluait les logs.
Remplacé par UI-TARS via la chaîne de grounding.

Log warning visible quand la description VLM de l'ancre échoue
(pour diagnostiquer les problèmes de VRAM).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:05:29 +02:00
Dom
729cd67743 feat(grounding): description VLM de l'ancre quand le label est vide
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Quand le target_text est vide ou identique au type d'action
(click_anchor, double_click_anchor...), le VLM décrit l'image
de l'ancre en 5 mots ("folder icon named Demo").

Cette description est ensuite passée à UI-TARS pour le grounding
("click on folder icon named Demo") et à l'OCR pour la recherche.

Chaîne complète : VLM décrit → OCR cherche → UI-TARS grounding → VLM raisonne.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:44:19 +02:00
Dom
73ddcdb29d feat: chaîne de grounding 3 niveaux + refonte capture écran
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Grounding en cascade quand CLIP/template échouent :
1. OCR (docTR) → cherche le texte exact sur l'écran (~1s)
2. UI-TARS grounding → "click on X" → coordonnées (~3s, 94% ScreenSpot)
3. VLM reasoning → raisonnement complet + confirmation OCR (~10s)

find_element_on_screen() dans input_handler.py (partagé VWB + Léa).
Câblé dans find_and_click() et execute_action() comme fallback.

Refonte capture écran :
- mss.monitors[0] (composite) pour capturer la VM en plein écran
- FullscreenSelector réécrit : overlay via getBoundingClientRect()
- Bboxes et sélection alignées avec l'image (calcul JS, pas CSS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:31:38 +02:00
Dom
14a9442343 refactor(vwb): refonte complète capture écran — stable définitivement
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
FullscreenSelector réécrit :
- Overlay unique positionné via getBoundingClientRect()
- Recalcul auto au resize
- Coordonnées souris relatives à l'image
- Plus de décalage bboxes/sélection

Capture backend :
- mss.monitors[0] (écran composite) au lieu de pyautogui.screenshot()
- Capture la VM en plein écran correctement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:03:19 +02:00
Dom
5da4581e76 feat(cognition): orchestrateur VRAM + VLM 7b par défaut
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
VRAMOrchestrator : bascule automatique entre modes SHADOW et REPLAY.
- SHADOW : streaming server + agent_chat actifs
- REPLAY : VLM qwen2.5vl:7b chargé, services non-essentiels stoppés

vlm_reason_about_screen() appelle ensure_reasoning_ready() avant
chaque raisonnement — libère la VRAM si nécessaire.

Benchmark : qwen2.5vl:7b en 10s (warm) vs 44s quand VRAM saturée.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 22:13:29 +02:00
Dom
cbe8dc95d2 feat(cognition): timing + écran attendu + auto-apprentissage Shadow + VLM qwen2.5vl
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Mémoire de travail enrichie :
- Timing par étape (durée, moyenne, alerte si lent)
- Écran attendu vs observation réelle
- Contexte VLM étendu

VLM reasoning : default qwen2.5vl:3b (gemma4 ne supporte pas vision)

Auto-apprentissage Shadow :
- stream_processor apprend les dialogues automatiquement
- Clic utilisateur après dialogue → pattern mémorisé
- Sauvegardé dans data/learned_patterns.json

GUI-R1 : 10 patterns additionnels extraits du dataset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:52:45 +02:00
Dom
04a14a56b2 feat(cognition): mémoire de travail — Léa sait où elle en est
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
CognitiveContext : bloc-notes interne réinjecté à chaque décision.
- objective : ce que Léa essaie de faire
- current_step : progression dans le plan
- action_history : les N dernières actions (succès/échec)
- learned_facts : faits appris pendant l'exécution
- confidence : auto-évaluation (baisse sur échec)
- needs_help : demande d'aide à l'humain
- to_prompt_context() : génère le texte pour le VLM

Module standalone, pas encore câblé dans l'executor.
Testé sur scénario de facturation OSIRIS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:41:10 +02:00
Dom
2290f1846b feat(cognition): raisonnement VLM quand les réflexes ne suffisent pas
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
vlm_reason_about_screen() : capture l'écran, envoie au VLM local
(gemma4/Ollama) avec l'objectif et le contexte, retourne une action
en JSON (click/type/wait/nothing + target + reasoning).

Chaîne de décision :
1. Réflexes (UIPatternLibrary) → instantané
2. OCR bouton (docTR) → rapide
3. VLM reasoning (Ollama) → intelligent, ~2-5s

Le VLM intervient UNIQUEMENT quand 1 et 2 échouent — pas de latence
ajoutée quand les réflexes suffisent.

UIPatternLibrary enrichie : charge builtin + GUI-R1 + learned patterns.
save_learned_pattern() persiste les patterns appris par Shadow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:37:03 +02:00
Dom
c57b40ae1d feat: CLIP auto-GPU si >1.5 Go VRAM libre + index FAISS IVF 11.5x plus rapide
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
CLIP embedder : auto-détection GPU avec vérification VRAM disponible.
Si >1.5 Go libre → CUDA, sinon → CPU. Évite les OOM quand Ollama
utilise déjà la VRAM.

FAISS : migration Flat → IVF (116 clusters, nprobe=8).
Benchmark : 0.46ms → 0.04ms par recherche (11.5x).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:27:01 +02:00
Dom
bc21b27da7 fix(dashboard): diagrammes BPMN/DFG grande taille (DPI 150, layout vertical)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Les images générées par PM4Py étaient trop petites et illisibles.
- DPI 150, taille 40x20 pouces, layout vertical (TB)
- La modale plein écran permet le défilement (scroll)
- Fallback sur pm4py.save_vis si le rendu Graphviz échoue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:29:49 +02:00
Dom
6a2248ddcd feat(dashboard): clic plein écran sur les images cartographie
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Modale fullscreen au clic sur les diagrammes BPMN/DFG.
Fermeture par clic ou Échap. Les images sont illisibles en miniature.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:26:05 +02:00
Dom
82d7b38cff feat(dashboard): page Base de connaissances — métriques FAISS, sessions, patterns
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Nouvelle page /knowledge-base avec :
- Mémoire visuelle : 331 vecteurs FAISS / 13666 embeddings (alerte consolidation)
- Sessions observées : 56 sessions, 6.66 Go, 3 machines
- Réflexes natifs : 16 patterns UI en 6 catégories
- Workflows appris : 29

Onglet 📚 Connaissances ajouté dans toute la navigation.
Tout en français, dark theme, zéro jargon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:41:23 +02:00
Dom
6c7f88c05d refactor: factorisation input_handler partagé + page cartographie processus
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
core/execution/input_handler.py (NOUVEAU) :
- safe_type_text() : setxkbmap fr + xdotool, partagé entre les 2 executors
- check_screen_for_patterns() : détection dialogues UI via OCR
- handle_detected_pattern() : clic bouton par OCR (mot exact, le plus bas)
- post_execution_cleanup() : vérification post-workflow

VWB executor : suppression du code dupliqué, alias vers input_handler
Core executor : pyautogui.write() remplacé par safe_type_text()

Page dashboard "Cartographie des processus" :
- GET /process-mining : vue analyse des flux de travail
- POST /api/process-mining/discover : génère BPMN + indicateurs
- 4 cartes indicateurs, diagramme, points d'attention, variantes
- Dark theme, français, zéro jargon technique
- Onglet ajouté dans la navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:08:37 +02:00
Dom
447fbb2c6e chore: sauvegarde complète avant factorisation executor
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Point de sauvegarde incluant les fichiers non committés des sessions
précédentes (systemd, docs, agents, GPU manager).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:03:44 +02:00
Dom
623be15bfe fix(knowledge): triggers courts en mot entier + cookies trigger enrichi
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 12s
tests / Tests unitaires (sans GPU) (push) Failing after 12s
tests / Tests sécurité (critique) (push) Has been skipped
Les triggers ≤3 chars (ok, no) utilisent maintenant des frontières
de mots (\b) pour éviter les faux positifs (ok dans cookies).
Trigger "utilise des cookies" ajouté pour le pattern cookie_accept.

7/7 patterns validés en test terrain simulé.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:45:58 +02:00
Dom
55d5aebbd2 feat(knowledge): vérification post-workflow — dialogues restants
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Après la dernière étape, Léa vérifie l'écran et gère les dialogues
restants (jusqu'à 3 vérifications en cascade). Le workflow laisse
l'écran propre à la fin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:53:38 +02:00
Dom
73b731fef8 fix(knowledge): seuil OCR bouton 3→2 chars pour supporter OK et No
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 18s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Le filtre len<3 bloquait les boutons "OK" (2 chars) et "No" (2 chars).
Seuil abaissé à 2 — filtre les lettres isolées mais laisse passer
les boutons courts courants des dialogues Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:09:10 +02:00
Dom
ffd97ae9a5 feat(knowledge): détection et gestion automatique des dialogues UI
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 12s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
UIPatternLibrary câblée dans l'executor et le stream processor.
Pendant un wait_for_anchor, Léa surveille l'écran toutes les secondes :
1. OCR plein écran (docTR)
2. Pattern matching (dialogues Save, OK, Cancel, cookies...)
3. OCR ciblé pour trouver le bouton par son texte réel
4. Clic sur le match le plus bas (bouton, pas titre)

Fix : seuil ratio supprimé (trigger trouvé = match, quelle que soit
la longueur du texte OCR). Matching strict mot exact ≥3 chars
(évite les faux positifs sur lettres isolées). Fallback recherche
partielle pour les lettres soulignées (E_nregistrer).

Plus aucune coordonnée hardcodée — 100% vision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:06:17 +02:00
Dom
d168833609 fix: import Optional/Dict/Any pour _check_screen_for_patterns
Some checks failed
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:55:26 +02:00
Dom
23a06a744c feat(knowledge): câblage UIPatternLibrary dans executor + stream processor
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
VWB Executor :
- _check_screen_for_patterns() : capture écran + OCR + pattern matching
- _handle_detected_pattern() : clic automatique sur dialogues connus
- Vérifie entre chaque étape en mode intelligent/debug
- Si un dialogue bloque (OK, Save, Cancel), Léa le gère seule

Stream Processor :
- Enrichit les ScreenState avec ui_pattern/ui_pattern_action/ui_pattern_target
- Les patterns détectés sont loggés et stockés dans les résultats
- Permet au GraphBuilder de savoir quels écrans sont des dialogues

Phase 2 du plan "connaissance native de l'environnement".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:54:19 +02:00
Dom
af4eae28b9 feat(knowledge): base de connaissances UI — réflexes natifs pour Léa
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
UIPatternLibrary : 16 patterns builtin (dialogues, menus, formulaires,
popups, raccourcis) qui donnent à Léa des réflexes immédiats.

Quand Léa reconnaît "Voulez-vous enregistrer ?" elle sait cliquer
sur "Enregistrer" sans apprentissage préalable.

- core/knowledge/ui_patterns.py : bibliothèque avec find_pattern(),
  get_dialog_handler(), add_pattern() pour patterns appris
- Métadonnées GUI-R1 (3K exemples) extraites dans data/ (gitignored)

Phase 1 du plan "connaissance native de l'environnement".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:44:45 +02:00
Dom
c198c930a1 fix(vwb): capture plein écran — retirer height:0 + wrapper flex
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 12s
tests / Tests sécurité (critique) (push) Has been skipped
Le conteneur .fullscreen-content avait height:0 + min-height:0
qui écrasait la hauteur du flex child → image minuscule.
Le wrapper inline-block limitait aussi le dimensionnement.

Fix : overflow:hidden sans height forcée, wrapper en flex 100%.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:28:16 +02:00
Dom
e3efef2fe7 fix(vwb): noms workflows lisibles + bibliothèque captures persistante
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
CSS : le dropdown héritait color:white du header → forcé #212121
sur .workflow-dropdown et .dropdown-item .item-name

Bibliothèque : migration localStorage → backend (capture_library.json)
- GET/POST /api/v3/capture/library (max 50 captures)
- loadLibraryAsync() charge depuis backend, fallback localStorage
- saveLibrary() écrit dans les deux (localStorage + backend)
- capture_library.json gitignored

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 00:04:30 +02:00
Dom
95fddeebb3 fix(typing): setxkbmap fr avant xdotool type — fix AZERTY dans VM QEMU
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Le refresh du layout X11 juste avant xdotool type force le bon keymap.
Sans ça, xdotool envoie des keycodes décalés (: → M, / → !, etc.)
dans les VM spice/QEMU.

Solution trouvée via askubuntu.com/questions/914718.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:52:19 +02:00
Dom
71523cebd3 fix(typing): presse-papier en priorité (fonctionne avec spice-vdagent)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Remet xclip+Ctrl+V comme méthode prioritaire. Les spice tools sont
installés dans la VM → le presse-papier est partagé → pas de problème
de mapping clavier. xdotool envoie des événements synthétiques X11
que spice/QEMU traite différemment des vraies frappes (: → M).

Citrix partage aussi le clipboard nativement → même méthode en prod.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:27:54 +02:00
Dom
3aa806a630 fix(typing): hybride xdotool type+key — rapide et compatible AZERTY/VM
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
xdotool type pour les segments alphanumériques (un seul appel, rapide),
xdotool key avec keysym uniquement pour les caractères spéciaux
(:, /, @, etc.) qui cassent en AZERTY dans les VM.

Évite le subprocess par caractère (trop lent, effet visuel désagréable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:18:21 +02:00
Dom
588c8f22c1 fix(typing): xdotool key par keysym au lieu de type (fix AZERTY dans VM)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
xdotool type envoie des scancodes QWERTY — dans une VM AZERTY,
':' devient 'M', '/' devient '!', etc.

Nouvelle approche : xdotool key avec les noms de keysym X11
(colon, slash, period, etc.) qui sont indépendants du layout.
Chaque caractère est envoyé individuellement — plus lent mais
100% fiable en AZERTY/QWERTY, local ou VM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:15:44 +02:00
Dom
3d243d731d fix: xdotool prioritaire sur clipboard (VM/Citrix), cosmétique sidebar
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
safe_type_text() : xdotool type en priorité au lieu du presse-papier.
Le clipboard xclip ne traverse pas les VM (QEMU) ni Citrix/RDP.
xdotool envoie des frappes X11 réelles que les VM capturent.
Délai 20ms entre caractères pour fiabilité.

Cosmétique : couleur texte forcée sur les items workflow du sidebar
(color: var(--text-primary)) — était blanc sur blanc.

Logs diagnostic ajoutés dans execute_workflow_thread et execute_action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:11:10 +02:00
Dom
2431a6c9e9 fix(vision): dernier seuil distance hardcodé (150px→500px) + nettoyage commentaires
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
MAX_TEMPLATE_DISTANCE dans zoned_template_match était encore à 150px.
Tous les seuils de distance sont maintenant alignés à 500px :
- MAX_DISTANCE_PX (CLIP) : 500
- MAX_GLOBAL_DISTANCE (template global) : 500
- MAX_SEECLICK_DISTANCE : 500
- MAX_TEMPLATE_DISTANCE (template zonée) : 500

Commentaires périmés corrigés (plus de références aux anciennes valeurs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 19:52:20 +02:00
Dom
969236da03 fix(vision): distance max 500px pour template global et SeeClick
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Le template matching global trouvait l'icône Chrome à 0.99 de confiance
mais la rejetait car elle avait bougé de >150px. Même problème pour
SeeClick (>200px). Aligné tous les seuils de distance à 500px
pour supporter les workflows VWB cross-résolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 19:48:26 +02:00
Dom
f30461b88c fix(vision): seuils grounding assouplis pour VWB cross-résolution
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
MAX_DISTANCE_PX 120→500 (ancre peut être loin si résolution différente)
MIN_CLIP_SCORE 0.55→0.50 (tolérance basique suffisante)
MIN_COMBINED_SCORE 0.5→0.45 (accepter les matchs raisonnables)

L'icône Chrome à 81% de confiance était rejetée à cause de la distance.
Les workflows VWB manuels capturent sur un écran et s'exécutent
potentiellement sur un autre — la tolérance de distance doit être large.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 17:09:08 +02:00
Dom
f34eca20f9 fix(vwb): double accolades JSX dans CapturePanel et CaptureLibrary
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Corrige les src={{b64ImgSrc(...)}} → src={b64ImgSrc(...)} causés par
le replace_all sur les template literals. Corrige aussi l'appel
b64ImgSrc dans du code JS pur (pas de {} autour).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 16:49:58 +02:00
Dom
309dfd5287 feat: process mining BPMN, détection changement écran pHash, OCR docTR
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Process Mining (core/analytics/process_mining_bridge.py) :
- Bridge PM4Py : conversion sessions Shadow → event log → BPMN XML + PNG
- KPIs automatiques : durée, variantes, goulots, distribution par app
- Support sessions JSONL brutes et workflows core JSON
- 42 tests (dont 1 sur données réelles)

Détection changement d'écran (core/analytics/screen_change_detector.py) :
- pHash (imagehash) : ~16ms par screenshot, seuils SAME/MINOR/MAJOR
- 8 tests sur screenshots réels

OCR docTR dans execute_extract_text :
- docTR par défaut pour lecture simple (rapide, CPU)
- Ollama VLM en fallback ou sur demande explicite (mode "vlm"/"ai")
- Dual-mode adaptatif selon extraction_mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 13:07:56 +02:00
Dom
f5a672d7b9 fix(vwb): capture plein écran + auto-détection MIME PNG/JPEG des ancres
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 12s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
- CSS fullscreen-content : height:0 + min-height:0 pour forcer flex fill
- Image fullscreen : max-height calc(100vh - 60px) + object-fit contain
- Fonction b64ImgSrc() détecte automatiquement PNG vs JPEG depuis le base64
- Corrige l'affichage des thumbnails compressés JPEG dans la bibliothèque
- Appliqué dans CapturePanel + CaptureLibrary (toutes les occurrences)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 10:55:51 +02:00
Dom
1acea85fa6 feat(vwb): câblage 19 blocs, OCR réel, screenshots ancres, configs déploiement
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Dispatch execute_action élargi de 12 à 19 blocs opérationnels :
- 4 blocs souris (hover, drag_drop, scroll, focus) avec pyautogui
- extract_text via Ollama VLM (remplace stub hardcodé)
- 5 blocs ai_* redirigés vers execute_ai_analyze avec prompts adaptés
- screenshot_evidence (capture + sauvegarde PNG)
- verify_element_exists (détection visuelle CLIP)

Import workflows Léa enrichi :
- Bridge extrait anchor_image_base64 des edges
- Import crée VisualAnchor en DB + fichiers thumbnail sur disque
- PropertiesPanel affiche automatiquement les screenshots

Frontend :
- visual_condition et loop_visual masqués (hidden: true)
- Filtre dans ToolPalette pour exclure les blocs cachés

Déploiement :
- 2 configs agent (TIM Pauline + Dev Windows) avec machine_id unique
- 2 workflows démo dans la BDD (batch factures + extraction IA)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 09:40:28 +02:00
Dom
4f61741420 feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Pipeline E2E complet validé :
  Capture VM → streaming → serveur → cleaner → replay → audit trail
  Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise)

Dashboard :
  - Cleanup 14→10 onglets (RCE supprimée)
  - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable
  - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD
  - Formulaire Fleet simplifié (nom + email, machine_id auto)

VWB bridge Léa→VWB :
  - Compound décomposés en N steps (saisie + raccourci visibles)
  - Layout serpentin 3 colonnes (plus colonne verticale)
  - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows)
  - Fix import SQLite readonly

Cleaner intelligent :
  - Descriptions lisibles (UIA/C2) + détection doublons
  - Logique C2 : UIElement identifié = jamais parasite
  - Patterns parasites resserrés
  - Message Léa : "Je n'y arrive pas, montrez-moi comment faire"

Config agent (INC-1 à INC-7) :
  - SERVER_URL + SERVER_BASE unifiés
  - RPA_OLLAMA_HOST séparé
  - allow_redirects=False sur POST
  - Middleware réécriture URL serveur

CI Gitea : fix token + Flask-SocketIO + ruff propre
Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite
Backup : script quotidien workflows.db + audit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:46:40 +02:00
2007 changed files with 817186 additions and 2456 deletions

View File

@@ -46,6 +46,14 @@ LOGS_PATH=logs
UPLOADS_PATH=data/training/uploads
SESSIONS_PATH=data/training/sessions
# ============================================================================
# Feedback Bus (Léa parle pendant exécution)
# ============================================================================
# Bus SocketIO unifié 'lea:*' (action_started, action_done, need_confirm, paused).
# Désactivé par défaut. Mettre à 1 pour activer les bulles temps réel dans ChatWindow.
# Si la connexion bus échoue, l'exécution continue normalement (fail-safe).
LEA_FEEDBACK_BUS=0
# ============================================================================
# FAISS
# ============================================================================

1
.gitignore vendored
View File

@@ -95,6 +95,7 @@ archives/
# === Données runtime (sessions, learning, buffer, config local) ===
data/
**/capture_library.json
.hypothesis/
.deps_installed
# Buffers SQLite locaux (streamer, cache)

View File

@@ -185,6 +185,7 @@ Quelques tests legacy sont connus comme cassés — voir la mémoire projet et
- [`docs/STATUS.md`](docs/STATUS.md) — état réel par module
- [`docs/DEV_SETUP.md`](docs/DEV_SETUP.md) — tâches d'administration (worktrees, build)
- [`docs/EXECUTION_LOOP_FLAGS.md`](docs/EXECUTION_LOOP_FLAGS.md) — flags C1 vision-aware (`enable_ui_detection`, `enable_ocr`, `analyze_timeout_ms`, `window_info_provider`)
- [`docs/VISION_RPA_INTELLIGENT.md`](docs/VISION_RPA_INTELLIGENT.md) — cahier des charges
- [`docs/PLAN_ACTEUR_V1.md`](docs/PLAN_ACTEUR_V1.md) — architecture 3 niveaux (Macro / Méso / Micro)
- [`docs/CONFORMITE_AI_ACT.md`](docs/CONFORMITE_AI_ACT.md) — journalisation, floutage, rétention

View File

@@ -133,6 +133,28 @@ def _streaming_headers() -> dict:
headers["Authorization"] = f"Bearer {_STREAMING_API_TOKEN}"
return headers
# ============================================================
# Feedback Bus — events 'lea:*' temps réel vers ChatWindow
# ============================================================
LEA_FEEDBACK_BUS = os.environ.get("LEA_FEEDBACK_BUS", "0").lower() in ("1", "true", "yes", "on")
def _emit_lea(event: str, payload: Dict[str, Any]) -> None:
"""Émet 'lea:{event}' sur le bus SocketIO. No-op silencieux si flag off ou erreur."""
if not LEA_FEEDBACK_BUS:
return
try:
socketio.emit(f"lea:{event}", payload)
except Exception:
logger.debug("_emit_lea silenced", exc_info=True)
def _emit_dual(legacy_event: str, lea_event: str, payload: Dict[str, Any], **kwargs) -> None:
"""Émet l'event legacy (compat dashboard) ET l'alias lea:* (ChatWindow tkinter)."""
socketio.emit(legacy_event, payload, **kwargs)
_emit_lea(lea_event, payload)
execution_status = {
"running": False,
"workflow": None,
@@ -623,7 +645,7 @@ def api_execute():
}
# Notifier via WebSocket
socketio.emit('execution_started', {
_emit_dual('execution_started', 'action_started', {
"workflow": match.workflow_name,
"params": all_params
})
@@ -1181,28 +1203,28 @@ def _execute_gesture(gesture):
)
if resp.status_code == 200:
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": True,
"message": f"Geste '{gesture.name}' ({'+'.join(gesture.keys)}) envoyé",
})
else:
error = resp.text[:200]
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": False,
"message": f"Erreur: {error}",
})
except http_requests.ConnectionError:
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": False,
"message": "Serveur de streaming non disponible (port 5005).",
})
except Exception as e:
logger.error(f"Gesture execution error: {e}")
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": False,
"message": f"Erreur: {str(e)}",
@@ -1661,6 +1683,52 @@ def handle_copilot_abort():
})
# =============================================================================
# Bulle paused_need_help — handlers SocketIO depuis ChatWindow (J3.5)
# =============================================================================
@socketio.on('lea:replay_resume')
def handle_lea_replay_resume(data):
"""Bouton Continuer : relayer le resume vers le streaming server."""
replay_id = (data or {}).get("replay_id")
if not replay_id:
_emit_lea("resume_acked", {"status": "error", "detail": "replay_id manquant"})
return
try:
resp = http_requests.post(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}/resume",
headers=_streaming_headers(),
timeout=5,
)
if resp.ok:
logger.info(f"Replay {replay_id} resume relayé OK")
_emit_lea("resume_acked", {"replay_id": replay_id, "status": "ok"})
else:
detail = resp.text[:200]
logger.warning(f"Resume échoué (HTTP {resp.status_code}): {detail}")
_emit_lea("resume_acked", {
"replay_id": replay_id, "status": "error",
"http_status": resp.status_code, "detail": detail,
})
except Exception as e:
logger.warning(f"Resume relay error: {e}")
_emit_lea("resume_acked", {
"replay_id": replay_id, "status": "error", "detail": str(e),
})
@socketio.on('lea:replay_abort')
def handle_lea_replay_abort(data):
"""Bouton Annuler : arrêter le polling local. Le replay côté streaming sera
cleaned up naturellement au prochain replay (cf api_stream._replay_states stale)."""
global execution_status
replay_id = (data or {}).get("replay_id")
execution_status["running"] = False
execution_status["message"] = "Annulé par l'utilisateur"
logger.info(f"Replay {replay_id or '?'} abort par l'utilisateur (paused bubble)")
_emit_lea("abort_acked", {"replay_id": replay_id, "status": "ok"})
# =============================================================================
# Exécution de workflow
# =============================================================================
@@ -1730,14 +1798,20 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
"""Suivre la progression d'un replay distant via polling."""
import time
max_wait = 120 # 2 minutes max
max_wait_running = 120 # 2 min en exécution active
max_wait_paused = 600 # 10 min en pause supervisée (humain peut prendre son temps)
poll_interval = 2.0
elapsed = 0
was_paused = False
while elapsed < max_wait and execution_status.get("running"):
while execution_status.get("running"):
time.sleep(poll_interval)
elapsed += poll_interval
cap = max_wait_paused if was_paused else max_wait_running
if elapsed >= cap:
break
try:
resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}",
@@ -1753,7 +1827,26 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
failed = data.get("failed_actions", 0)
progress = int(10 + (completed / max(total_actions, 1)) * 80)
socketio.emit('execution_progress', {
if status == "paused_need_help" and not was_paused:
_emit_lea("paused", {
"workflow": workflow_name,
"replay_id": replay_id,
"completed": completed,
"total": total_actions,
"failed_action": data.get("failed_action"),
"reason": data.get("error") or "Action incertaine",
})
was_paused = True
elapsed = 0
elif was_paused and status != "paused_need_help":
_emit_lea("resumed", {
"workflow": workflow_name,
"replay_id": replay_id,
"status_after": status,
})
was_paused = False
_emit_dual('execution_progress', 'action_progress', {
"progress": progress,
"step": f"Action {completed}/{total_actions} exécutée",
"current": completed,
@@ -1922,7 +2015,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
actions = _build_actions_from_workflow(match, params)
if not actions:
socketio.emit('copilot_complete', {
_emit_dual('copilot_complete', 'done', {
"workflow": workflow_name,
"status": "error",
"message": "Aucune action exécutable dans ce workflow.",
@@ -1959,7 +2052,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
break
copilot_state["status"] = "waiting_approval"
socketio.emit('copilot_step', {
_emit_dual('copilot_step', 'need_confirm', {
"workflow": workflow_name,
"step_index": idx,
"total": total,
@@ -1982,7 +2075,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
if waited >= max_wait:
copilot_state["status"] = "aborted"
socketio.emit('copilot_complete', {
_emit_dual('copilot_complete', 'done', {
"workflow": workflow_name,
"status": "timeout",
"message": f"Timeout : pas de réponse après {max_wait}s.",
@@ -1999,7 +2092,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
elif decision == "skipped":
copilot_state["skipped"] += 1
logger.info(f"Copilot skip étape {idx + 1}/{total}")
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "skipped",
@@ -2034,7 +2127,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
if action_success:
copilot_state["completed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "completed",
@@ -2042,7 +2135,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
})
else:
copilot_state["failed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2051,7 +2144,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
else:
error = resp.text[:200]
copilot_state["failed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2060,7 +2153,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
except http_requests.ConnectionError:
copilot_state["failed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2070,7 +2163,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
except Exception as e:
copilot_state["failed"] += 1
logger.error(f"Copilot action error: {e}")
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2098,7 +2191,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
f"Copilot terminé : {completed} réussies, "
f"{skipped} passées, {failed} échouées sur {total} étapes."
)
socketio.emit('copilot_complete', {
_emit_dual('copilot_complete', 'done', {
"workflow": workflow_name,
"status": "completed" if success else "partial",
"message": message,
@@ -2175,7 +2268,7 @@ def execute_workflow(match, params):
execution_status["progress"] = 10
execution_status["message"] = f"Envoyé à l'Agent V1 ({target_session})"
socketio.emit('execution_progress', {
_emit_dual('execution_progress', 'action_progress', {
"progress": 10,
"step": f"Replay envoyé à l'Agent V1 — {total_actions} actions en attente",
"current": 0,
@@ -2523,7 +2616,7 @@ def update_progress(progress: int, message: str, current: int, total: int):
execution_status["progress"] = progress
execution_status["message"] = message
socketio.emit('execution_progress', {
_emit_dual('execution_progress', 'action_progress', {
"progress": progress,
"step": message,
"current": current,
@@ -2543,7 +2636,7 @@ def finish_execution(workflow_name: str, success: bool, message: str):
if command_history:
command_history[-1]["status"] = "completed" if success else "failed"
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": workflow_name,
"success": success,
"message": message

View File

@@ -147,8 +147,10 @@ class AutonomousPlanner:
"""Initialise le client VLM pour analyse intelligente."""
if VLM_AVAILABLE and OllamaClient:
try:
self._vlm_client = OllamaClient(model="qwen2.5vl:7b")
logger.info("VLM client initialized (qwen2.5vl:7b)")
from core.detection.vlm_config import get_vlm_model
_planner_vlm = get_vlm_model()
self._vlm_client = OllamaClient(model=_planner_vlm)
logger.info("VLM client initialized (%s)", _planner_vlm)
except Exception as e:
logger.warning(f"Could not initialize VLM client: {e}")
self._vlm_client = None

View File

@@ -40,10 +40,18 @@ MACHINE_ID = os.environ.get(
BASE_DIR = Path(__file__).resolve().parent
# Endpoint du serveur Streaming (port 5005)
# SERVER_URL contient TOUJOURS /api/v1 à la fin (convention unifiée).
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
# Base sans /api/v1 — pour les routes à la racine (/health)
SERVER_BASE = SERVER_URL.rsplit("/api/v1", 1)[0]
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
# Host Ollama — SÉPARÉ du serveur RPA.
# Ollama tourne en local sur la machine serveur, jamais exposé via le reverse proxy.
# Défaut : localhost (exécution locale ou accès LAN direct).
OLLAMA_HOST = os.getenv("RPA_OLLAMA_HOST", "localhost")
# Token d'authentification API (doit correspondre au token du serveur)
# Configurable via variable d'environnement RPA_API_TOKEN
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")

View File

@@ -477,9 +477,15 @@ class ActionExecutorV1:
},
headers=headers,
timeout=10,
allow_redirects=False,
)
if resp.ok:
if resp.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp.status_code} sur POST {url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
elif resp.ok:
data = resp.json()
state = data.get("screen_state", "ok")
if state != "ok":
@@ -703,7 +709,11 @@ class ActionExecutorV1:
f"attendu '{expected_title}' → mode apprentissage"
)
try:
self.notifier.replay_wrong_window(current_title, expected_title)
self.notifier.replay_learning_mode(
raison="wrong_window",
target_description=expected_title,
window_title=current_title,
)
except Exception:
pass
@@ -935,9 +945,10 @@ class ActionExecutorV1:
# et ne trouve toujours pas. L'humain doit montrer.
print(f" [POLICY] Retry échoué → mode apprentissage")
try:
self.notifier.replay_target_not_found(
target_desc,
target_spec.get("window_title", ""),
self.notifier.replay_learning_mode(
raison="retry_failed",
target_description=target_desc,
window_title=target_spec.get("window_title", ""),
)
except Exception:
pass
@@ -993,9 +1004,10 @@ class ActionExecutorV1:
# passe en mode capture et enregistre ce que
# l'humain fait (mini-workflow de correction).
try:
self.notifier.replay_target_not_found(
target_desc,
target_spec.get("window_title", ""),
self.notifier.replay_learning_mode(
raison="supervise",
target_description=target_desc,
window_title=target_spec.get("window_title", ""),
)
except Exception:
pass
@@ -1221,7 +1233,9 @@ class ActionExecutorV1:
f"je demande de l'aide"
)
try:
self.notifier.replay_no_screen_change(action_type)
self.notifier.replay_learning_mode(
raison="no_screen_change",
)
except Exception:
pass
@@ -1377,7 +1391,13 @@ class ActionExecutorV1:
try:
print(f" [SERVER-RESOLVE] Appel serveur {server_url}...")
resp = _requests.post(url, json=payload, headers=headers, timeout=30)
resp = _requests.post(url, json=payload, headers=headers, timeout=30, allow_redirects=False)
if resp.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp.status_code} sur POST {url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
return None
if not resp.ok:
logger.warning(f"Server resolve HTTP {resp.status_code}")
return None
@@ -1521,7 +1541,7 @@ class ActionExecutorV1:
if not vlm_description:
return None
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = (
@@ -1657,7 +1677,7 @@ Example: x_pct=0.50, y_pct=0.30"""
if anchor_b64:
images.append(anchor_b64)
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
# Prefill pour les modèles thinking (qwen3) — évite le mode réflexion >180s
@@ -1861,8 +1881,14 @@ Example: x_pct=0.50, y_pct=0.30"""
json=report,
headers=self._auth_headers(),
timeout=10,
allow_redirects=False,
)
if resp2.ok:
if resp2.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp2.status_code} sur POST {replay_result_url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
elif resp2.ok:
server_resp = resp2.json()
msg = (
f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, "
@@ -2128,7 +2154,7 @@ Example: x_pct=0.50, y_pct=0.30"""
"""
import requests as _requests
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = (
@@ -2575,8 +2601,8 @@ Example: x_pct=0.50, y_pct=0.30"""
f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)"
)
print(
f" [APPRENTISSAGE] Montre-moi comment faire.\n"
f" Quand tu as fini → Ctrl+Shift+L\n"
f" [APPRENTISSAGE] Je n'y arrive pas, montrez-moi comment faire.\n"
f" Quand vous avez fini → Ctrl+Shift+L\n"
f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)"
)

View File

@@ -17,6 +17,7 @@ import threading
from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT,
)
from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1
@@ -86,22 +87,23 @@ class AgentV1:
self._state.set_on_stop(self.stop_session)
# Client serveur pour le chat et les workflows
# Plus de RPA_SERVER_HOST : le LeaServerClient derive tout de SERVER_URL
self._server_client = None
if LeaServerClient is not None:
# Forcer le token API pour éviter les 401
# (le token est set par start.bat dans l'environnement)
from .config import API_TOKEN as _token
server_host = os.getenv("RPA_SERVER_HOST", "localhost")
self._server_client = LeaServerClient(server_host=server_host)
self._server_client = LeaServerClient()
if _token and not self._server_client._api_token:
self._server_client._api_token = _token
logger.info("Token API forcé dans LeaServerClient")
# Fenetre de chat Lea (tkinter natif)
# Le host est derive de SERVER_URL (plus de RPA_SERVER_HOST)
server_host = (
self._server_client.server_host
if self._server_client is not None
else os.getenv("RPA_SERVER_HOST", "localhost")
else "localhost"
)
self._chat_window = ChatWindow(
server_client=self._server_client,
@@ -363,11 +365,11 @@ class AgentV1:
continue
self._last_bg_hash = img_hash
# Envoyer au streaming server (avec token auth)
# Envoyer au streaming server (via STREAMING_ENDPOINT unifié)
headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
with open(full_path, 'rb') as f:
req.post(
f"{SERVER_URL}/traces/stream/image",
f"{STREAMING_ENDPOINT}/image",
params={
"session_id": bg_session,
"shot_id": f"heartbeat_{int(time.time())}",
@@ -376,6 +378,7 @@ class AgentV1:
headers=headers,
files={"file": ("screenshot.png", f, "image/png")},
timeout=10,
allow_redirects=False,
)
except Exception as e:
logger.debug(f"[HEARTBEAT] Erreur: {e}")
@@ -445,6 +448,12 @@ class AgentV1:
window_title = self.vision.get_active_window_title()
if window_title:
heartbeat_event["active_window_title"] = window_title
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
try:
from .vision.capturer import _enrich_with_monitor_info
_enrich_with_monitor_info(heartbeat_event)
except Exception:
pass
self.streamer.push_event(heartbeat_event)
except Exception as e:
logger.error(f"Heartbeat error: {e}")

View File

@@ -0,0 +1,149 @@
# agent_v1/network/feedback_bus.py
"""Client SocketIO pour le bus feedback Léa.
Consomme les events 'lea:*' émis par agent_chat (port 5004) et les dispatche
vers ChatWindow pour affichage en bulles temps réel.
Events écoutés :
lea:action_started — début d'un workflow ou d'une action
lea:action_progress — progression dans le workflow
lea:done — fin d'un workflow ou d'un copilot
lea:need_confirm — étape copilot en attente de validation
lea:step_result — résultat d'une étape copilot
lea:paused — basculement en paused_need_help (asset démo)
lea:resumed — sortie de pause supervisée
Fail-safe : toute erreur de connexion ou de dispatch est silencieusement
loggée. Le ChatWindow continue de fonctionner même si le bus est mort
(comportement strictement identique au pré-J3).
Usage :
bus = FeedbackBusClient(
server_url="http://localhost:5004",
token=os.environ.get("RPA_API_TOKEN", ""),
on_event=lambda event, payload: print(event, payload),
)
bus.start() # connexion en arrière-plan, non-bloquant
# ... ChatWindow tourne ...
bus.stop()
"""
import logging
import threading
from typing import Callable, Optional
import socketio
logger = logging.getLogger(__name__)
LEA_EVENTS = (
'lea:action_started',
'lea:action_progress',
'lea:done',
'lea:need_confirm',
'lea:step_result',
'lea:paused',
'lea:resumed',
)
EventCallback = Callable[[str, dict], None]
class FeedbackBusClient:
"""Client SocketIO non-bloquant pour le bus 'lea:*'."""
def __init__(
self,
server_url: str,
token: Optional[str] = None,
on_event: Optional[EventCallback] = None,
):
self._url = server_url.rstrip('/')
self._token = token or None
self._on_event: EventCallback = on_event or (lambda e, p: None)
self._sio = socketio.Client(
reconnection=True,
reconnection_attempts=0, # 0 = illimité
reconnection_delay=2,
reconnection_delay_max=30,
logger=False,
engineio_logger=False,
)
self._thread: Optional[threading.Thread] = None
self._register_handlers()
def _register_handlers(self) -> None:
@self._sio.event
def connect():
logger.info("FeedbackBus connecté à %s", self._url)
@self._sio.event
def disconnect():
logger.info("FeedbackBus déconnecté")
for ev in LEA_EVENTS:
self._sio.on(ev, lambda data, e=ev: self._dispatch(e, data))
def _dispatch(self, event: str, payload: Optional[dict]) -> None:
try:
self._on_event(event, payload or {})
except Exception:
logger.debug("FeedbackBus dispatch silenced", exc_info=True)
def start(self) -> None:
"""Démarrer la connexion en arrière-plan (idempotent, non-bloquant)."""
if self._thread is not None and self._thread.is_alive():
return
self._thread = threading.Thread(
target=self._run, daemon=True, name="LeaFeedbackBus",
)
self._thread.start()
def _run(self) -> None:
headers = {}
if self._token:
headers['Authorization'] = f'Bearer {self._token}'
try:
self._sio.connect(self._url, headers=headers, wait=True)
self._sio.wait()
except Exception as e:
logger.warning(
"FeedbackBus connect échoué (%s) — ChatWindow continue normalement", e,
)
def stop(self) -> None:
"""Arrêter proprement la connexion (idempotent, fail-safe)."""
try:
if self._sio.connected:
self._sio.disconnect()
except Exception:
logger.debug("FeedbackBus stop silenced", exc_info=True)
@property
def connected(self) -> bool:
return bool(self._sio.connected)
# ------------------------------------------------------------------
# Actions utilisateur depuis la bulle paused_need_help (J3.5)
# ------------------------------------------------------------------
def resume_replay(self, replay_id: str) -> bool:
"""Bouton Continuer : émet 'lea:replay_resume' vers agent_chat.
Retourne True si l'event a pu être émis, False sinon (déconnecté/erreur).
"""
return self._safe_emit("lea:replay_resume", {"replay_id": replay_id})
def abort_replay(self, replay_id: str) -> bool:
"""Bouton Annuler : émet 'lea:replay_abort' vers agent_chat."""
return self._safe_emit("lea:replay_abort", {"replay_id": replay_id})
def _safe_emit(self, event: str, payload: dict) -> bool:
try:
if not self._sio.connected:
return False
self._sio.emit(event, payload)
return True
except Exception:
logger.debug("FeedbackBus _safe_emit silenced", exc_info=True)
return False

View File

@@ -544,6 +544,28 @@ class TraceStreamer:
except OSError as e:
logger.debug(f"Purge échouée : {path}{e}")
# =========================================================================
# Protection redirect POST→GET (INC-7)
# =========================================================================
@staticmethod
def _check_redirect(resp, url: str):
"""Detecter et logger une redirection sur un POST.
La lib requests transforme un POST en GET sur 301/302 (RFC 7231).
Avec allow_redirects=False, on recoit le 301/302 directement.
On log un WARNING explicite pour que l'admin corrige l'URL.
"""
if resp.status_code in (301, 302, 307, 308):
location = resp.headers.get("Location", "?")
logger.warning(
f"Redirection {resp.status_code} detectee sur POST {url} "
f"{location}. Verifiez que RPA_SERVER_URL utilise "
f"https:// si le serveur redirige."
)
return True
return False
# =========================================================================
# Envois HTTP
# =========================================================================
@@ -551,15 +573,20 @@ class TraceStreamer:
def _register_session(self):
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
try:
url = f"{STREAMING_ENDPOINT}/register"
resp = requests.post(
f"{STREAMING_ENDPOINT}/register",
url,
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
headers=self._auth_headers(),
timeout=3,
allow_redirects=False,
)
if self._check_redirect(resp, url):
logger.warning("Enregistrement session échoué (redirect)")
return
if resp.ok:
logger.info(
f"Session {self.session_id} enregistrée sur le serveur "
@@ -579,15 +606,18 @@ class TraceStreamer:
C'est la dernière chance de sauver les données de la session.
"""
try:
url = f"{STREAMING_ENDPOINT}/finalize"
resp = requests.post(
f"{STREAMING_ENDPOINT}/finalize",
url,
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
headers=self._auth_headers(),
timeout=30, # Le build workflow peut prendre du temps
allow_redirects=False,
)
self._check_redirect(resp, url)
if resp.ok:
result = resp.json()
logger.info(f"Session finalisée: {result}")
@@ -601,6 +631,7 @@ class TraceStreamer:
if not self._server_available:
return False
try:
url = f"{STREAMING_ENDPOINT}/event"
payload = {
"session_id": self.session_id,
"timestamp": time.time(),
@@ -608,11 +639,14 @@ class TraceStreamer:
"machine_id": self.machine_id,
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/event",
url,
json=payload,
headers=self._auth_headers(),
timeout=2,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return False
return resp.ok
except Exception as e:
logger.debug(f"Streaming Event échoué: {e}")
@@ -645,18 +679,22 @@ class TraceStreamer:
"machine_id": self.machine_id,
}
url = f"{STREAMING_ENDPOINT}/image"
if jpeg_buf is not None:
# Envoi du JPEG compressé (BytesIO, pas de fuite possible)
files = {
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
url,
files=files,
params=params,
headers=self._auth_headers(),
timeout=5,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok:
self._purge_local_image(path)
return ImageSendResult.OK
@@ -668,12 +706,15 @@ class TraceStreamer:
"file": (f"{shot_id}.png", f, "image/png")
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
url,
files=files,
params=params,
headers=self._auth_headers(),
timeout=5,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok:
self._purge_local_image(path)
return ImageSendResult.OK

View File

@@ -3,7 +3,9 @@ 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
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
pystray>=0.19.5 # Icône Tray UI
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
pywebview>=5.0 # Fenêtre de chat Léa intégrée (Edge WebView2 sur Windows)

View File

@@ -16,6 +16,15 @@ from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
# FeedbackBus : import fail-safe (le ChatWindow doit tourner même si python-socketio
# n'est pas installé sur le poste client, par exemple ancienne installation Pauline)
try:
from ..network.feedback_bus import FeedbackBusClient
_HAS_FEEDBACK_BUS = True
except Exception:
FeedbackBusClient = None # type: ignore
_HAS_FEEDBACK_BUS = False
# ---------------------------------------------------------------------------
# Theme — palette professionnelle claire
# ---------------------------------------------------------------------------
@@ -42,6 +51,25 @@ SCROLLBAR_BG = "#E5E7EB" # Fond scrollbar
SCROLLBAR_FG = "#9CA3AF" # Curseur scrollbar
MSG_BORDER_COLOR = "#D1D5DB" # Bordure subtile des bulles de messages
# Bulle paused_need_help (J3.5) — alerte non bloquante, asset démo majeur
PAUSED_BG = "#FEF3C7" # Jaune pâle
PAUSED_BORDER = "#F59E0B" # Orange ambré
PAUSED_FG = "#92400E" # Brun foncé (lisible sur fond jaune)
PAUSED_BTN_RESUME_BG = "#22C55E" # Vert
PAUSED_BTN_RESUME_HOVER = "#16A34A"
PAUSED_BTN_ABORT_BG = "#9CA3AF" # Gris neutre (pas dramatique)
PAUSED_BTN_ABORT_HOVER = "#6B7280"
# Bulle "Léa exécute" (J3.4) — distincte des bulles chat normales
ACTION_BG = "#F1F5F9" # Gris très clair (différencie d'une réponse chat)
ACTION_BORDER = "#CBD5E1" # Gris pâle
ACTION_FG = "#1E293B" # Gris foncé
ACTION_META_FG = "#94A3B8" # Métadonnées en gris discret
ACTION_ICON_RUN = "#3B82F6" # Bleu (en cours)
ACTION_ICON_OK = "#22C55E" # Vert (succès)
ACTION_ICON_ERR = "#EF4444" # Rouge (échec)
ACTION_ICON_INFO = "#64748B" # Gris (neutre)
# Dimensions — confortables
WIN_WIDTH = 600
WIN_HEIGHT = 800
@@ -62,6 +90,80 @@ FONT_SEND_BTN = ("Segoe UI", 13)
FONT_RESIZE_GRIP = ("Segoe UI", 10)
# ---------------------------------------------------------------------------
# Templates de bulles "Léa exécute" (J3.4)
# Chaque template prend un payload et retourne (icon, icon_color, title).
# Les libellés sont volontairement neutres : le contexte métier vient du
# payload (workflow, action, message), pas de hardcoding.
# ---------------------------------------------------------------------------
def _tpl_action_started(payload: Dict[str, Any]) -> tuple:
wf = payload.get("workflow") or "?"
return ("", ACTION_ICON_RUN, f"Démarrage : {wf}")
def _tpl_action_progress(payload: Dict[str, Any]) -> tuple:
cur = payload.get("current", "?")
tot = payload.get("total", "?")
step = payload.get("step")
title = step if step else f"Étape {cur}/{tot}"
return ("", ACTION_ICON_RUN, str(title))
def _tpl_done(payload: Dict[str, Any]) -> tuple:
success = bool(payload.get("success", True))
msg = payload.get("message") or ("Terminé" if success else "Échec")
if success:
return ("", ACTION_ICON_OK, str(msg))
return ("", ACTION_ICON_ERR, str(msg))
def _tpl_need_confirm(payload: Dict[str, Any]) -> tuple:
action = payload.get("action") or {}
desc = action.get("description") if isinstance(action, dict) else None
title = desc or "Validation requise"
return ("?", ACTION_ICON_RUN, str(title))
def _tpl_step_result(payload: Dict[str, Any]) -> tuple:
status = (payload.get("status") or "").lower()
msg = payload.get("message") or status or "Étape terminée"
if status in ("ok", "success", "approved"):
return ("", ACTION_ICON_OK, str(msg))
if status in ("error", "failed"):
return ("", ACTION_ICON_ERR, str(msg))
return ("·", ACTION_ICON_INFO, str(msg))
def _tpl_resumed(payload: Dict[str, Any]) -> tuple:
return ("", ACTION_ICON_OK, "Reprise")
_ACTION_TEMPLATES = {
"lea:action_started": _tpl_action_started,
"lea:action_progress": _tpl_action_progress,
"lea:done": _tpl_done,
"lea:need_confirm": _tpl_need_confirm,
"lea:step_result": _tpl_step_result,
"lea:resumed": _tpl_resumed,
}
def _extract_meta(payload: Dict[str, Any]) -> str:
"""Métadonnées techniques en pied de bulle (workflow, étape, replay_id court)."""
parts = []
wf = payload.get("workflow")
if wf:
parts.append(str(wf))
cur, tot = payload.get("current"), payload.get("total")
if cur is not None and tot is not None:
parts.append(f"étape {cur}/{tot}")
rid = payload.get("replay_id")
if rid:
parts.append(f"#{str(rid)[-6:]}")
return "".join(parts)
class ChatWindow:
"""Fenetre de chat Lea en tkinter natif.
@@ -91,6 +193,8 @@ class ChatWindow:
self._root = None
self._ready = threading.Event()
self._messages = [] # historique local
self._bus: Optional[Any] = None # FeedbackBusClient (J3.3, peut rester None)
self._active_paused_bubble: Optional[Dict[str, Any]] = None # bulle paused active (J3.5)
# S'abonner aux changements de l'etat partage
if self._shared_state is not None:
@@ -266,6 +370,9 @@ class ChatWindow:
# Signaler que la fenetre est prete
self._ready.set()
# Demarrer le bus feedback Lea (events 'lea:*' temps reel)
self._start_feedback_bus()
# Boucle tkinter
root.mainloop()
@@ -608,6 +715,12 @@ class ChatWindow:
def _do_destroy(self) -> None:
"""Detruit la fenetre (appele dans le thread tkinter)."""
if self._bus is not None:
try:
self._bus.stop()
except Exception:
pass
self._bus = None
if self._root is not None:
try:
self._root.quit()
@@ -617,6 +730,232 @@ class ChatWindow:
self._root = None
self._visible = False
# ======================================================================
# FeedbackBus — bulles temps reel pendant l'execution (J3.3)
# ======================================================================
def _start_feedback_bus(self) -> None:
"""Demarrer la connexion au bus 'lea:*' si flag actif et lib disponible."""
if not _HAS_FEEDBACK_BUS:
logger.debug("FeedbackBus non disponible (python-socketio manquant)")
return
flag = os.environ.get("LEA_FEEDBACK_BUS", "0").lower()
if flag not in ("1", "true", "yes", "on"):
return
try:
url = f"http://{self._server_host}:{self._chat_port}"
token = os.environ.get("RPA_API_TOKEN", "") or None
self._bus = FeedbackBusClient(url, token=token, on_event=self._on_lea_event)
self._bus.start()
logger.info("FeedbackBus demarre : %s", url)
except Exception:
logger.debug("FeedbackBus init silenced", exc_info=True)
self._bus = None
def _on_lea_event(self, event: str, payload: Dict[str, Any]) -> None:
"""Callback bus → bulle Lea. Thread-safe : helpers utilisent root.after."""
payload = payload or {}
# J3.5 : la pause supervisée a sa propre bulle interactive
if event == "lea:paused":
self._add_paused_bubble(payload)
return
if event in ("lea:resumed", "lea:done"):
self._close_active_paused_bubble(reason=event)
# on continue pour afficher la bulle d'action (cf. dispatch ci-dessous)
# Acks bus (resume_acked, abort_acked) : silencieux côté UI
if event in ("lea:resume_acked", "lea:abort_acked"):
return
# J3.4 : bulle "Léa exécute" stylisée (séparée des bulles chat normales)
rendered = _ACTION_TEMPLATES.get(event)
if rendered is None:
# Event inconnu : on affiche en bulle d'action neutre
self._add_action_bubble(
icon="·", icon_color=ACTION_ICON_INFO,
title=event.removeprefix("lea:"),
meta=_extract_meta(payload),
)
return
icon, icon_color, title = rendered(payload)
self._add_action_bubble(
icon=icon, icon_color=icon_color, title=title,
meta=_extract_meta(payload),
)
# ------------------------------------------------------------------
# Bulle "Léa exécute" stylisée (J3.4)
# ------------------------------------------------------------------
def _add_action_bubble(
self, icon: str, icon_color: str, title: str, meta: str = "",
) -> None:
if self._root is None:
return
self._root.after(0, lambda: self._render_action_bubble(icon, icon_color, title, meta))
def _render_action_bubble(
self, icon: str, icon_color: str, title: str, meta: str,
) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
return
now = datetime.now().strftime("%H:%M")
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
container.pack(fill=tk.X, padx=MARGIN, pady=3)
inner = tk.Frame(
container, bg=ACTION_BG, padx=10, pady=6,
highlightbackground=ACTION_BORDER, highlightthickness=1,
)
inner.pack(anchor=tk.W, padx=(0, 70), fill=tk.X)
row = tk.Frame(inner, bg=ACTION_BG)
row.pack(fill=tk.X, anchor=tk.W)
tk.Label(
row, text=icon, bg=ACTION_BG, fg=icon_color,
font=("Segoe UI", 13, "bold"), padx=4,
).pack(side=tk.LEFT)
tk.Label(
row, text=title, bg=ACTION_BG, fg=ACTION_FG,
font=FONT_MSG, anchor="w", justify=tk.LEFT,
wraplength=MSG_WRAP_WIDTH - 60,
).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(2, 0))
if meta:
tk.Label(
inner, text=f"{meta}{now}",
bg=ACTION_BG, fg=ACTION_META_FG,
font=FONT_TIMESTAMP, anchor="w",
).pack(fill=tk.X, anchor=tk.W, pady=(2, 0))
# ------------------------------------------------------------------
# Bulle paused_need_help interactive (J3.5)
# ------------------------------------------------------------------
def _add_paused_bubble(self, payload: Dict[str, Any]) -> None:
"""Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide)."""
if self._root is None:
return
self._root.after(0, lambda: self._render_paused_bubble(payload))
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
return
replay_id = str(payload.get("replay_id", "") or "")
workflow = payload.get("workflow", "?")
reason = payload.get("reason") or "Action incertaine — j'ai besoin de votre validation."
completed = payload.get("completed", 0)
total = payload.get("total", "?")
now = datetime.now().strftime("%H:%M")
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
container.pack(fill=tk.X, padx=MARGIN, pady=6)
inner = tk.Frame(
container, bg=PAUSED_BG, padx=14, pady=12,
highlightbackground=PAUSED_BORDER, highlightthickness=2,
)
inner.pack(anchor=tk.W, padx=(0, 50), fill=tk.X)
tk.Label(
inner, text=f"⏸ Pause supervisée • {now}",
bg=PAUSED_BG, fg=PAUSED_FG,
font=("Segoe UI", 12, "bold"), anchor="w",
).pack(fill=tk.X, anchor=tk.W)
tk.Label(
inner, text=reason, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wraplength=MSG_WRAP_WIDTH - 30,
anchor="w", justify=tk.LEFT,
).pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
tk.Label(
inner, text=f"{workflow} — étape {completed}/{total}",
bg=PAUSED_BG, fg=TIMESTAMP_FG, font=FONT_TIMESTAMP, anchor="w",
).pack(fill=tk.X, anchor=tk.W, pady=(4, 8))
btn_frame = tk.Frame(inner, bg=PAUSED_BG)
btn_frame.pack(fill=tk.X, anchor=tk.W)
btn_resume = tk.Button(
btn_frame, text="Continuer",
bg=PAUSED_BTN_RESUME_BG, fg="white", font=FONT_QUICK_BTN,
padx=14, pady=4, bd=0, cursor="hand2",
activebackground=PAUSED_BTN_RESUME_HOVER, activeforeground="white",
command=lambda: self._on_paused_resume(replay_id),
)
btn_resume.pack(side=tk.LEFT, padx=(0, 8))
btn_abort = tk.Button(
btn_frame, text="Annuler",
bg=PAUSED_BTN_ABORT_BG, fg="white", font=FONT_QUICK_BTN,
padx=14, pady=4, bd=0, cursor="hand2",
activebackground=PAUSED_BTN_ABORT_HOVER, activeforeground="white",
command=lambda: self._on_paused_abort(replay_id),
)
btn_abort.pack(side=tk.LEFT)
self._active_paused_bubble = {
"container": container, "inner": inner,
"btn_resume": btn_resume, "btn_abort": btn_abort,
"replay_id": replay_id,
}
def _close_active_paused_bubble(self, reason: str) -> None:
if self._active_paused_bubble is None or self._root is None:
return
self._root.after(0, lambda: self._do_close_paused_bubble(reason))
def _do_close_paused_bubble(self, reason: str) -> None:
bubble = self._active_paused_bubble
if bubble is None:
return
try:
bubble["btn_resume"].config(state="disabled")
bubble["btn_abort"].config(state="disabled")
label_text = {
"lea:resumed": "→ Reprise",
"lea:done": "→ Terminé",
}.get(reason, f"{reason}")
self._tk.Label(
bubble["inner"], text=label_text,
bg=PAUSED_BG, fg=PAUSED_FG, font=FONT_TIMESTAMP, anchor="w",
).pack(fill="x", anchor="w", pady=(6, 0))
except Exception:
logger.debug("close paused bubble silenced", exc_info=True)
self._active_paused_bubble = None
def _on_paused_resume(self, replay_id: str) -> None:
if not replay_id or self._bus is None or not self._bus.connected:
self._add_lea_message("⚠ Bus indisponible — impossible de relancer")
return
self._bus.resume_replay(replay_id)
if self._active_paused_bubble:
try:
self._active_paused_bubble["btn_resume"].config(state="disabled")
self._active_paused_bubble["btn_abort"].config(state="disabled")
except Exception:
pass
def _on_paused_abort(self, replay_id: str) -> None:
if self._bus is None or not self._bus.connected:
self._add_lea_message("⚠ Bus indisponible — impossible d'annuler")
return
self._bus.abort_replay(replay_id)
if self._active_paused_bubble:
try:
self._active_paused_bubble["btn_resume"].config(state="disabled")
self._active_paused_bubble["btn_abort"].config(state="disabled")
except Exception:
pass
# ======================================================================
# Ajout de messages dans la zone de chat
# ======================================================================

View File

@@ -293,6 +293,49 @@ def formatter_ecran_inchange(action_type: str = "") -> MessageUtilisateur:
)
def formatter_mode_apprentissage(
raison: str = "",
description_cible: str = "",
titre_fenetre: Optional[str] = None,
) -> MessageUtilisateur:
"""Message quand Léa passe en mode apprentissage (pause supervisée).
L'utilisateur doit comprendre :
1. Léa est bloquée et a besoin d'aide
2. L'utilisateur doit prendre la main et montrer comment faire
3. Ctrl+Shift+L pour signaler qu'il a fini
Le ton est humble, clair, actionnable. Pas technique.
Exemple :
Léa a besoin d'aide
Je n'y arrive pas, montrez-moi comment faire.
Quand vous avez fini, appuyez sur Ctrl+Shift+L.
"""
cible = _nettoyer_description_cible(description_cible) if description_cible else ""
app = _extraire_nom_application(titre_fenetre or "") if titre_fenetre else ""
# Construire un contexte court si disponible
contexte = ""
if cible and app:
contexte = f"{cible} » dans {app})"
elif cible:
contexte = f"{cible} »)"
corps = (
f"Je n'y arrive pas{contexte}, montrez-moi comment faire. "
f"Quand vous avez fini, appuyez sur Ctrl+Shift+L."
)
return MessageUtilisateur(
niveau=NiveauMessage.BLOCAGE,
titre="Léa a besoin d'aide",
corps=corps,
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
persistent=True,
)
def formatter_connexion_perdue(hote_serveur: str = "") -> MessageUtilisateur:
"""Message quand la connexion avec le serveur est perdue.

View File

@@ -32,6 +32,7 @@ from .messages import (
formatter_etape_workflow,
formatter_fenetre_incorrecte,
formatter_fin_workflow,
formatter_mode_apprentissage,
formatter_ralentissement,
formatter_retry,
)
@@ -273,6 +274,20 @@ class NotificationManager:
msg = formatter_ecran_inchange(action_type)
return self.notify_message(msg)
def replay_learning_mode(
self,
raison: str = "",
target_description: str = "",
window_title: Optional[str] = None,
) -> bool:
"""Notification quand Léa passe en mode apprentissage.
Léa est bloquée et demande à l'utilisateur de montrer comment faire.
Message humble et actionnable pour un utilisateur non technique.
"""
msg = formatter_mode_apprentissage(raison, target_description, window_title)
return self.notify_message(msg)
def replay_retry(self, action_type: str = "", tentative: int = 2) -> bool:
"""Notification quand Léa retente une action."""
msg = formatter_retry(action_type, tentative)

View File

@@ -2,12 +2,20 @@
"""
Gestionnaire de vision avancé pour Agent V1.
Optimisé pour le streaming fibre avec détection de changement.
Captures disponibles :
- Plein écran (full) : contexte global 1920x1080+
- Crop ciblé (crop) : 80x80 autour du clic (apprentissage VLM)
- Fenêtre active (window) : image isolée de la fenêtre + métadonnées
(titre, rect, coordonnées clic relatives) — cross-platform
"""
import os
import time
import logging
import hashlib
import platform
from typing import Any, Dict, List, Optional
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
@@ -15,6 +23,69 @@ from .blur_sensitive import blur_sensitive_regions
logger = logging.getLogger(__name__)
# OS courant (détecté une seule fois)
_SYSTEM = platform.system()
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
try:
from screeninfo import get_monitors as _screeninfo_get_monitors
_SCREENINFO_AVAILABLE = True
except ImportError:
_SCREENINFO_AVAILABLE = False
def _get_monitors_geometry() -> List[Dict[str, Any]]:
"""Retourne la liste des monitors physiques avec leurs offsets.
Returns:
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
indisponible (le serveur tombera sur fallback composite).
"""
if not _SCREENINFO_AVAILABLE:
return []
try:
monitors = _screeninfo_get_monitors()
return [
{
"idx": i,
"x": int(m.x),
"y": int(m.y),
"w": int(m.width),
"h": int(m.height),
"primary": bool(getattr(m, "is_primary", False)),
}
for i, m in enumerate(monitors)
]
except Exception:
return []
def _get_active_monitor_index() -> Optional[int]:
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
Returns:
int ou None si indéterminable.
"""
if not _SCREENINFO_AVAILABLE:
return None
try:
import pyautogui # import paresseux : évite la dépendance dure
cx, cy = pyautogui.position()
for i, m in enumerate(_screeninfo_get_monitors()):
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
return i
except Exception:
return None
return None
def _enrich_with_monitor_info(payload: dict) -> dict:
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
if isinstance(payload, dict):
payload["monitor_index"] = _get_active_monitor_index()
payload["monitors_geometry"] = _get_monitors_geometry()
return payload
class VisionCapturer:
def __init__(self, session_dir: str):
self.session_dir = session_dir
@@ -27,13 +98,16 @@ class VisionCapturer:
"""
Capture l'écran complet.
Si force=False, vérifie d'abord si l'écran a changé.
Enrichit les métadonnées avec le titre de la fenêtre active
(utile pour le contextualisation des heartbeats côté serveur).
"""
try:
with mss.mss() as sct:
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
# Détection de changement (pour Heartbeat)
if not force:
current_hash = self._compute_quick_hash(img)
@@ -52,8 +126,24 @@ class VisionCapturer:
logger.error(f"Erreur Context Capture: {e}")
return ""
def get_active_window_title(self) -> str:
"""Retourne le titre de la fenêtre active (pour enrichir les heartbeats).
Fallback gracieux : retourne une chaîne vide si indisponible.
"""
try:
from ..window_info_crossplatform import get_active_window_info
info = get_active_window_info()
return info.get("title", "")
except Exception:
return ""
def capture_dual(self, x: int, y: int, screenshot_id: str, anonymize=False) -> dict:
"""Capture duale (Full + Crop) systématique (forcée car liée à une action)."""
"""Capture triple (Full + Crop + Fenêtre active) systématique.
La fenêtre active est un AJOUT — en cas d'échec, le full + crop
sont toujours retournés (fallback gracieux).
"""
try:
with mss.mss() as sct:
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
@@ -67,7 +157,7 @@ class VisionCapturer:
left = max(0, x - w // 2)
top = max(0, y - h // 2)
crop_img = img.crop((left, top, left + w, top + h))
if anonymize:
crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4))
@@ -82,11 +172,136 @@ class VisionCapturer:
# Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img)
return {"full": full_path, "crop": crop_path}
result = {"full": full_path, "crop": crop_path}
# --- Capture de la fenêtre active ---
# Ajout non-bloquant : enrichit le résultat avec l'image
# de la fenêtre seule + métadonnées (titre, rect, clic relatif)
window_info = self.capture_active_window(x, y, screenshot_id, full_img=img)
if window_info:
result["window_capture"] = window_info
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
_enrich_with_monitor_info(result)
return result
except Exception as e:
logger.error(f"Erreur Dual Capture: {e}")
return {}
def capture_active_window(
self,
x: int,
y: int,
screenshot_id: str,
full_img: Optional[Image.Image] = None,
) -> Optional[Dict[str, Any]]:
"""Capture l'image de la fenêtre active seule + métadonnées.
Stratégie :
1. Obtenir le rectangle de la fenêtre via l'API OS (pywin32 / xdotool / Quartz)
2. Cropper depuis le screenshot plein écran (plus fiable que PrintWindow)
3. Calculer les coordonnées du clic relatives à la fenêtre
Args:
x, y: coordonnées du clic en pixels écran
screenshot_id: identifiant pour le nom de fichier
full_img: screenshot plein écran déjà capturé (optionnel, évite une
double capture si appelé depuis capture_dual)
Returns:
Dict avec window_image, window_title, window_rect, click_in_window,
window_size — ou None si la fenêtre est introuvable.
"""
try:
from ..window_info_crossplatform import get_active_window_rect
rect_info = get_active_window_rect()
if not rect_info:
logger.debug("Fenêtre active introuvable — skip capture fenêtre")
return None
win_rect = rect_info["rect"] # [left, top, right, bottom]
win_left, win_top, win_right, win_bottom = win_rect
win_w, win_h = rect_info["size"] # [width, height]
title = rect_info.get("title", "unknown_window")
app_name = rect_info.get("app_name", "unknown_app")
# Ignorer les fenêtres trop petites (barres de tâches, popups système)
if win_w < 50 or win_h < 50:
logger.debug(f"Fenêtre trop petite ({win_w}x{win_h}) — skip")
return None
# Coordonnées du clic relatives à la fenêtre
click_rel_x = x - win_left
click_rel_y = y - win_top
# Si le clic est en dehors de la fenêtre, on le signale mais on continue
click_inside = (0 <= click_rel_x <= win_w and 0 <= click_rel_y <= win_h)
# --- Crop de la fenêtre depuis le plein écran ---
if full_img is None:
# Pas de screenshot fourni — en capturer un (cas standalone)
try:
with mss.mss() as sct:
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
full_img = Image.frombytes(
"RGB", sct_img.size, sct_img.bgra, "raw", "BGRX"
)
except Exception as e:
logger.error(f"Erreur capture plein écran pour fenêtre : {e}")
return None
# Borner le crop aux limites de l'image plein écran
img_w, img_h = full_img.size
crop_left = max(0, win_left)
crop_top = max(0, win_top)
crop_right = min(img_w, win_right)
crop_bottom = min(img_h, win_bottom)
if crop_right <= crop_left or crop_bottom <= crop_top:
logger.debug("Fenêtre hors écran — skip capture fenêtre")
return None
window_img = full_img.crop((crop_left, crop_top, crop_right, crop_bottom))
# Floutage conformité AI Act
if BLUR_SENSITIVE:
blur_sensitive_regions(window_img)
# Sauvegarde
window_path = os.path.join(
self.shots_dir, f"{screenshot_id}_window.png"
)
window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY)
result = {
"window_image": window_path,
"window_title": title,
"app_name": app_name,
"window_rect": win_rect,
"window_size": [win_w, win_h],
"click_in_window": [click_rel_x, click_rel_y],
"click_inside_window": click_inside,
}
# QW1 — enrichissement multi-écrans (additif)
_enrich_with_monitor_info(result)
logger.debug(
f"Fenêtre capturée : {title} ({win_w}x{win_h}) — "
f"clic relatif ({click_rel_x}, {click_rel_y})"
)
return result
except ImportError as e:
logger.debug(f"Module fenêtre indisponible : {e}")
return None
except Exception as e:
logger.error(f"Erreur capture fenêtre active : {e}")
return None
def _compute_quick_hash(self, img: Image) -> str:
"""Calcule un hash rapide basé sur une vignette réduite pour détecter les changements."""
# On réduit l'image à 64x64 pour comparer les masses de couleurs (très rapide)

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
import platform
import subprocess
from typing import Dict, Optional
from typing import Any, Dict, Optional
def _run_cmd(cmd: list[str]) -> Optional[str]:
@@ -36,11 +36,11 @@ def get_active_window_info() -> Dict[str, str]:
"title": "...",
"app_name": "..."
}
Détecte automatiquement l'OS et utilise la méthode appropriée.
"""
system = platform.system()
if system == "Linux":
return _get_window_info_linux()
elif system == "Windows":
@@ -51,6 +51,32 @@ def get_active_window_info() -> Dict[str, str]:
return {"title": "unknown_window", "app_name": "unknown_app"}
def get_active_window_rect() -> Optional[Dict[str, Any]]:
"""
Renvoie le rectangle de la fenêtre active :
{
"title": "...",
"app_name": "...",
"rect": [left, top, right, bottom],
"position": [left, top],
"size": [width, height],
"hwnd": int # Windows uniquement
}
Retourne None si la fenêtre est introuvable ou minimisée.
Détecte automatiquement l'OS et utilise la méthode appropriée.
"""
system = platform.system()
if system == "Windows":
return _get_window_rect_windows()
elif system == "Linux":
return _get_window_rect_linux()
elif system == "Darwin":
return _get_window_rect_macos()
return None
def _get_window_info_linux() -> Dict[str, str]:
"""
Linux: utilise xdotool (X11)
@@ -178,6 +204,163 @@ def _get_window_info_macos() -> Dict[str, str]:
}
def _get_window_rect_windows() -> Optional[Dict[str, Any]]:
"""
Windows : utilise pywin32 pour obtenir le rectangle de la fenêtre active.
Retourne None si la fenêtre est minimisée (icônifiée) ou si pywin32 manque.
"""
try:
import win32gui
import win32process
import psutil
hwnd = win32gui.GetForegroundWindow()
if not hwnd:
return None
# Ignorer les fenêtres minimisées (pas de contenu visible)
if win32gui.IsIconic(hwnd):
return None
title = win32gui.GetWindowText(hwnd) or "unknown_window"
# Rectangle de la fenêtre (coordonnées écran absolues)
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
width = right - left
height = bottom - top
# Ignorer les fenêtres de taille nulle ou absurde
if width <= 0 or height <= 0:
return None
# Nom du processus
_, pid = win32process.GetWindowThreadProcessId(hwnd)
try:
app_name = psutil.Process(pid).name()
except Exception:
app_name = "unknown_app"
return {
"title": title,
"app_name": app_name,
"rect": [left, top, right, bottom],
"position": [left, top],
"size": [width, height],
"hwnd": hwnd,
}
except ImportError:
return None
except Exception:
return None
def _get_window_rect_linux() -> Optional[Dict[str, Any]]:
"""
Linux (X11) : utilise xdotool + xwininfo pour obtenir le rectangle.
Nécessite : sudo apt-get install xdotool x11-utils
"""
try:
# Identifiant de la fenêtre active
wid = _run_cmd(["xdotool", "getactivewindow"])
if not wid:
return None
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) or "unknown_window"
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
app_name = "unknown_app"
if pid_str:
app_name = _run_cmd(["ps", "-p", pid_str.strip(), "-o", "comm="]) or "unknown_app"
# Géométrie via xdotool --shell (position + taille)
geom_raw = _run_cmd(["xdotool", "getwindowgeometry", "--shell", wid])
if not geom_raw:
return None
vals: Dict[str, int] = {}
for line in geom_raw.strip().splitlines():
if "=" in line:
k, v = line.split("=", 1)
try:
vals[k.strip()] = int(v.strip())
except ValueError:
pass
if not {"X", "Y", "WIDTH", "HEIGHT"} <= vals.keys():
return None
x, y = vals["X"], vals["Y"]
w, h = vals["WIDTH"], vals["HEIGHT"]
return {
"title": title,
"app_name": app_name,
"rect": [x, y, x + w, y + h],
"position": [x, y],
"size": [w, h],
}
except Exception:
return None
def _get_window_rect_macos() -> Optional[Dict[str, Any]]:
"""
macOS : utilise Quartz (CGWindowListCopyWindowInfo) pour obtenir le rectangle.
Nécessite : pip install pyobjc-framework-Quartz
"""
try:
from AppKit import NSWorkspace
from Quartz import (
CGWindowListCopyWindowInfo,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
)
active_app = NSWorkspace.sharedWorkspace().activeApplication()
app_name = active_app.get("NSApplicationName", "unknown_app")
window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
)
for window in window_list:
owner_name = window.get("kCGWindowOwnerName", "")
if owner_name != app_name:
continue
bounds = window.get("kCGWindowBounds")
if not bounds:
continue
x = int(bounds.get("X", 0))
y = int(bounds.get("Y", 0))
w = int(bounds.get("Width", 0))
h = int(bounds.get("Height", 0))
if w <= 0 or h <= 0:
continue
title = window.get("kCGWindowName", "unknown_window") or "unknown_window"
return {
"title": title,
"app_name": app_name,
"rect": [x, y, x + w, y + h],
"position": [x, y],
"size": [w, h],
}
except ImportError:
return None
except Exception:
return None
return None
# Test rapide
if __name__ == "__main__":
import time
@@ -185,8 +368,13 @@ if __name__ == "__main__":
print(f"OS détecté: {platform.system()}")
print("\nTest de capture fenêtre active (5 secondes)...")
print("Changez de fenêtre pour tester!\n")
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']}")
if rect:
print(f" Rect: {rect['rect']} | Size: {rect['size']}")
else:
print(" Rect: non disponible")
time.sleep(1)

View File

@@ -512,6 +512,21 @@ class ActionExecutorV1:
x_pct = action.get("x_pct", 0.0)
y_pct = action.get("y_pct", 0.0)
# QW1 — Si le serveur a résolu un monitor cible (idx >= 0),
# appliquer son offset aux coords absolues. Pour idx == -1
# (composite_fallback), aucun offset (backward compat).
# Le calcul des coords reste percent * (width/height) du monitor[1]
# côté client (x_pct est exprimé sur l'écran physique principal).
mon_res = action.get("monitor_resolution") or {}
mon_idx = mon_res.get("idx", -1)
mon_offset_x = mon_res.get("offset_x", 0) if mon_idx >= 0 else 0
mon_offset_y = mon_res.get("offset_y", 0) if mon_idx >= 0 else 0
if mon_idx >= 0 and (mon_offset_x or mon_offset_y):
logger.info(
f"[REPLAY] QW1 monitor cible idx={mon_idx} source={mon_res.get('source')} "
f"offset=({mon_offset_x},{mon_offset_y}) — appliqué aux coords"
)
# ── Diagnostic résolution ──
logger.info(
f"[REPLAY] Action {action_id} ({action_type}) — "
@@ -578,8 +593,8 @@ class ActionExecutorV1:
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
if popup_coords:
real_x = int(popup_coords["x_pct"] * width)
real_y = int(popup_coords["y_pct"] * height)
real_x = int(popup_coords["x_pct"] * width) + mon_offset_x
real_y = int(popup_coords["y_pct"] * height) + mon_offset_y
self._click((real_x, real_y), "left")
time.sleep(1.0)
print(f" [OBSERVER] Popup fermée — reprise du flow normal")
@@ -718,8 +733,8 @@ class ActionExecutorV1:
self.notifier.replay_target_not_found(target_desc)
return result
real_x = int(x_pct * width)
real_y = int(y_pct * height)
real_x = int(x_pct * width) + mon_offset_x
real_y = int(y_pct * height) + mon_offset_y
button = action.get("button", "left")
mode = "VISUAL" if result.get("visual_resolved") else "COORD"
print(
@@ -781,8 +796,8 @@ class ActionExecutorV1:
print(f" [TYPE] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
# Cliquer sur le champ avant de taper (si coordonnees disponibles)
if x_pct > 0 and y_pct > 0:
real_x = int(x_pct * width)
real_y = int(y_pct * height)
real_x = int(x_pct * width) + mon_offset_x
real_y = int(y_pct * height) + mon_offset_y
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
self._click((real_x, real_y), "left")
time.sleep(0.3)
@@ -808,8 +823,8 @@ class ActionExecutorV1:
logger.info(f"Replay key_combo : {keys} (raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "scroll":
real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width)
real_y = int(y_pct * height) if y_pct > 0 else int(0.5 * height)
real_x = (int(x_pct * width) if x_pct > 0 else int(0.5 * width)) + mon_offset_x
real_y = (int(y_pct * height) if y_pct > 0 else int(0.5 * height)) + mon_offset_y
delta = action.get("delta", -3)
print(f" [SCROLL] delta={delta} a ({real_x}, {real_y})")
self.mouse.position = (real_x, real_y)
@@ -1386,6 +1401,16 @@ Example: x_pct=0.50, y_pct=0.30"""
data = resp.json()
action = data.get("action")
if action is None:
# pause_for_human : afficher le message de décision à l'utilisateur
if data.get("replay_paused") and data.get("pause_message"):
msg = data["pause_message"]
print(f"[PAUSE] {msg}")
logger.info(f"Replay en pause — message : {msg}")
self.notifier.notify(
title="Léa — Validation requise",
message=msg[:250],
timeout=30,
)
return False
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:

View File

@@ -319,7 +319,22 @@ class AgentV1:
if img_hash != self._last_heartbeat_hash:
self._last_heartbeat_hash = img_hash
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id})
heartbeat_event = {
"type": "heartbeat",
"image": full_path,
"timestamp": time.time(),
"machine_id": self.machine_id,
}
# QW1 — enrichissement multi-écrans (monitor_index + monitors_geometry)
# Additif, fallback gracieux : sans cet enrichissement, le serveur
# ne reçoit l'info qu'au moment des clics, donc QW1 ne s'active
# pas en continu sur poste Windows multi-écrans.
try:
from .vision.capturer import _enrich_with_monitor_info
_enrich_with_monitor_info(heartbeat_event)
except Exception as e:
logger.debug("QW1 enrichissement heartbeat échoué: %s", e)
self.streamer.push_event(heartbeat_event)
except Exception as e:
logger.error(f"Heartbeat error: {e}")
time.sleep(5)

View File

@@ -8,12 +8,73 @@ import os
import time
import logging
import hashlib
from typing import Any, Dict, List, Optional
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY
logger = logging.getLogger(__name__)
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
try:
from screeninfo import get_monitors as _screeninfo_get_monitors
_SCREENINFO_AVAILABLE = True
except ImportError:
_SCREENINFO_AVAILABLE = False
def _get_monitors_geometry() -> List[Dict[str, Any]]:
"""Retourne la liste des monitors physiques avec leurs offsets.
Returns:
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
indisponible (le serveur tombera sur fallback composite).
"""
if not _SCREENINFO_AVAILABLE:
return []
try:
monitors = _screeninfo_get_monitors()
return [
{
"idx": i,
"x": int(m.x),
"y": int(m.y),
"w": int(m.width),
"h": int(m.height),
"primary": bool(getattr(m, "is_primary", False)),
}
for i, m in enumerate(monitors)
]
except Exception:
return []
def _get_active_monitor_index() -> Optional[int]:
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
Returns:
int ou None si indéterminable.
"""
if not _SCREENINFO_AVAILABLE:
return None
try:
import pyautogui # import paresseux : évite la dépendance dure
cx, cy = pyautogui.position()
for i, m in enumerate(_screeninfo_get_monitors()):
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
return i
except Exception:
return None
return None
def _enrich_with_monitor_info(payload: dict) -> dict:
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
if isinstance(payload, dict):
payload["monitor_index"] = _get_active_monitor_index()
payload["monitors_geometry"] = _get_monitors_geometry()
return payload
class VisionCapturer:
def __init__(self, session_dir: str):
self.session_dir = session_dir
@@ -72,7 +133,12 @@ class VisionCapturer:
# Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img)
return {"full": full_path, "crop": crop_path}
result = {"full": full_path, "crop": crop_path}
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
_enrich_with_monitor_info(result)
return result
except Exception as e:
logger.error(f"Erreur Dual Capture: {e}")
return {}

View File

@@ -3,7 +3,9 @@ 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
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
pystray>=0.19.5 # Icône Tray UI
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)

View File

@@ -21,36 +21,33 @@ from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger("lea_ui.server_client")
def _get_server_host() -> str:
"""Recuperer l'adresse du serveur Linux.
def _get_server_url() -> str:
"""Recuperer l'URL du serveur RPA (avec /api/v1).
Ordre de resolution :
1. Variable d'environnement RPA_SERVER_HOST
2. Fichier de config agent_config.json (cle "server_host")
3. Fallback localhost
1. Import depuis agent_v1.config (source de verite unique)
2. Variable d'environnement RPA_SERVER_URL
3. Fallback http://localhost:5005/api/v1
"""
# 1. Variable d'environnement
host = os.environ.get("RPA_SERVER_HOST", "").strip()
if host:
return host
# 1. Import depuis config.py (source de verite)
try:
from agent_v1.config import SERVER_URL
return SERVER_URL
except ImportError:
pass
# 2. Fichier de config
config_paths = [
os.path.join(os.path.dirname(__file__), "..", "agent_config.json"),
os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"),
]
for config_path in config_paths:
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
host = cfg.get("server_host", "").strip()
if host:
return host
except (OSError, json.JSONDecodeError):
continue
# 2. Variable d'environnement directe
url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
if url:
return url
# 3. Fallback
return "localhost"
return "http://localhost:5005/api/v1"
def _get_server_base(server_url: str) -> str:
"""Extraire la base URL (sans /api/v1) pour les routes racine (/health)."""
return server_url.rsplit("/api/v1", 1)[0]
class LeaServerClient:
@@ -67,19 +64,22 @@ class LeaServerClient:
chat_port: int = 5004,
stream_port: int = 5005,
) -> None:
self._host = server_host or _get_server_host()
# URL unifiée : SERVER_URL contient TOUJOURS /api/v1 (convention INC-1).
# _stream_url = URL avec /api/v1 (pour les routes API)
# _stream_base = URL sans /api/v1 (pour /health uniquement)
self._stream_url = _get_server_url()
self._stream_base = _get_server_base(self._stream_url)
# Extraire le host depuis l'URL pour le chat et pour l'affichage
try:
from urllib.parse import urlparse
parsed = urlparse(self._stream_base)
self._host = parsed.hostname or "localhost"
except Exception:
self._host = server_host or "localhost"
self._chat_port = chat_port
self._stream_port = stream_port
# En prod, la base URL passe par le reverse proxy HTTPS
# (ex. https://lea.labs.laurinebazin.design). Si RPA_SERVER_URL est
# definie on l'utilise telle quelle, sinon on reconstruit http://host:port.
server_url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
if server_url:
self._stream_base = server_url
else:
self._stream_base = f"http://{self._host}:{self._stream_port}"
self._chat_base = f"http://{self._host}:{self._chat_port}"
# Etat de connexion
@@ -103,8 +103,8 @@ class LeaServerClient:
self._api_token = os.environ.get("RPA_API_TOKEN", "")
logger.info(
"LeaServerClient initialise : chat=%s, stream=%s",
self._chat_base, self._stream_base,
"LeaServerClient initialise : chat=%s, stream_url=%s, stream_base=%s",
self._chat_base, self._stream_url, self._stream_base,
)
# ---------------------------------------------------------------------------
@@ -154,7 +154,11 @@ class LeaServerClient:
# ---------------------------------------------------------------------------
def check_connection(self) -> bool:
"""Tester la connexion au serveur streaming (port 5005)."""
"""Tester la connexion au serveur streaming (port 5005).
Le health check utilise _stream_base (sans /api/v1) car la route
/health est a la racine du serveur FastAPI, pas sous /api/v1.
"""
try:
import requests
resp = requests.get(
@@ -227,7 +231,7 @@ class LeaServerClient:
import requests
headers = self._auth_headers()
resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/workflows",
f"{self._stream_url}/traces/stream/workflows",
headers=headers,
timeout=10,
)
@@ -284,7 +288,7 @@ class LeaServerClient:
while self._polling:
try:
resp = req_lib.get(
f"{self._stream_base}/api/v1/traces/stream/replay/next",
f"{self._stream_url}/traces/stream/replay/next",
params={"session_id": self._poll_session_id},
headers=self._auth_headers(),
timeout=5,
@@ -318,7 +322,7 @@ class LeaServerClient:
try:
import requests
resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/replays",
f"{self._stream_url}/traces/stream/replays",
headers=self._auth_headers(),
timeout=5,
)
@@ -346,7 +350,7 @@ class LeaServerClient:
try:
import requests
requests.post(
f"{self._stream_base}/api/v1/traces/stream/replay/result",
f"{self._stream_url}/traces/stream/replay/result",
json={
"session_id": session_id,
"action_id": action_id,

View File

@@ -33,6 +33,8 @@ from .audit_trail import AuditTrail, AuditEntry
from .agent_registry import AgentRegistry, AgentAlreadyEnrolledError
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
from .worker_stream import StreamWorker
from .monitor_router import resolve_target_monitor # QW1 — résolution écran cible
from .loop_detector import LoopDetector # QW2 — détection de boucle pendant replay
from .execution_plan_runner import (
execution_plan_to_actions,
inject_plan_into_queue,
@@ -219,6 +221,11 @@ from .replay_engine import (
_is_learned_workflow,
_edge_to_normalized_actions,
_substitute_variables,
_resolve_runtime_vars,
_SERVER_SIDE_ACTION_TYPES,
_handle_extract_text_action,
_handle_extract_table_action,
_handle_t2a_decision_action,
_expand_compound_steps,
_pre_check_screen_state as _pre_check_screen_state_impl,
_detect_popup_hint as _detect_popup_hint_impl,
@@ -292,6 +299,20 @@ app.add_middleware(
)
@app.middleware("http")
async def url_compat_rewrite(request: Request, call_next):
"""Rétrocompatibilité : réécriture des anciennes URLs sans préfixe /api/v1.
Certains agents clients (Léa V1 gelée) envoient sur /traces/stream/...
au lieu de /api/v1/traces/stream/... Ce middleware redirige silencieusement.
"""
path = request.url.path
if path.startswith("/traces/stream/") and not path.startswith("/api/v1/"):
new_path = "/api/v1" + path
request.scope["path"] = new_path
return await call_next(request)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""Ajouter les headers de sécurité sur toutes les réponses."""
@@ -341,6 +362,18 @@ REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor)
# QW2 — LoopDetector singleton lazy (utilise le CLIP embedder du processor)
_loop_detector: Optional["LoopDetector"] = None
def _get_loop_detector() -> "LoopDetector":
"""Singleton lazy — crée le LoopDetector avec le CLIP embedder du processor."""
global _loop_detector
if _loop_detector is None:
embedder = getattr(processor, "_clip_embedder", None)
_loop_detector = LoopDetector(clip_embedder=embedder)
return _loop_detector
# Registre des postes Lea enroles (table enrolled_agents dans rpa_data.db)
# Emplacement configurable via RPA_AGENTS_DB_PATH pour les tests.
_AGENTS_DB_PATH = os.environ.get(
@@ -493,6 +526,7 @@ class ReplayRequest(BaseModel):
session_id: str
machine_id: Optional[str] = None # Machine cible pour le replay (multi-machine)
params: Optional[Dict[str, Any]] = None
variables: Optional[Dict[str, Any]] = None # Variables runtime initiales (templating {{var}})
class RawReplayRequest(BaseModel):
@@ -747,6 +781,21 @@ async def startup():
_cleanup_thread = threading.Thread(target=_cleanup_loop, daemon=True, name="replay_cleanup")
_cleanup_thread.start()
# Préchargement EasyOCR en arrière-plan : sans ça, le 1er extract_text /
# extract_table déclenche un cold start de ~3-5s qui bloque l'event loop
# FastAPI (constaté 2026-05-05 : streaming server inaccessible 2 min).
# Le thread tourne pendant que le boot continue ; le 1er appel OCR sera rapide.
def _preload_easyocr():
try:
t0 = time.time()
from core.llm.ocr_extractor import _get_reader
_get_reader()
logger.info("[OCR] EasyOCR préchargé (fr+en, CPU) en %.1fs", time.time() - t0)
except Exception as e:
logger.warning("[OCR] Échec préchargement EasyOCR : %s", e)
threading.Thread(target=_preload_easyocr, daemon=True, name="preload_easyocr").start()
logger.info(
"API Streaming démarrée — StreamProcessor, Worker et Cleanup prêts. "
"VLM Worker dans un process séparé (run_worker.py)."
@@ -1944,6 +1993,11 @@ async def start_replay(request: ReplayRequest):
machine_id=resolved_machine_id,
actions=actions,
)
# Pré-injection des variables runtime (templating {{var}} sur by_text,
# text, target_spec.* etc.). Permet à l'orchestrateur d'appeler ce
# workflow avec p.ex. variables={"patient_id": "25003284"} pour boucler.
if request.variables:
_replay_states[replay_id]["variables"].update(request.variables)
# Enregistrer le mapping machine -> session pour le replay ciblé
if resolved_machine_id and resolved_machine_id != "default":
_machine_replay_target[resolved_machine_id] = session_id
@@ -2744,8 +2798,29 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
Si la session de l'agent n'a pas d'actions en attente, cherche dans les
autres queues de la MÊME machine (pas cross-machine).
Acquire timeout : si une action serveur lente (extract_text OCR,
t2a_decision LLM) tient le lock, on retourne immédiatement
{action: None, server_busy: True} avant que le client ne timeout à 5s.
Sans cela, des actions seraient popped serveur puis envoyées sur des
sockets clients déjà fermées par timeout — perdues silencieusement.
L'acquire et les actions serveur lentes sont exécutés via
run_in_executor : sinon l'appel synchrone bloque l'event loop FastAPI
(single-threaded) et même les polls qui devraient recevoir server_busy
sont bloqués jusqu'à libération — ce qui annule l'effet du timeout.
"""
with _replay_lock:
import asyncio
loop = asyncio.get_event_loop()
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, 4.5)
if not acquired:
return {
"action": None,
"session_id": session_id,
"machine_id": machine_id,
"server_busy": True,
}
try:
# Verifier si le replay est en pause supervisee (target_not_found).
# Dans ce cas, NE PAS envoyer d'action — attendre l'intervention utilisateur.
for state in _replay_states.values():
@@ -2810,6 +2885,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
break
if target_state:
queue = target_queue
owning_replay = target_state
_replay_queues[session_id] = target_queue
del _replay_queues[target_sid]
target_state["session_id"] = session_id
@@ -2826,6 +2902,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
other_queue = _replay_queues.get(other_sid, [])
if other_queue:
queue = other_queue
owning_replay = state
_replay_queues[session_id] = other_queue
del _replay_queues[other_sid]
state["session_id"] = session_id
@@ -2836,8 +2913,132 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
if not queue:
return {"action": None, "session_id": session_id, "machine_id": machine_id}
# Peek à la prochaine action SANS la retirer (pour le pre-check)
action = queue[0]
# ── Boucle de traitement : actions serveur (extract_text, t2a_decision)
# exécutées entièrement côté serveur jusqu'à trouver une action visuelle
# à transmettre à l'Agent V1 ou un pause_for_human qui bloque le replay.
action = None
while queue:
action = queue[0]
# Résoudre les variables runtime ({{var}} et {{var.field}})
if owning_replay is not None:
runtime_vars = owning_replay.get("variables") or {}
if runtime_vars:
action = _resolve_runtime_vars(action, runtime_vars)
type_ = action.get("type")
# pause_for_human : pause supervisée si safety_level/safety_checks ou mode supervised,
# sinon no-op en mode autonome (skip).
if type_ == "pause_for_human":
_params = action.get("parameters") or {}
_exec_mode = (
(owning_replay or {}).get("params", {}).get("execution_mode", "autonomous")
if owning_replay else "autonomous"
)
_has_safety_decl = bool(_params.get("safety_level") or _params.get("safety_checks"))
_is_supervised = _exec_mode != "autonomous"
if owning_replay is not None and (_has_safety_decl or _is_supervised):
# QW4 — Construire le payload de pause enrichi (déclaratif + LLM contextuel)
try:
from agent_v0.server_v1.safety_checks_provider import build_pause_payload
last_screenshot_path = owning_replay.get("last_screenshot")
payload = build_pause_payload(action, owning_replay, last_screenshot_path)
owning_replay["safety_checks"] = payload.checks
owning_replay["pause_payload"] = {
"checks": payload.checks,
"pause_reason": payload.pause_reason,
"message": payload.message,
}
if payload.message:
owning_replay["pause_message"] = payload.message
# Bus event d'observabilité (pattern QW1/QW2 = logger.info)
logger.info(
"[BUS] lea:safety_checks_generated replay=%s count=%d sources=%s",
owning_replay.get("replay_id", "?"),
len(payload.checks),
[c["source"] for c in payload.checks],
)
except Exception as e:
logger.warning("QW4 build_pause_payload échec (%s) — pause sans checks", e)
owning_replay["safety_checks"] = []
# Conserver le contexte de l'action (audit + reprise)
owning_replay["failed_action"] = {
"action_id": action.get("action_id"),
"type": "pause_for_human",
"reason": "user_request",
}
owning_replay["status"] = "paused_need_help"
queue.pop(0)
_replay_queues[session_id] = queue
return {"action": None, "session_id": session_id, "machine_id": machine_id}
# Mode autonome sans safety_checks → skip (comportement legacy)
logger.info(
"pause_for_human ignorée (mode autonome) — replay %s continue",
owning_replay["replay_id"] if owning_replay else "?"
)
queue.pop(0)
_replay_queues[session_id] = queue
continue
# Actions serveur : exécuter HORS event loop pour ne pas bloquer
# les autres polls (extract_text OCR ~5s, t2a_decision LLM ~8-13s).
# Le lock reste tenu (queue cohérente) mais l'event loop est libre,
# donc les polls concurrents peuvent recevoir {server_busy: True}.
if type_ in _SERVER_SIDE_ACTION_TYPES and owning_replay is not None:
try:
if type_ == "extract_text":
await loop.run_in_executor(
None,
_handle_extract_text_action,
action, owning_replay, session_id, _last_heartbeat,
)
elif type_ == "extract_table":
await loop.run_in_executor(
None,
_handle_extract_table_action,
action, owning_replay, session_id, _last_heartbeat,
)
elif type_ == "t2a_decision":
await loop.run_in_executor(
None,
_handle_t2a_decision_action,
action, owning_replay,
)
except Exception as e:
logger.warning(f"Action serveur {type_} a levé : {e}")
queue.pop(0)
_replay_queues[session_id] = queue
continue # action suivante
# Clic conditionnel : si l'action a un paramètre "condition", évaluer la variable
# Format : "dec.critere1_valide" → runtime_vars["dec"]["critere1_valide"]
condition_key = (action.get("parameters") or {}).get("condition")
if condition_key and owning_replay is not None:
runtime_vars = owning_replay.get("variables") or {}
parts = condition_key.split(".", 1)
if len(parts) == 2:
val = (runtime_vars.get(parts[0]) or {}).get(parts[1])
else:
val = runtime_vars.get(parts[0])
if not val:
logger.info("Clic conditionnel ignoré (%s=%s) — action %s",
condition_key, val, action.get("action_id", "?"))
queue.pop(0)
_replay_queues[session_id] = queue
continue
# Action visuelle : sortir de la boucle pour la transmettre à l'Agent V1
break
# Si la queue s'est vidée après les exécutions serveur, rien à transmettre
if not queue or action is None:
return {"action": None, "session_id": session_id, "machine_id": machine_id}
finally:
_replay_lock.release()
# ---- Pre-check écran (optionnel, non bloquant) ----
# Ne s'applique qu'aux actions qui ont un from_node (actions de workflow,
@@ -3004,6 +3205,51 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
f"{_precheck_sim}"
)
# QW1 — Résoudre l'écran cible et joindre l'info à l'action
# Cascade : action.monitor_index → session.last_focused_monitor → composite_fallback
try:
session_qw1 = processor.session_manager.get_session(session_id)
last_window_info_qw1 = (
session_qw1.last_window_info if session_qw1 is not None else {}
) or {}
session_state_qw1 = {
"monitors_geometry": last_window_info_qw1.get("monitors_geometry", []),
"last_focused_monitor": last_window_info_qw1.get("monitor_index"),
}
target = resolve_target_monitor(action, session_state_qw1)
action["monitor_resolution"] = {
"idx": target.idx,
"offset_x": target.offset_x,
"offset_y": target.offset_y,
"w": target.w,
"h": target.h,
"source": target.source,
}
# QW1 — Émission bus lea:monitor_routed (no-op si bus indisponible)
# Le serveur streaming n'a pas de SocketIO local : on logge en INFO
# bien lisible. Un consommateur (agent_chat / dashboard) peut tailer
# `journalctl -u rpa-streaming | grep '\[BUS\] lea:monitor_routed'`.
try:
_replay_id_bus = (
owning_replay.get("replay_id") if owning_replay else None
)
logger.info(
"[BUS] lea:monitor_routed replay=%s action=%s idx=%d source=%s "
"offset=(%d,%d) wh=(%d,%d)",
_replay_id_bus,
action.get("action_id"),
target.idx,
target.source,
target.offset_x,
target.offset_y,
target.w,
target.h,
)
except Exception as _e_bus:
logger.debug("emit lea:monitor_routed échec (non bloquant): %s", _e_bus)
except Exception as e:
logger.debug("QW1 monitor_resolution skip (%s)", e)
response: Dict[str, Any] = {
"action": action,
"session_id": session_id,
@@ -3742,6 +3988,82 @@ async def report_action_result(report: ReplayResultReport):
f"— worker VLM autorisé à reprendre"
)
# ===================================================================
# QW2 — LoopDetector : alimentation des anneaux + évaluation
# ===================================================================
# On n'évalue que si le replay est encore "running" — inutile de
# pauser quelque chose de déjà completed/error/paused.
if replay_state["status"] == "running":
# Snapshot image (PIL) dans l'anneau
try:
from PIL import Image
ss_raw = screenshot_after or replay_state.get("last_screenshot")
img = None
if isinstance(ss_raw, str) and ss_raw:
if os.path.isfile(ss_raw):
img = Image.open(ss_raw).copy() # détache du file handle
else:
# Possible base64 — décoder
try:
import base64
import io as _io
img_bytes = base64.b64decode(ss_raw, validate=False)
img = Image.open(_io.BytesIO(img_bytes)).copy()
except Exception:
img = None
if img is not None:
replay_state.setdefault("_screenshot_history", []).append(img)
replay_state["_screenshot_history"] = replay_state["_screenshot_history"][-5:]
except Exception as e:
logger.debug("LoopDetector: snapshot historique échoué: %s", e)
# Snapshot signature de l'action courante
try:
_act_pos = report.actual_position or {}
action_sig = {
"type": (original_action or {}).get("type")
or replay_state.get("_last_action_type", ""),
"x_pct": _act_pos.get("x_pct") if isinstance(_act_pos, dict)
else (original_action or {}).get("x_pct"),
"y_pct": _act_pos.get("y_pct") if isinstance(_act_pos, dict)
else (original_action or {}).get("y_pct"),
}
replay_state.setdefault("_action_history", []).append(action_sig)
replay_state["_action_history"] = replay_state["_action_history"][-5:]
except Exception as e:
logger.debug("LoopDetector: snapshot action_sig échoué: %s", e)
# Évaluation (silencieux si rien)
try:
verdict = _get_loop_detector().evaluate(
replay_state,
screenshots=replay_state.get("_screenshot_history", []),
actions=replay_state.get("_action_history", []),
)
if verdict.detected:
replay_state["status"] = "paused_need_help"
replay_state["pause_reason"] = "loop_detected"
replay_state["pause_message"] = (
f"Léa semble bloquée — {verdict.signal} "
f"(détail: {verdict.evidence})"
)
logger.warning(
"LoopDetector: replay %s mis en pause — signal=%s evidence=%s",
replay_state["replay_id"], verdict.signal, verdict.evidence,
)
# Bus event d'observabilité (logger pattern QW1)
try:
logger.info(
"[BUS] lea:loop_detected replay=%s signal=%s evidence=%s",
replay_state["replay_id"],
verdict.signal,
verdict.evidence,
)
except Exception as _e_bus:
logger.debug("emit lea:loop_detected échec: %s", _e_bus)
except Exception as e:
logger.warning("LoopDetector: évaluation échouée (non bloquant): %s", e)
return {
"status": "recorded",
"action_id": action_id,
@@ -3828,8 +4150,16 @@ async def list_replays():
}
class ReplayResumeRequest(BaseModel):
"""Body optionnel pour /replay/resume — QW4 acquittement de safety_checks."""
acknowledged_check_ids: List[str] = []
@app.post("/api/v1/traces/stream/replay/{replay_id}/resume")
async def resume_replay(replay_id: str):
async def resume_replay(
replay_id: str,
payload: Optional[ReplayResumeRequest] = None,
):
"""Reprendre un replay en pause supervisee (paused_need_help).
L'utilisateur a intervenu manuellement (naviguer vers le bon ecran,
@@ -3837,6 +4167,10 @@ async def resume_replay(replay_id: str):
est reinjectee en tete de queue pour etre re-tentee.
Si le replay n'est pas en pause, retourne une erreur 409 (conflit).
QW4 — Si des safety_checks sont attachés à la pause, tous ceux marqués
`required` doivent figurer dans `acknowledged_check_ids`. Sinon → 400
avec `{"error": "required_checks_missing", "missing": [...]}`.
"""
with _replay_lock:
state = _replay_states.get(replay_id)
@@ -3855,6 +4189,25 @@ async def resume_replay(replay_id: str):
),
)
# QW4 — Vérification des safety_checks required avant reprise
safety_checks = state.get("safety_checks") or []
ack_ids = (payload.acknowledged_check_ids if payload else []) or []
if safety_checks:
required_ids = {c["id"] for c in safety_checks if c.get("required")}
ack_set = set(ack_ids)
missing = sorted(required_ids - ack_set)
if missing:
raise HTTPException(
status_code=400,
detail={"error": "required_checks_missing", "missing": missing},
)
# Audit trail
state["checks_acknowledged"] = sorted(ack_set)
logger.info(
"QW4 resume replay=%s acquittements=%d (%s)",
state.get("replay_id"), len(ack_set), sorted(ack_set),
)
# Recuperer l'action echouee pour la reinjecter
failed_action = state.get("failed_action")
session_id = state["session_id"]
@@ -3863,9 +4216,15 @@ async def resume_replay(replay_id: str):
state["status"] = "running"
state["failed_action"] = None
state["pause_message"] = None
# QW4 — vider safety_checks après acquittement (la pause est résolue)
state["safety_checks"] = []
state["pause_payload"] = None
state["pause_reason"] = ""
# Reinjecter l'action echouee en tete de queue (sera re-tentee)
if failed_action and failed_action.get("action_id"):
# pause_for_human est une pause intentionnelle, pas une erreur — ne pas réinjecter
if (failed_action and failed_action.get("action_id")
and failed_action.get("reason") != "user_request"):
# Reconstruire l'action a partir du retry_pending ou de l'original
original_action_id = failed_action["action_id"]
# Chercher l'action originale dans les retry_pending
@@ -3906,6 +4265,26 @@ async def resume_replay(replay_id: str):
}
@app.post("/api/v1/traces/stream/replay/{replay_id}/cancel")
async def cancel_replay(replay_id: str):
"""Annuler un replay (quel que soit son statut) et vider sa queue."""
with _replay_lock:
state = _replay_states.get(replay_id)
if not state:
raise HTTPException(status_code=404, detail=f"Replay '{replay_id}' non trouvé")
session_id = state["session_id"]
state["status"] = "cancelled"
state["failed_action"] = None
state["pause_message"] = None
_replay_queues[session_id] = []
keys_to_del = [k for k, v in _retry_pending.items() if v.get("replay_id") == replay_id]
for k in keys_to_del:
_retry_pending.pop(k, None)
logger.info("Replay %s annulé manuellement", replay_id)
return {"status": "cancelled", "replay_id": replay_id, "session_id": session_id}
# =========================================================================
# Visual Replay — Résolution visuelle des cibles (module resolve_engine)
# =========================================================================

View File

@@ -256,6 +256,20 @@ class LiveSessionManager:
session.last_window_info["title"] = wc_title
if wc_app:
session.last_window_info["app_name"] = wc_app
# QW1 — propager monitor_index et monitors_geometry depuis window_capture
if "monitor_index" in window_capture:
session.last_window_info["monitor_index"] = window_capture["monitor_index"]
if "monitors_geometry" in window_capture:
session.last_window_info["monitors_geometry"] = window_capture["monitors_geometry"]
# QW1 — propager monitor_index/monitors_geometry du payload event
# (cas heartbeat enrichi sans window/window_title). Toujours
# rafraîchir le focus actif (change souvent) et la géométrie
# (l'utilisateur peut brancher/débrancher un écran).
if "monitor_index" in event_data:
session.last_window_info["monitor_index"] = event_data["monitor_index"]
if "monitors_geometry" in event_data and event_data["monitors_geometry"]:
session.last_window_info["monitors_geometry"] = event_data["monitors_geometry"]
# Accumuler les titres/apps pour le nommage automatique
title = session.last_window_info.get("title", "").strip()

View File

@@ -0,0 +1,154 @@
# agent_v0/server_v1/loop_detector.py
"""LoopDetector composite — détection de stagnation de Léa pendant un replay (QW2).
Trois signaux indépendants :
- screen_static : N captures consécutives avec CLIP similarity > seuil
- action_repeat : N actions consécutives identiques (type + coords)
- retry_threshold : nombre de retries cumulés >= seuil
Un seul signal positif → verdict.detected=True. Le serveur bascule alors le
replay en paused_need_help avec pause_reason explicite.
Désactivable via env var RPA_LOOP_DETECTOR_ENABLED=0.
"""
import logging
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class LoopVerdict:
detected: bool = False
reason: str = ""
signal: str = "" # "screen_static" | "action_repeat" | "retry_threshold" | ""
evidence: Dict[str, Any] = field(default_factory=dict)
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_bool_enabled(name: str) -> bool:
val = os.environ.get(name, "1").strip().lower()
return val not in ("0", "false", "no", "off", "")
def _cosine_similarity(a, b) -> float:
"""Similarité cosine entre deux vecteurs (listes ou np.array). Robuste vecteur nul."""
import numpy as np
av = np.asarray(a, dtype=np.float32).flatten()
bv = np.asarray(b, dtype=np.float32).flatten()
na, nb = float(np.linalg.norm(av)), float(np.linalg.norm(bv))
if na < 1e-8 or nb < 1e-8:
return 0.0
return float(np.dot(av, bv) / (na * nb))
class LoopDetector:
def __init__(self, clip_embedder=None):
self.clip_embedder = clip_embedder
def evaluate(
self,
state: Dict[str, Any],
screenshots: List[Any],
actions: List[Dict[str, Any]],
) -> LoopVerdict:
"""Évalue les 3 signaux. Retourne le premier déclenché.
Args:
state: replay_state (utilisé pour retried_actions)
screenshots: anneau d'embeddings CLIP (les N derniers)
actions: anneau des N dernières actions exécutées
"""
if not _env_bool_enabled("RPA_LOOP_DETECTOR_ENABLED"):
return LoopVerdict(detected=False)
# Signal A : screen_static
verdict = self._check_screen_static(screenshots)
if verdict.detected:
return verdict
# Signal B : action_repeat
verdict = self._check_action_repeat(actions)
if verdict.detected:
return verdict
# Signal C : retry_threshold
verdict = self._check_retry_threshold(state)
if verdict.detected:
return verdict
return LoopVerdict(detected=False)
def _check_screen_static(self, screenshots: List[Any]) -> LoopVerdict:
n_required = _env_int("RPA_LOOP_SCREEN_STATIC_N", 4)
threshold = _env_float("RPA_LOOP_SCREEN_STATIC_THRESHOLD", 0.99)
if self.clip_embedder is None or len(screenshots) < n_required:
return LoopVerdict()
try:
recent = screenshots[-n_required:]
# Embed chaque capture via le CLIP embedder (peut lever)
embeddings = [self.clip_embedder.embed_image(img) for img in recent]
sims = [_cosine_similarity(embeddings[i], embeddings[i + 1])
for i in range(len(embeddings) - 1)]
min_sim = min(sims)
if min_sim > threshold:
return LoopVerdict(
detected=True,
reason="loop_detected",
signal="screen_static",
evidence={"min_similarity": round(min_sim, 4),
"n_captures": n_required,
"threshold": threshold},
)
except Exception as e:
logger.warning("LoopDetector signal_A erreur (%s) — signal inerte ce tick", e)
return LoopVerdict()
def _check_action_repeat(self, actions: List[Dict[str, Any]]) -> LoopVerdict:
n_required = _env_int("RPA_LOOP_ACTION_REPEAT_N", 3)
if len(actions) < n_required:
return LoopVerdict()
recent = actions[-n_required:]
def _signature(a: Dict[str, Any]) -> tuple:
return (a.get("type"), a.get("x_pct"), a.get("y_pct"))
sigs = [_signature(a) for a in recent]
if all(s == sigs[0] for s in sigs):
return LoopVerdict(
detected=True,
reason="loop_detected",
signal="action_repeat",
evidence={"signature": sigs[0], "count": n_required},
)
return LoopVerdict()
def _check_retry_threshold(self, state: Dict[str, Any]) -> LoopVerdict:
threshold = _env_int("RPA_LOOP_RETRY_THRESHOLD", 3)
retried = int(state.get("retried_actions", 0))
if retried >= threshold:
return LoopVerdict(
detected=True,
reason="loop_detected",
signal="retry_threshold",
evidence={"retried_actions": retried, "threshold": threshold},
)
return LoopVerdict()

View File

@@ -0,0 +1,99 @@
# agent_v0/server_v1/monitor_router.py
"""MonitorRouter — résolution de l'écran cible pour le replay (QW1).
Stratégie en cascade :
1. action.monitor_index (hérité de la session source) → cible cet écran
2. session.last_focused_monitor (focus actif vu en dernier heartbeat) → fallback
3. composite (offset 0, 0) → backward compat
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
"""
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class MonitorTarget:
"""Représente l'écran cible résolu pour une action de replay."""
idx: int
offset_x: int
offset_y: int
w: int
h: int
source: str # "action" | "focus" | "composite_fallback"
_COMPOSITE_FALLBACK = MonitorTarget(
idx=-1,
offset_x=0,
offset_y=0,
w=0,
h=0,
source="composite_fallback",
)
def _find_monitor(geometry: List[Dict[str, Any]], idx: int) -> Optional[Dict[str, Any]]:
"""Retourne le monitor d'index donné, ou None si absent."""
for m in geometry:
if m.get("idx") == idx:
return m
return None
def _to_target(monitor: Dict[str, Any], source: str) -> MonitorTarget:
return MonitorTarget(
idx=int(monitor["idx"]),
offset_x=int(monitor.get("x", 0)),
offset_y=int(monitor.get("y", 0)),
w=int(monitor.get("w", 0)),
h=int(monitor.get("h", 0)),
source=source,
)
def resolve_target_monitor(
action: Dict[str, Any],
session_state: Dict[str, Any],
) -> MonitorTarget:
"""Résout l'écran cible d'une action de replay.
Args:
action: Dict de l'action (peut contenir `monitor_index`).
session_state: État de la session (doit contenir `monitors_geometry`
et `last_focused_monitor`).
Returns:
MonitorTarget avec l'offset à appliquer aux coordonnées de grounding.
"""
geometry: List[Dict[str, Any]] = session_state.get("monitors_geometry") or []
# 1. Cible explicite via action
explicit_idx = action.get("monitor_index")
if explicit_idx is not None and geometry:
m = _find_monitor(geometry, int(explicit_idx))
if m is not None:
return _to_target(m, source="action")
# Index invalide → on tombe sur le fallback focus
logger.warning(
"[BUS] lea:monitor_invalid_index requested=%d available_idx=%s",
int(explicit_idx), [g.get("idx") for g in geometry],
)
# 2. Fallback focus actif
focused_idx = session_state.get("last_focused_monitor")
if focused_idx is not None and geometry:
m = _find_monitor(geometry, int(focused_idx))
if m is not None:
return _to_target(m, source="focus")
logger.warning(
"[BUS] lea:monitor_unavailable focused_idx=%d available_idx=%s",
int(focused_idx), [g.get("idx") for g in geometry],
)
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
return _COMPOSITE_FALLBACK

View File

@@ -32,8 +32,16 @@ _ALLOWED_ACTION_TYPES = {
"click", "type", "key_combo", "scroll", "wait",
"file_open", "file_save", "file_close", "file_new", "file_dialog",
"double_click", "right_click", "drag",
"verify_screen", # Replay hybride : vérification visuelle entre groupes
"verify_screen", # Replay hybride : vérification visuelle entre groupes
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
}
# Types d'actions exécutées CÔTÉ SERVEUR (jamais transmises à l'Agent V1).
# Le pipeline /replay/next les traite en boucle interne et passe à l'action
# suivante jusqu'à trouver une action visuelle (à transmettre au client).
_SERVER_SIDE_ACTION_TYPES = {"extract_text", "t2a_decision"}
_MAX_ACTION_TEXT_LENGTH = 10000
_MAX_KEYS_PER_COMBO = 10
# Touches autorisées dans les key_combo (modificateurs + touches spéciales + caractères simples)
@@ -852,6 +860,30 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
keys = [action_params["key"]]
normalized["keys"] = keys
elif action_type == "pause_for_human":
normalized["type"] = "pause_for_human"
normalized["parameters"] = {
"message": action_params.get("message", "Validation requise"),
}
return [normalized] # pas de target/coords pour cette action logique
elif action_type == "extract_text":
normalized["type"] = "extract_text"
normalized["parameters"] = {
"output_var": action_params.get("output_var", "extracted_text"),
"paragraph": bool(action_params.get("paragraph", True)),
}
return [normalized]
elif action_type == "t2a_decision":
normalized["type"] = "t2a_decision"
normalized["parameters"] = {
"input_template": action_params.get("input_template", ""),
"output_var": action_params.get("output_var", "t2a_result"),
"model": action_params.get("model"),
}
return [normalized]
else:
logger.warning(f"Type d'action inconnu : {action_type}")
return []
@@ -886,6 +918,143 @@ def _substitute_variables(text: str, params: Dict[str, Any], defaults: Dict[str,
return re.sub(r'\$\{(\w+)\}', replacer, text)
# Regex pour le templating runtime : {{var}} ou {{var.champ}} ou {{var.champ.sous}}
_RUNTIME_VAR_PATTERN = re.compile(r'\{\{\s*(\w+)(?:\.([\w.]+))?\s*\}\}')
def _resolve_runtime_vars_in_str(text: str, variables: Dict[str, Any]) -> str:
"""Remplace {{var}} et {{var.field}} par leur valeur depuis le dict variables.
Variables/champs absents : laissés tels quels (ne casse pas le pipeline).
Pour les valeurs non-str (dict, list), str() est appelé.
"""
def replacer(match):
var_name = match.group(1)
path = match.group(2)
if var_name not in variables:
return match.group(0)
value = variables[var_name]
if path:
for field in path.split('.'):
if isinstance(value, dict) and field in value:
value = value[field]
else:
return match.group(0)
return str(value)
return _RUNTIME_VAR_PATTERN.sub(replacer, text)
def _resolve_runtime_vars(value: Any, variables: Dict[str, Any]) -> Any:
"""Résout récursivement les {{var}} et {{var.field}} dans une valeur.
Supporte str, dict, list. Les autres types sont retournés tels quels.
Si variables est vide ou None, value est retournée inchangée.
"""
if not variables:
return value
if isinstance(value, str):
return _resolve_runtime_vars_in_str(value, variables)
if isinstance(value, dict):
return {k: _resolve_runtime_vars(v, variables) for k, v in value.items()}
if isinstance(value, list):
return [_resolve_runtime_vars(item, variables) for item in value]
return value
# =========================================================================
# Handlers pour les actions exécutées côté serveur (extract_text, t2a_decision)
# =========================================================================
def _handle_extract_text_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
session_id: str,
last_heartbeat: Dict[str, Dict[str, Any]],
) -> bool:
"""Traite une action extract_text côté serveur. Stocke le texte OCRisé dans
replay_state["variables"][output_var]. Retourne True si succès.
Robuste aux échecs : si pas de heartbeat ou OCR raté, stocke "" et retourne
False (le pipeline continue, pas de blocage).
"""
params = action.get("parameters") or {}
output_var = (params.get("output_var") or "extracted_text").strip()
paragraph = bool(params.get("paragraph", True))
heartbeat = last_heartbeat.get(session_id) or {}
path = heartbeat.get("path")
text = ""
if path:
try:
from core.llm import extract_text_from_image
text = extract_text_from_image(path, paragraph=paragraph)
except Exception as e:
logger.warning("extract_text OCR échoué (%s) — variable '%s' = ''", e, output_var)
else:
logger.warning(
"extract_text : pas de heartbeat pour session %s — variable '%s' = ''",
session_id, output_var,
)
replay_state.setdefault("variables", {})[output_var] = text
logger.info(
"extract_text → variable '%s' (%d chars) replay %s",
output_var, len(text), replay_state.get("replay_id", "?"),
)
return bool(text)
def _handle_t2a_decision_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
) -> bool:
"""Traite une action t2a_decision côté serveur. Stocke le résultat JSON
dans replay_state["variables"][output_var]. Retourne True si succès.
Le DPI à analyser vient de action.parameters.input_template (déjà résolu
par _resolve_runtime_vars donc les {{var}} sont remplis).
"""
params = action.get("parameters") or {}
output_var = (params.get("output_var") or "t2a_result").strip()
dpi_text = (params.get("input_template") or params.get("dpi") or "").strip()
model = params.get("model") or None # None → DEFAULT_MODEL
if not dpi_text:
logger.warning(
"t2a_decision : input vide — variable '%s' = {decision: 'INDETERMINE'}", output_var,
)
replay_state.setdefault("variables", {})[output_var] = {
"decision": "INDETERMINE",
"justification": "DPI vide ou non extrait",
"confiance": "faible",
"_error": "empty_input",
}
return False
try:
from core.llm import analyze_dpi, DEFAULT_MODEL
result = analyze_dpi(dpi_text, model=model or DEFAULT_MODEL)
except Exception as e:
logger.warning("t2a_decision : analyze_dpi exception %s", e)
result = {
"decision": "INDETERMINE",
"justification": f"Erreur analyse : {e}",
"confiance": "faible",
"_error": str(e),
}
replay_state.setdefault("variables", {})[output_var] = result
decision = result.get("decision", "?")
elapsed = result.get("_elapsed_s", "?")
logger.info(
"t2a_decision → variable '%s' decision=%s (%ss) replay %s",
output_var, decision, elapsed, replay_state.get("replay_id", "?"),
)
return "_error" not in result
def _expand_compound_steps(
steps: List[Dict[str, Any]], base: Dict[str, Any], params: Dict[str, Any]
) -> List[Dict[str, Any]]:
@@ -1208,6 +1377,18 @@ def _create_replay_state(
# Champs pour pause supervisée (target_not_found)
"failed_action": None, # Contexte de l'action en echec (quand paused_need_help)
"pause_message": None, # Message a afficher a l'utilisateur
# Variables d'exécution produites en cours de workflow (extract_text,
# t2a_decision, etc.). Résolues via templating {{var}} ou {{var.field}}
# dans les paramètres des actions suivantes.
"variables": {},
# QW2 — Anneaux d'historique pour LoopDetector (5 derniers max)
"_screenshot_history": [], # images PIL des N derniers heartbeats (LoopDetector embed à chaque tick)
"_action_history": [], # N dernières actions exécutées (signature)
# QW4 — Safety checks (hybride déclaratif + LLM contextuel) et audit acquittements
"safety_checks": [], # liste produite par SafetyChecksProvider
"checks_acknowledged": [], # ids acquittés via /replay/resume (audit trail)
"pause_reason": "", # "loop_detected" | "" pour V1
"pause_payload": None, # payload complet pour debug/audit
}

View File

@@ -2193,22 +2193,33 @@ def _validate_resolution_quality(
dx = abs(resolved_x - fallback_x_pct)
dy = abs(resolved_y - fallback_y_pct)
if dx > _RESOLUTION_MAX_DRIFT or dy > _RESOLUTION_MAX_DRIFT:
# Exception : si le template matching trouve l'image avec une
# similarité quasi parfaite, on fait confiance à la position
# visuelle peu importe le drift. Une image retrouvée à >= 0.95
# de score est SUR l'écran à l'endroit indiqué — le drift par
# rapport à l'enregistrement ne reflète qu'un changement de
# layout (scroll, redimensionnement, F11, devtools), pas une
# erreur de résolution.
_HIGH_CONFIDENCE = 0.95
if score >= _HIGH_CONFIDENCE and method.startswith("template_matching"):
logger.info(
"[REPLAY] Drift (%.3f, %.3f) > %.2f IGNORÉ : score=%.3f >= %.2f "
"sur %s — résultat visuel fiable, on l'utilise",
dx, dy, _RESOLUTION_MAX_DRIFT, score, _HIGH_CONFIDENCE, method,
)
return result
logger.warning(
"[REPLAY] Resolution REJETÉE (drift trop grand) : "
"method=%s resolved=(%.3f, %.3f) expected=(%.3f, %.3f) "
"drift=(%.3f, %.3f) max=%.2f",
method, resolved_x, resolved_y,
fallback_x_pct, fallback_y_pct,
dx, dy, _RESOLUTION_MAX_DRIFT,
"[REPLAY] Drift trop grand (%.3f, %.3f) > %.2f — fallback coords enregistrées (%.3f, %.3f)",
dx, dy, _RESOLUTION_MAX_DRIFT, fallback_x_pct, fallback_y_pct,
)
# Fallback : coordonnées enregistrées lors de la capture (écran identique = safe)
return {
"resolved": False,
"method": f"rejected_drift_{method}",
"reason": f"drift_dx{dx:.3f}_dy{dy:.3f}_max{_RESOLUTION_MAX_DRIFT:.2f}",
"resolved": True,
"method": "fallback_recorded_coords",
"reason": f"drift_dx{dx:.3f}_dy{dy:.3f}_using_recorded",
"original_method": method,
"original_score": score,
"drift_dx": round(dx, 3),
"drift_dy": round(dy, 3),
"x_pct": fallback_x_pct,
"y_pct": fallback_y_pct,
}

View File

@@ -0,0 +1,195 @@
# agent_v0/server_v1/safety_checks_provider.py
"""SafetyChecksProvider — checks hybrides déclaratifs + LLM contextuels (QW4).
Pour une action pause_for_human :
- les checks déclaratifs (workflow) sont toujours inclus
- si safety_level == "medical_critical" et RPA_SAFETY_CHECKS_LLM_ENABLED=1,
un appel LLM (medgemma:4b par défaut) ajoute jusqu'à N checks contextuels
Tout échec côté LLM (timeout, exception, parse) → additional_checks=[] :
le replay continue avec uniquement les déclaratifs (fallback safe).
"""
import base64
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class PausePayload:
checks: List[Dict[str, Any]] = field(default_factory=list)
pause_reason: str = ""
message: str = ""
def _env(name: str, default: str) -> str:
return os.environ.get(name, default).strip()
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_bool_enabled(name: str) -> bool:
val = os.environ.get(name, "1").strip().lower()
return val not in ("0", "false", "no", "off", "")
def build_pause_payload(
action: Dict[str, Any],
replay_state: Dict[str, Any],
last_screenshot: Optional[str],
) -> PausePayload:
"""Construit le payload de pause enrichi pour une action pause_for_human."""
params = action.get("parameters") or {}
message = params.get("message", "Validation requise")
safety_level = params.get("safety_level")
declarative = params.get("safety_checks") or []
# Normalisation des checks déclaratifs
checks: List[Dict[str, Any]] = []
for d in declarative:
checks.append({
"id": d.get("id") or f"decl_{uuid.uuid4().hex[:6]}",
"label": d.get("label", "Validation"),
"required": bool(d.get("required", True)),
"source": "declarative",
"evidence": None,
})
# Ajout LLM contextual si applicable
if safety_level == "medical_critical" and _env_bool_enabled("RPA_SAFETY_CHECKS_LLM_ENABLED"):
try:
additional = _call_llm_for_contextual_checks(
action=action,
replay_state=replay_state,
last_screenshot=last_screenshot,
existing_labels=[c["label"] for c in checks],
)
except Exception as e:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=exception detail=%s", e)
additional = []
for a in additional:
checks.append({
"id": f"llm_{uuid.uuid4().hex[:6]}",
"label": a.get("label", ""),
"required": False, # checks LLM = informationnels, pas obligatoires V1
"source": "llm_contextual",
"evidence": a.get("evidence", ""),
})
return PausePayload(
checks=checks,
pause_reason="",
message=message,
)
def _call_llm_for_contextual_checks(
action: Dict[str, Any],
replay_state: Dict[str, Any],
last_screenshot: Optional[str],
existing_labels: List[str],
) -> List[Dict[str, str]]:
"""Appelle Ollama en mode JSON strict pour générer 0-N checks contextuels.
Returns:
List[{label, evidence}] (max RPA_SAFETY_CHECKS_LLM_MAX_CHECKS).
[] sur tout échec (timeout, JSON invalide, exception).
"""
import requests
# Défaut gemma4:latest : meilleur compromis détection/latence sur bench
# 2026-05-06 (cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md). medgemma:4b
# retournait systématiquement [] (refus de signaler).
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "gemma4:latest")
# Timeout 7s : warm avg gemma4 = 2.9s + marge 4s. Cold start ~10s couvert
# si le modèle reste résident (OLLAMA_KEEP_ALIVE=24h recommandé prod).
timeout_s = _env_int("RPA_SAFETY_CHECKS_LLM_TIMEOUT_S", 7)
max_checks = _env_int("RPA_SAFETY_CHECKS_LLM_MAX_CHECKS", 3)
ollama_url = _env("OLLAMA_URL", "http://localhost:11434")
params = action.get("parameters") or {}
workflow_message = params.get("message", "")
existing = ", ".join(existing_labels) if existing_labels else "aucun"
prompt = f"""Tu es Léa, assistante médicale supervisée.
Avant de continuer le workflow, tu dois lister 0 à {max_checks} vérifications supplémentaires
que l'humain doit acquitter, en regardant l'écran actuel.
Contexte workflow : {workflow_message}
Checks déjà demandés : {existing}
NE répète PAS un check déjà demandé.
Si rien d'inhabituel à signaler, retourne {{"additional_checks": []}}.
Réponds UNIQUEMENT en JSON :
{{
"additional_checks": [
{{"label": "string court", "evidence": "ce que tu as vu d'inhabituel"}}
]
}}
"""
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.1, "num_predict": 200},
}
if last_screenshot and os.path.isfile(last_screenshot):
try:
with open(last_screenshot, "rb") as f:
payload["images"] = [base64.b64encode(f.read()).decode("ascii")]
except Exception as e:
logger.debug("safety_checks: lecture screenshot échouée (%s) — appel sans image", e)
try:
response = requests.post(
f"{ollama_url}/api/generate",
json=payload,
timeout=timeout_s,
)
if response.status_code != 200:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=http_status detail=%s", response.status_code)
return []
text = response.json().get("response", "").strip()
except requests.Timeout:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=timeout detail=%ss", timeout_s)
return []
except Exception as e:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=network detail=%s", e)
return []
# format=json garantit normalement du JSON valide
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=json_decode detail=%s", e)
return []
additional = parsed.get("additional_checks") or []
if not isinstance(additional, list):
return []
# Filtre + tronc
valid = []
for item in additional[:max_checks]:
if isinstance(item, dict) and item.get("label"):
valid.append({
"label": str(item["label"])[:200],
"evidence": str(item.get("evidence", ""))[:300],
})
return valid

View File

@@ -1791,6 +1791,10 @@ class StreamProcessor:
# Workflows construits (pour le matching)
self._workflows: Dict[str, Any] = {}
# Shadow learning : dernier pattern UI détecté par session
# Stocke {session_id: {"pattern": str, "ocr_text": str, "screen_state": obj, "shot_id": str}}
self._pending_ui_patterns: Dict[str, Dict[str, Any]] = {}
# Charger les workflows existants depuis le disque
self._load_persisted_workflows()
@@ -1975,6 +1979,9 @@ class StreamProcessor:
- key_combo/key_press avec uniquement des modificateurs seuls (ctrl, alt, shift, etc.)
- key_combo/key_press avec liste de touches vide
- text_input avec texte vide
Shadow learning : quand un clic suit un pattern UI détecté,
on apprend l'association dialogue→bouton.
"""
if _is_parasitic_event(event_data):
logger.debug(
@@ -1982,9 +1989,119 @@ class StreamProcessor:
f"type={event_data.get('type')}, data={event_data.get('keys', event_data.get('text', ''))}"
)
return {"status": "event_filtered", "session_id": session_id, "reason": "parasitic"}
# Shadow learning : si un pattern UI est en attente et qu'on reçoit un clic
if event_data.get("type") == "mouse_click":
self._try_shadow_learn(session_id, event_data)
self.session_manager.add_event(session_id, event_data)
return {"status": "event_recorded", "session_id": session_id}
def _try_shadow_learn(self, session_id: str, click_event: Dict[str, Any]):
"""Tente d'apprendre un pattern UI depuis un clic observé en Shadow.
Quand un screenshot contenait un pattern UI détecté (dialogue) et que
l'utilisateur clique ensuite, on extrait le texte OCR au point de clic
pour apprendre l'association : "quand je vois ce texte → cliquer sur ce bouton".
"""
with self._data_lock:
pending = self._pending_ui_patterns.pop(session_id, None)
if not pending:
return
screen_state = pending.get("screen_state")
if screen_state is None:
return
# Extraire la position du clic (pixels absolus)
pos = click_event.get("pos", [])
if not pos or len(pos) != 2:
return
click_x, click_y = pos[0], pos[1]
# Trouver le texte OCR le plus proche du point de clic
# via les ui_elements du ScreenState (ils ont bbox + label)
clicked_label = self._find_label_at_position(screen_state, click_x, click_y)
if not clicked_label:
return
# Extraire le trigger principal du texte OCR du dialogue
ocr_text = pending.get("ocr_text", "")
# Utiliser un extrait court comme trigger (max 80 chars, premier segment pertinent)
trigger_text = ocr_text[:80].strip().lower()
if not trigger_text:
return
logger.info(
f"Shadow learning: pattern '{pending['pattern_name']}' "
f"→ utilisateur a cliqué '{clicked_label}' | trigger='{trigger_text[:40]}...'"
)
# Sauvegarder le pattern appris
try:
from core.knowledge.ui_patterns import UIPatternLibrary
lib = UIPatternLibrary()
lib.save_learned_pattern({
"category": "dialog",
"triggers": [trigger_text],
"action": "click",
"target": clicked_label,
"os": "windows",
"confidence": 0.8,
})
except Exception as e:
logger.warning(f"Shadow learning: échec sauvegarde pattern: {e}")
@staticmethod
def _find_label_at_position(screen_state, click_x: int, click_y: int) -> Optional[str]:
"""Trouve le label de l'élément UI le plus proche du point de clic.
Parcourt les ui_elements du ScreenState et retourne le label de
l'élément dont la bbox contient le point, ou le plus proche si aucun
ne contient exactement le point.
"""
ui_elements = getattr(screen_state, "ui_elements", [])
if not ui_elements:
return None
best_label = None
best_dist = float("inf")
for elem in ui_elements:
bbox = getattr(elem, "bbox", None)
label = getattr(elem, "label", "")
if not bbox or not label:
continue
# BBox = (x, y, width, height) — extraire les coordonnées
try:
bx, by = bbox.x, bbox.y
bw, bh = bbox.width, bbox.height
except AttributeError:
# Fallback si bbox est une liste/tuple
if hasattr(bbox, '__len__') and len(bbox) >= 4:
bx, by, bw, bh = bbox[0], bbox[1], bbox[2], bbox[3]
else:
continue
# Vérifier si le clic est dans la bbox
if bx <= click_x <= bx + bw and by <= click_y <= by + bh:
return label.strip()
# Sinon calculer la distance au centre
cx = bx + bw / 2
cy = by + bh / 2
dist = ((click_x - cx) ** 2 + (click_y - cy) ** 2) ** 0.5
if dist < best_dist:
best_dist = dist
best_label = label.strip()
# Ne retourner le plus proche que s'il est raisonnablement proche (< 100px)
if best_label and best_dist < 100:
return best_label
return None
# =========================================================================
# Screenshots
# =========================================================================
@@ -2042,6 +2159,37 @@ class StreamProcessor:
self._screen_states[session_id] = []
self._screen_states[session_id].append(screen_state)
# Enrichir avec les patterns UI connus
try:
from core.knowledge.ui_patterns import UIPatternLibrary
detected_text = getattr(screen_state.perception, "detected_text", [])
if detected_text:
ocr_text = " ".join(str(t) for t in detected_text) if isinstance(detected_text, list) else str(detected_text)
lib = UIPatternLibrary()
pattern = lib.find_pattern(ocr_text)
if pattern:
result["ui_pattern"] = pattern["pattern"]
result["ui_pattern_action"] = pattern["action"]
result["ui_pattern_target"] = pattern["target"]
logger.info(f"Pattern UI détecté: {pattern['pattern']}{pattern['target']}")
# Shadow learning : mémoriser le pattern en attente du clic utilisateur
with self._data_lock:
self._pending_ui_patterns[session_id] = {
"pattern_name": pattern["pattern"],
"ocr_text": ocr_text,
"screen_state": screen_state,
"shot_id": shot_id,
}
else:
# Pas de pattern connu → effacer le pending (l'écran a changé)
with self._data_lock:
self._pending_ui_patterns.pop(session_id, None)
except ImportError:
pass
except Exception as e:
logger.debug(f"Pattern check: {e}")
logger.info(
f"Screenshot analysé: {shot_id} | "
f"{result['ui_elements_count']} UI elements, "

View File

@@ -0,0 +1,643 @@
"""
Bridge entre les workflows Lea (core) et PM4Py pour le process mining.
Genere des diagrammes BPMN et KPIs depuis les traces Shadow.
Usage:
from core.analytics.process_mining_bridge import (
sessions_to_event_log,
workflow_to_event_log,
discover_bpmn,
compute_kpis,
)
# Depuis des sessions JSONL brutes
df = sessions_to_event_log(sessions_data)
result = discover_bpmn(df, output_dir="data/analytics/bpmn")
kpis = compute_kpis(df)
# Depuis un workflow core (dict JSON)
df = workflow_to_event_log(workflow_dict)
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
logger = logging.getLogger(__name__)
# ---- Import conditionnel PM4Py -----------------------------------------
try:
import pm4py
PM4PY_AVAILABLE = True
except ImportError:
PM4PY_AVAILABLE = False
logger.warning("pm4py non installe -- le process mining est desactive")
def _sanitize_label(label: str) -> str:
"""
Supprime les caracteres de controle (0x00-0x1F sauf tab/newline)
qui sont invalides en XML et font planter PM4Py.
"""
return "".join(
c if c in ("\t", "\n", "\r") or ord(c) >= 0x20 else f"<0x{ord(c):02x}>"
for c in label
)
# ---- Types d'evenements a ignorer (bruit) --------------------------------
_NOISE_EVENT_TYPES = frozenset({
"heartbeat",
"action_result",
"screenshot",
})
# Types d'evenements significatifs pour le process mining
_RELEVANT_EVENT_TYPES = frozenset({
"mouse_click",
"text_input",
"key_press",
"key_combo",
"window_focus_change",
})
# ===========================================================================
# Conversion sessions JSONL -> event log PM4Py
# ===========================================================================
def _build_activity_label(event: dict) -> Optional[str]:
"""
Construit un label d'activite lisible depuis un event JSONL brut.
Regles :
- mouse_click -> "Clic - <app_name> (<window_title tronque>)"
- text_input -> "Saisie '<text>' - <app_name>"
- key_press -> "Touche <key> - <app_name>"
- key_combo -> "Raccourci <keys> - <app_name>"
- window_focus_change -> "Fenetre <to.title> (<to.app_name>)"
Tous les labels sont sanitises pour supprimer les caracteres de controle
(ex: \\x13 pour Ctrl+S) qui sont invalides en XML/BPMN.
"""
evt = event.get("event", event)
etype = evt.get("type", "")
if etype in _NOISE_EVENT_TYPES:
return None
# Extraction fenetre
window = evt.get("window", {})
app_name = window.get("app_name", "inconnu")
win_title = window.get("title", "")
# Tronquer le titre a 40 caracteres
short_title = (win_title[:40] + "...") if len(win_title) > 40 else win_title
label: Optional[str] = None
if etype == "mouse_click":
label = f"Clic - {app_name} ({short_title})"
elif etype == "text_input":
text = evt.get("text", "")
# Tronquer le texte a 20 caracteres pour rester lisible
short_text = (text[:20] + "...") if len(text) > 20 else text
label = f"Saisie '{short_text}' - {app_name}"
elif etype == "key_press":
key = evt.get("key", "?")
label = f"Touche {key} - {app_name}"
elif etype == "key_combo":
keys = evt.get("keys", [])
combo = "+".join(str(k) for k in keys)
label = f"Raccourci {combo} - {app_name}"
elif etype == "window_focus_change":
to_info = evt.get("to", {})
if not to_info:
return None
to_title = to_info.get("title", "?")
to_app = to_info.get("app_name", "?")
label = f"Fenetre {to_title} ({to_app})"
else:
# Types non reconnus : label generique
label = f"{etype} - {app_name}"
return _sanitize_label(label) if label else None
def _extract_timestamp(event: dict) -> Optional[float]:
"""Extrait le timestamp unix depuis un event JSONL."""
# Le timestamp peut etre au niveau racine ou dans event.timestamp
evt = event.get("event", event)
ts = evt.get("timestamp") or event.get("timestamp")
if ts is not None:
return float(ts)
# Fallback sur le champ 't' (format simplifie)
t = evt.get("t") or event.get("t")
if t is not None:
return float(t)
return None
def sessions_to_event_log(
sessions_data: List[dict],
deduplicate_windows: bool = True,
) -> pd.DataFrame:
"""
Convertit des traces de sessions brutes (events JSONL) en event log PM4Py.
Chaque event pertinent devient une ligne :
- case:concept:name = session_id
- concept:name = label d'activite (ex: "Clic - Notepad.exe (Bloc-notes)")
- time:timestamp = timestamp UTC
Args:
sessions_data: liste de dicts, chaque dict est une ligne JSONL parsee.
deduplicate_windows: si True, supprime les window_focus_change
consecutifs vers la meme fenetre (bruit typique de Windows).
Returns:
DataFrame pret pour PM4Py.
"""
rows: List[Dict[str, Any]] = []
# Regrouper par session_id pour le deduplication
sessions: Dict[str, List[dict]] = {}
for event in sessions_data:
sid = event.get("session_id", "unknown")
sessions.setdefault(sid, []).append(event)
for sid, events in sessions.items():
# Trier par timestamp
events.sort(key=lambda e: _extract_timestamp(e) or 0.0)
last_window_label: Optional[str] = None
for event in events:
label = _build_activity_label(event)
if label is None:
continue
ts = _extract_timestamp(event)
if ts is None:
continue
# Deduplication des changements de fenetre consecutifs
evt = event.get("event", event)
if deduplicate_windows and evt.get("type") == "window_focus_change":
if label == last_window_label:
continue
last_window_label = label
else:
last_window_label = None
rows.append({
"case:concept:name": sid,
"concept:name": label,
"time:timestamp": pd.Timestamp(
datetime.fromtimestamp(ts, tz=timezone.utc)
),
"event_type": evt.get("type", ""),
"app_name": evt.get("window", {}).get("app_name", ""),
})
if not rows:
logger.warning("Aucun evenement pertinent trouve dans les sessions")
return pd.DataFrame(columns=[
"case:concept:name",
"concept:name",
"time:timestamp",
"event_type",
"app_name",
])
df = pd.DataFrame(rows)
df = df.sort_values(["case:concept:name", "time:timestamp"]).reset_index(drop=True)
logger.info(
"Event log cree : %d evenements, %d sessions, %d activites distinctes",
len(df),
df["case:concept:name"].nunique(),
df["concept:name"].nunique(),
)
return df
# ===========================================================================
# Conversion workflow core (dict JSON) -> event log PM4Py
# ===========================================================================
def workflow_to_event_log(workflow_dict: dict) -> pd.DataFrame:
"""
Convertit un workflow core (dict JSON) en DataFrame PM4Py.
Utilise les nodes et edges pour reconstituer une trace.
Chaque chemin du entry_node vers un end_node = un case.
Mapping :
- case:concept:name = workflow_id + suffixe de chemin
- concept:name = node.name
- time:timestamp = deduced from edge stats ou created_at
"""
wf_id = workflow_dict.get("workflow_id", "wf_unknown")
nodes = {n["node_id"]: n for n in workflow_dict.get("nodes", [])}
edges = workflow_dict.get("edges", [])
entry_nodes = workflow_dict.get("entry_nodes", [])
created_at = workflow_dict.get("created_at", datetime.now(timezone.utc).isoformat())
if not nodes or not edges:
logger.warning("Workflow vide ou sans edges : %s", wf_id)
return pd.DataFrame(columns=[
"case:concept:name",
"concept:name",
"time:timestamp",
])
# Construire un graphe d'adjacence
adjacency: Dict[str, List[dict]] = {}
for edge in edges:
from_node = edge.get("from_node") or edge.get("source_node", "")
adjacency.setdefault(from_node, []).append(edge)
# Parcours DFS pour trouver les chemins (limites a eviter l'explosion)
MAX_PATHS = 100
paths: List[List[str]] = []
def _dfs(current: str, path: List[str], visited: set) -> None:
if len(paths) >= MAX_PATHS:
return
if current in visited:
# Boucle detectee, sauvegarder le chemin tel quel
paths.append(path[:])
return
visited.add(current)
path.append(current)
outgoing = adjacency.get(current, [])
if not outgoing:
# End node
paths.append(path[:])
else:
for edge in outgoing:
to_node = edge.get("to_node") or edge.get("target_node", "")
if to_node:
_dfs(to_node, path, visited)
path.pop()
visited.discard(current)
for entry in entry_nodes:
if entry in nodes:
_dfs(entry, [], set())
# Si pas d'entry nodes, essayer tous les nodes sans edges entrants
if not paths:
target_nodes = set()
for edge in edges:
to_node = edge.get("to_node") or edge.get("target_node", "")
target_nodes.add(to_node)
root_nodes = [nid for nid in nodes if nid not in target_nodes]
for root in root_nodes[:3]:
_dfs(root, [], set())
# Construire le DataFrame
rows: List[Dict[str, Any]] = []
try:
base_time = pd.Timestamp(datetime.fromisoformat(created_at))
except (ValueError, TypeError):
base_time = pd.Timestamp(datetime.now(timezone.utc))
for i, path in enumerate(paths):
case_id = f"{wf_id}_path_{i}"
for step_idx, node_id in enumerate(path):
node = nodes.get(node_id, {})
rows.append({
"case:concept:name": case_id,
"concept:name": node.get("name", node_id),
"time:timestamp": base_time + pd.Timedelta(seconds=step_idx),
})
df = pd.DataFrame(rows)
if not df.empty:
df = df.sort_values(["case:concept:name", "time:timestamp"]).reset_index(drop=True)
logger.info(
"Event log depuis workflow : %d evenements, %d chemins",
len(df), len(paths),
)
return df
# ===========================================================================
# Decouverte BPMN
# ===========================================================================
def discover_bpmn(
event_log_df: pd.DataFrame,
output_dir: str = "data/analytics/bpmn",
name: str = "process",
) -> dict:
"""
Decouvre un modele BPMN depuis un event log via Inductive Miner.
Args:
event_log_df: DataFrame au format PM4Py.
output_dir: repertoire de sortie pour les fichiers generes.
name: prefixe pour les noms de fichiers.
Returns:
{
'bpmn_xml_path': str,
'bpmn_image_path': str,
'petri_net_image_path': str,
'dfg_image_path': str,
'stats': {
'activities': int,
'variants': int,
'cases': int,
}
}
"""
if not PM4PY_AVAILABLE:
raise ImportError("pm4py n'est pas installe. Installez-le : pip install pm4py")
if event_log_df.empty:
raise ValueError("Event log vide, impossible de decouvrir un BPMN")
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Decouverte BPMN par Inductive Miner
bpmn_model = pm4py.discover_bpmn_inductive(event_log_df)
# Export BPMN XML
bpmn_xml_path = str(out / f"{name}.bpmn")
try:
pm4py.write_bpmn(bpmn_model, bpmn_xml_path)
except Exception as e:
# PM4Py layout peut echouer avec des labels contenant des caracteres
# speciaux (accents, guillemets, etc.). Fallback : export via l'exporter
# interne sans layout.
logger.warning("Layout BPMN echoue (%s), export sans layout", e)
from pm4py.objects.bpmn.exporter import exporter as bpmn_exporter
bpmn_exporter.apply(bpmn_model, bpmn_xml_path)
logger.info("BPMN XML exporte : %s", bpmn_xml_path)
# Export image BPMN (PNG) — grande taille pour lisibilité
bpmn_image_path = str(out / f"{name}_bpmn.png")
try:
from pm4py.visualization.bpmn import visualizer as bpmn_vis
gviz = bpmn_vis.apply(bpmn_model, parameters={
"rankdir": "TB",
"font_size": "12",
})
gviz.graph_attr["dpi"] = "150"
gviz.graph_attr["size"] = "40,20!"
gviz.graph_attr["rankdir"] = "TB"
gviz.render(filename=bpmn_image_path.replace(".png", ""), format="png", cleanup=True)
logger.info("BPMN PNG exporte : %s", bpmn_image_path)
except Exception as e:
logger.warning("BPMN image fallback : %s", e)
try:
pm4py.save_vis_bpmn(bpmn_model, bpmn_image_path)
except Exception:
bpmn_image_path = None
# DFG (Directly-Follows Graph) — grande taille
dfg_image_path = str(out / f"{name}_dfg.png")
try:
from pm4py.visualization.dfg import visualizer as dfg_vis
dfg, sa, ea = pm4py.discover_dfg(event_log_df)
gviz = dfg_vis.apply(dfg, activities_count=sa, parameters={
"start_activities": sa,
"end_activities": ea,
"rankdir": "TB",
"font_size": "11",
})
gviz.graph_attr["dpi"] = "150"
gviz.graph_attr["size"] = "40,20!"
gviz.graph_attr["rankdir"] = "TB"
gviz.render(filename=dfg_image_path.replace(".png", ""), format="png", cleanup=True)
logger.info("DFG PNG exporte : %s", dfg_image_path)
except Exception as e:
logger.warning("DFG image fallback : %s", e)
try:
pm4py.save_vis_dfg(*pm4py.discover_dfg(event_log_df), file_path=dfg_image_path)
except Exception:
dfg_image_path = None
# Petri net via Inductive Miner (pour visualisation alternative)
petri_image_path = str(out / f"{name}_petri.png")
try:
net, im, fm = pm4py.discover_petri_net_inductive(event_log_df)
pm4py.save_vis_petri_net(net, im, fm, file_path=petri_image_path)
logger.info("Petri net PNG exporte : %s", petri_image_path)
except Exception as e:
logger.warning("Impossible de generer le Petri net : %s", e)
petri_image_path = None
# Stats de base
variants = pm4py.get_variants(event_log_df)
n_cases = event_log_df["case:concept:name"].nunique()
n_activities = event_log_df["concept:name"].nunique()
result = {
"bpmn_xml_path": bpmn_xml_path,
"bpmn_image_path": bpmn_image_path,
"petri_net_image_path": petri_image_path,
"dfg_image_path": dfg_image_path,
"stats": {
"activities": n_activities,
"variants": len(variants),
"cases": n_cases,
},
}
logger.info("Decouverte BPMN terminee : %s", result["stats"])
return result
# ===========================================================================
# KPIs de process mining
# ===========================================================================
def compute_kpis(event_log_df: pd.DataFrame) -> dict:
"""
Calcule les KPIs de process mining.
Returns:
{
'total_cases': int,
'total_events': int,
'unique_activities': int,
'variants_count': int,
'variants_top5': list,
'avg_case_duration_seconds': float,
'median_case_duration_seconds': float,
'avg_events_per_case': float,
'activity_stats': {
'<activity_name>': {
'count': int,
'avg_duration_seconds': float,
'min_duration_seconds': float,
'max_duration_seconds': float,
}
},
'bottlenecks': [...], # top 3 activites les plus lentes
'app_distribution': { '<app_name>': int },
}
"""
if event_log_df.empty:
return {
"total_cases": 0,
"total_events": 0,
"unique_activities": 0,
"variants_count": 0,
"variants_top5": [],
"avg_case_duration_seconds": 0.0,
"median_case_duration_seconds": 0.0,
"avg_events_per_case": 0.0,
"activity_stats": {},
"bottlenecks": [],
"app_distribution": {},
}
df = event_log_df.copy()
# ---- Metriques globales ----
total_cases = df["case:concept:name"].nunique()
total_events = len(df)
unique_activities = df["concept:name"].nunique()
# ---- Variantes (PM4Py) ----
if PM4PY_AVAILABLE:
variants = pm4py.get_variants(df)
variants_count = len(variants)
# Top 5 variantes par frequence
sorted_variants = sorted(variants.items(), key=lambda x: x[1], reverse=True)
variants_top5 = [
{"variant": " -> ".join(v), "count": c}
for v, c in sorted_variants[:5]
]
else:
variants_count = 0
variants_top5 = []
# ---- Duree par case ----
case_durations: List[float] = []
for _case_id, group in df.groupby("case:concept:name"):
ts = group["time:timestamp"]
if len(ts) >= 2:
duration = (ts.max() - ts.min()).total_seconds()
case_durations.append(duration)
avg_case_dur = float(pd.Series(case_durations).mean()) if case_durations else 0.0
median_case_dur = float(pd.Series(case_durations).median()) if case_durations else 0.0
avg_events_per_case = total_events / total_cases if total_cases > 0 else 0.0
# ---- Stats par activite ----
activity_stats: Dict[str, Dict[str, Any]] = {}
# Calculer la duree entre chaque evenement et le suivant dans le meme case
df_sorted = df.sort_values(["case:concept:name", "time:timestamp"])
df_sorted["next_timestamp"] = df_sorted.groupby("case:concept:name")[
"time:timestamp"
].shift(-1)
df_sorted["duration_to_next"] = (
df_sorted["next_timestamp"] - df_sorted["time:timestamp"]
).dt.total_seconds()
for activity, grp in df_sorted.groupby("concept:name"):
durations = grp["duration_to_next"].dropna()
# Filtrer les durees aberrantes (> 5 min = probablement une pause)
durations = durations[durations <= 300]
stats: Dict[str, Any] = {
"count": len(grp),
"avg_duration_seconds": round(float(durations.mean()), 2) if len(durations) > 0 else 0.0,
"min_duration_seconds": round(float(durations.min()), 2) if len(durations) > 0 else 0.0,
"max_duration_seconds": round(float(durations.max()), 2) if len(durations) > 0 else 0.0,
}
activity_stats[activity] = stats
# ---- Goulots d'etranglement (top 3 activites les plus lentes) ----
bottlenecks = sorted(
[
{"activity": act, "avg_duration_seconds": s["avg_duration_seconds"]}
for act, s in activity_stats.items()
if s["avg_duration_seconds"] > 0
],
key=lambda x: x["avg_duration_seconds"],
reverse=True,
)[:3]
# ---- Distribution par application ----
app_distribution: Dict[str, int] = {}
if "app_name" in df.columns:
app_distribution = df["app_name"].value_counts().to_dict()
return {
"total_cases": total_cases,
"total_events": total_events,
"unique_activities": unique_activities,
"variants_count": variants_count,
"variants_top5": variants_top5,
"avg_case_duration_seconds": round(avg_case_dur, 2),
"median_case_duration_seconds": round(median_case_dur, 2),
"avg_events_per_case": round(avg_events_per_case, 1),
"activity_stats": activity_stats,
"bottlenecks": bottlenecks,
"app_distribution": app_distribution,
}
# ===========================================================================
# Helpers : chargement sessions JSONL
# ===========================================================================
def load_jsonl_session(jsonl_path: str) -> List[dict]:
"""
Charge un fichier live_events.jsonl en liste de dicts.
Ignore les lignes vides ou invalides.
"""
events: List[dict] = []
path = Path(jsonl_path)
if not path.exists():
raise FileNotFoundError(f"Fichier JSONL introuvable : {jsonl_path}")
with open(path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except json.JSONDecodeError as e:
logger.warning("Ligne %d invalide dans %s : %s", line_num, jsonl_path, e)
logger.info("Charge %d evenements depuis %s", len(events), jsonl_path)
return events
def load_multiple_sessions(session_dirs: List[str]) -> List[dict]:
"""
Charge plusieurs sessions depuis leurs repertoires.
Cherche un fichier live_events.jsonl dans chaque repertoire.
"""
all_events: List[dict] = []
for session_dir in session_dirs:
jsonl_path = Path(session_dir) / "live_events.jsonl"
if jsonl_path.exists():
all_events.extend(load_jsonl_session(str(jsonl_path)))
else:
logger.warning("Pas de live_events.jsonl dans %s", session_dir)
return all_events

View File

@@ -0,0 +1,60 @@
"""
Détection rapide de changement d'écran via perceptual hash (pHash).
Utilise imagehash pour calculer un hash perceptuel par screenshot.
La distance de Hamming entre deux hashes indique le degré de changement :
- < 5 : même écran (bruit, curseur déplacé)
- 5-15 : changement mineur (scroll, popup, champ rempli)
- > 15 : nouvel écran (nouvelle fenêtre, navigation)
Performance : ~15ms par hash sur CPU pour des screenshots 2560x1600.
"""
from PIL import Image
import imagehash
from typing import Tuple, Optional
from enum import Enum
class ScreenChangeLevel(Enum):
SAME = "same" # distance < 5
MINOR = "minor" # 5 <= distance < 15
MAJOR = "major" # distance >= 15
def compute_phash(image: Image.Image, hash_size: int = 8) -> imagehash.ImageHash:
"""Calcule le pHash d'une image PIL."""
return imagehash.phash(image, hash_size=hash_size)
def compare_screenshots(img1: Image.Image, img2: Image.Image, hash_size: int = 8) -> Tuple[int, ScreenChangeLevel]:
"""
Compare deux screenshots et retourne la distance + le niveau de changement.
Returns:
(distance, level) — distance de Hamming et niveau de changement
"""
h1 = compute_phash(img1, hash_size)
h2 = compute_phash(img2, hash_size)
distance = h1 - h2
if distance < 5:
level = ScreenChangeLevel.SAME
elif distance < 15:
level = ScreenChangeLevel.MINOR
else:
level = ScreenChangeLevel.MAJOR
return distance, level
def compare_hashes(hash1: imagehash.ImageHash, hash2: imagehash.ImageHash) -> Tuple[int, ScreenChangeLevel]:
"""Compare deux hashes pré-calculés."""
distance = hash1 - hash2
if distance < 5:
level = ScreenChangeLevel.SAME
elif distance < 15:
level = ScreenChangeLevel.MINOR
else:
level = ScreenChangeLevel.MAJOR
return distance, level

View File

View File

@@ -0,0 +1,191 @@
"""
Orchestrateur VRAM — gère le chargement/déchargement des modèles selon le mode.
Deux modes :
- SHADOW : streaming server + agent_chat actifs, VLM raisonnement déchargé
- REPLAY : VLM raisonnement (qwen2.5vl:7b) chargé, services non-essentiels stoppés
Bascule automatique ou manuelle selon le contexte.
"""
import logging
import os
import subprocess
import time
from enum import Enum
from typing import Optional
logger = logging.getLogger(__name__)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
REASONING_MODEL = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
MIN_VRAM_FOR_REASONING = 5.0 # Go minimum pour charger le modèle de raisonnement
class VRAMMode(Enum):
SHADOW = "shadow"
REPLAY = "replay"
class VRAMOrchestrator:
"""Gère la VRAM pour éviter les conflits entre modèles."""
def __init__(self):
self._current_mode: Optional[VRAMMode] = None
self._stopped_services: list = []
def get_free_vram_gb(self) -> float:
"""Retourne la VRAM libre en Go."""
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=memory.free", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5
)
return float(result.stdout.strip()) / 1024
except Exception:
return 0.0
def get_used_vram_gb(self) -> float:
"""Retourne la VRAM utilisée en Go."""
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5
)
return float(result.stdout.strip()) / 1024
except Exception:
return 0.0
def switch_to_replay(self) -> bool:
"""Bascule en mode replay : libère la VRAM pour le VLM de raisonnement.
1. Stoppe les services non-essentiels (agent_chat)
2. Redémarre Ollama pour libérer les modèles chargés
3. Précharge le modèle de raisonnement
"""
if self._current_mode == VRAMMode.REPLAY:
logger.info("Déjà en mode REPLAY")
return True
logger.info("Bascule en mode REPLAY...")
# Stopper agent_chat si il tourne
try:
result = subprocess.run(
["pgrep", "-f", "agent_chat"],
capture_output=True, text=True, timeout=5
)
pids = result.stdout.strip().split('\n')
for pid in pids:
if pid.strip():
subprocess.run(["kill", pid.strip()], timeout=5)
self._stopped_services.append(("agent_chat", pid.strip()))
logger.info(f"agent_chat stoppé (PID {pid.strip()})")
except Exception as e:
logger.debug(f"Pas d'agent_chat à stopper: {e}")
# Redémarrer Ollama pour libérer la mémoire
try:
subprocess.run(["sudo", "systemctl", "restart", "ollama"],
timeout=10, check=True)
time.sleep(2)
logger.info("Ollama redémarré")
except Exception as e:
logger.warning(f"Impossible de redémarrer Ollama: {e}")
# Vérifier la VRAM disponible
free = self.get_free_vram_gb()
logger.info(f"VRAM libre: {free:.1f} Go")
if free < MIN_VRAM_FOR_REASONING:
logger.warning(f"VRAM insuffisante ({free:.1f} Go < {MIN_VRAM_FOR_REASONING} Go)")
return False
# Précharger le modèle de raisonnement
try:
import requests
logger.info(f"Préchargement {REASONING_MODEL}...")
resp = requests.post(f"{OLLAMA_URL}/api/generate", json={
"model": REASONING_MODEL,
"prompt": "test",
"stream": False,
"options": {"num_predict": 1}
}, timeout=60)
if resp.status_code == 200:
logger.info(f"{REASONING_MODEL} chargé en VRAM")
free_after = self.get_free_vram_gb()
logger.info(f"VRAM libre après chargement: {free_after:.1f} Go")
except Exception as e:
logger.warning(f"Préchargement échoué: {e}")
self._current_mode = VRAMMode.REPLAY
return True
def switch_to_shadow(self) -> bool:
"""Bascule en mode shadow : relance les services d'observation.
1. Redémarre Ollama (décharge le VLM de raisonnement)
2. Relance les services stoppés
"""
if self._current_mode == VRAMMode.SHADOW:
logger.info("Déjà en mode SHADOW")
return True
logger.info("Bascule en mode SHADOW...")
# Redémarrer Ollama
try:
subprocess.run(["sudo", "systemctl", "restart", "ollama"],
timeout=10, check=True)
time.sleep(2)
except Exception as e:
logger.warning(f"Impossible de redémarrer Ollama: {e}")
# Relancer les services stoppés
for service_name, _pid in self._stopped_services:
try:
if service_name == "agent_chat":
subprocess.Popen(
["python3", "-m", "agent_chat.app"],
cwd="/home/dom/ai/rpa_vision_v3",
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
logger.info(f"{service_name} relancé")
except Exception as e:
logger.warning(f"Impossible de relancer {service_name}: {e}")
self._stopped_services.clear()
self._current_mode = VRAMMode.SHADOW
return True
def ensure_reasoning_ready(self) -> bool:
"""Vérifie que le VLM de raisonnement est prêt. Bascule si nécessaire."""
free = self.get_free_vram_gb()
if free >= MIN_VRAM_FOR_REASONING:
return True
return self.switch_to_replay()
@property
def current_mode(self) -> Optional[str]:
return self._current_mode.value if self._current_mode else None
def status(self) -> dict:
return {
"mode": self.current_mode,
"vram_free_gb": round(self.get_free_vram_gb(), 1),
"vram_used_gb": round(self.get_used_vram_gb(), 1),
"reasoning_model": REASONING_MODEL,
"stopped_services": [s[0] for s in self._stopped_services],
}
# Singleton
_orchestrator: Optional[VRAMOrchestrator] = None
def get_orchestrator() -> VRAMOrchestrator:
global _orchestrator
if _orchestrator is None:
_orchestrator = VRAMOrchestrator()
return _orchestrator

View File

@@ -0,0 +1,260 @@
"""
Mémoire de travail de Léa — contexte cognitif pendant l'exécution.
Donne à Léa la conscience de "où elle en est" :
- Quel objectif elle poursuit
- Quel écran elle voit
- Ce qu'elle vient de faire
- Ce qu'elle doit faire ensuite
- Ce qu'elle a appris en cours de route
Sans ça, chaque étape est indépendante — Léa est amnésique entre
deux actions. Avec ça, elle raisonne en contexte.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class Observation:
"""Ce que Léa observe sur l'écran à un instant donné."""
timestamp: datetime
window_title: str = ""
application: str = ""
ocr_text: str = ""
ui_pattern: Optional[str] = None
screen_description: str = ""
confidence: float = 0.0
@dataclass
class ActionRecord:
"""Une action que Léa a effectuée."""
timestamp: datetime
action_type: str
target: str = ""
result: str = ""
success: bool = True
duration_ms: float = 0
@dataclass
class CognitiveContext:
"""Contexte cognitif complet — la "pensée" de Léa à un instant donné.
C'est le bloc-notes interne qui est réinjecté à chaque décision.
Le VLM reçoit ce contexte pour raisonner en connaissance de cause.
"""
# Objectif global (ce que Léa essaie d'accomplir)
objective: str = ""
# Étape courante dans le plan
current_step: int = 0
total_steps: int = 0
current_step_description: str = ""
# Ce que Léa voit maintenant
current_observation: Optional[Observation] = None
# Historique des N dernières actions (mémoire court terme)
action_history: List[ActionRecord] = field(default_factory=list)
max_history: int = 10
# Ce que Léa a appris pendant cette session
learned_facts: List[str] = field(default_factory=list)
# Plan : les étapes restantes
remaining_steps: List[str] = field(default_factory=list)
# État émotionnel / confiance
confidence: float = 1.0
needs_help: bool = False
help_reason: str = ""
# Timing
session_id: str = ""
machine_id: str = ""
started_at: Optional[datetime] = None
step_started_at: Optional[datetime] = None
step_durations: Dict[str, List[float]] = field(default_factory=dict)
# Ce que Léa devrait voir à l'écran (comparaison attendu vs réel)
expected_screen: str = ""
def record_action(self, action_type: str, target: str = "",
result: str = "", success: bool = True,
duration_ms: float = 0):
"""Enregistre une action dans l'historique."""
self.action_history.append(ActionRecord(
timestamp=datetime.now(),
action_type=action_type,
target=target,
result=result,
success=success,
duration_ms=duration_ms,
))
if len(self.action_history) > self.max_history:
self.action_history = self.action_history[-self.max_history:]
if not success:
self.confidence = max(0, self.confidence - 0.2)
else:
self.confidence = min(1.0, self.confidence + 0.05)
def observe(self, window_title: str = "", application: str = "",
ocr_text: str = "", ui_pattern: Optional[str] = None,
screen_description: str = ""):
"""Met à jour l'observation courante."""
self.current_observation = Observation(
timestamp=datetime.now(),
window_title=window_title,
application=application,
ocr_text=ocr_text,
ui_pattern=ui_pattern,
screen_description=screen_description,
)
def advance_step(self):
"""Passe à l'étape suivante du plan."""
# Enregistrer la durée de l'étape précédente
if self.step_started_at:
duration = (datetime.now() - self.step_started_at).total_seconds()
step_key = self.current_step_description or f"step_{self.current_step}"
self.step_durations.setdefault(step_key, []).append(duration)
self.current_step += 1
self.step_started_at = datetime.now()
if self.remaining_steps:
self.current_step_description = self.remaining_steps.pop(0)
def get_step_timing(self) -> Optional[Dict[str, Any]]:
"""Retourne les infos de timing de l'étape en cours."""
if not self.step_started_at:
return None
elapsed = (datetime.now() - self.step_started_at).total_seconds()
step_key = self.current_step_description or f"step_{self.current_step}"
history = self.step_durations.get(step_key, [])
avg = sum(history) / len(history) if history else None
result = {"elapsed_seconds": elapsed}
if avg:
result["avg_previous"] = avg
result["is_slow"] = elapsed > avg * 2
return result
def set_expected_screen(self, description: str):
"""Définit ce que Léa devrait voir à l'écran pour cette étape."""
self.expected_screen = description
def check_screen_matches_expected(self) -> Optional[bool]:
"""Compare l'observation actuelle avec l'écran attendu."""
if not self.expected_screen or not self.current_observation:
return None
obs_text = (self.current_observation.window_title + " " +
self.current_observation.ocr_text).lower()
expected_words = self.expected_screen.lower().split()
matches = sum(1 for w in expected_words if w in obs_text)
return matches / max(len(expected_words), 1) > 0.3
def learn(self, fact: str):
"""Enregistre un fait appris pendant l'exécution."""
if fact not in self.learned_facts:
self.learned_facts.append(fact)
logger.info(f"Fait appris: {fact}")
def ask_for_help(self, reason: str):
"""Signale que Léa a besoin d'aide."""
self.needs_help = True
self.help_reason = reason
self.confidence = max(0, self.confidence - 0.3)
logger.warning(f"Léa demande de l'aide: {reason}")
def to_prompt_context(self) -> str:
"""Génère le contexte à injecter dans le prompt VLM.
C'est ce texte qui donne au VLM la conscience de la situation.
"""
lines = []
if self.objective:
lines.append(f"OBJECTIF : {self.objective}")
if self.current_step > 0:
lines.append(f"PROGRESSION : étape {self.current_step}/{self.total_steps}")
if self.current_step_description:
lines.append(f"ÉTAPE EN COURS : {self.current_step_description}")
if self.current_observation:
obs = self.current_observation
if obs.window_title:
lines.append(f"FENÊTRE ACTIVE : {obs.window_title}")
if obs.application:
lines.append(f"APPLICATION : {obs.application}")
if obs.ui_pattern:
lines.append(f"DIALOGUE DÉTECTÉ : {obs.ui_pattern}")
if self.action_history:
last_actions = self.action_history[-3:]
lines.append("DERNIÈRES ACTIONS :")
for a in last_actions:
status = "OK" if a.success else "ÉCHEC"
lines.append(f" - {a.action_type} '{a.target}'{status}")
if self.learned_facts:
lines.append("FAITS APPRIS :")
for fact in self.learned_facts[-5:]:
lines.append(f" - {fact}")
if self.remaining_steps:
lines.append("PROCHAINES ÉTAPES :")
for step in self.remaining_steps[:3]:
lines.append(f" - {step}")
timing = self.get_step_timing()
if timing:
lines.append(f"TEMPS ÉTAPE : {timing['elapsed_seconds']:.1f}s")
if timing.get('avg_previous'):
lines.append(f"MOYENNE PRÉCÉDENTE : {timing['avg_previous']:.1f}s")
if timing.get('is_slow'):
lines.append("⚠ ÉTAPE ANORMALEMENT LENTE")
if self.expected_screen:
match = self.check_screen_matches_expected()
if match is False:
lines.append(f"⚠ ÉCRAN INATTENDU (attendu: {self.expected_screen})")
elif match is True:
lines.append(f"ÉCRAN CONFORME : {self.expected_screen}")
lines.append(f"CONFIANCE : {self.confidence:.0%}")
if self.needs_help:
lines.append(f"BESOIN D'AIDE : {self.help_reason}")
return "\n".join(lines)
def to_dict(self) -> Dict[str, Any]:
"""Sérialise le contexte pour le stockage/transport."""
return {
"objective": self.objective,
"current_step": self.current_step,
"total_steps": self.total_steps,
"current_step_description": self.current_step_description,
"confidence": self.confidence,
"needs_help": self.needs_help,
"help_reason": self.help_reason,
"action_count": len(self.action_history),
"learned_facts": self.learned_facts,
"remaining_steps": self.remaining_steps,
"last_observation": {
"window_title": self.current_observation.window_title,
"application": self.current_observation.application,
"ui_pattern": self.current_observation.ui_pattern,
} if self.current_observation else None,
}

View File

@@ -58,9 +58,19 @@ class CLIPEmbedder(EmbedderBase):
"Install it with: pip install open-clip-torch"
)
# Default to CPU to save GPU for vision models (Qwen3-VL, etc.)
if device is None:
device = "cpu"
try:
import torch
if torch.cuda.is_available():
free_vram = torch.cuda.mem_get_info()[0] / 1024**3
if free_vram > 1.5:
device = "cuda"
else:
device = "cpu"
else:
device = "cpu"
except Exception:
device = "cpu"
self.model_name = model_name
self.pretrained = pretrained

View File

@@ -10,6 +10,7 @@ from .error_handler import ErrorHandler, ErrorType, RecoveryStrategy
from .workflow_runner import WorkflowRunner, RunResult, RunStatus, RunnerConfig
from .dag_executor import DAGExecutor, WorkflowStep, StepType, StepStatus, DAGExecutionResult
from .llm_actions import LLMActionHandler
from .observe_reason_act import ORALoop, Observation, Decision, VerificationResult, LoopResult
# Import tardif pour éviter import circulaire avec pipeline
def _get_execution_loop():
@@ -34,5 +35,11 @@ __all__ = [
'StepStatus',
'DAGExecutionResult',
'LLMActionHandler',
# ORA — boucle Observe-Raisonne-Agit avec vérification
'ORALoop',
'Observation',
'Decision',
'VerificationResult',
'LoopResult',
# ExecutionLoop accessible via import direct du module
]

View File

@@ -654,7 +654,8 @@ class ActionExecutor:
if PYAUTOGUI_AVAILABLE:
pyautogui.click(click_x, click_y)
time.sleep(0.2)
pyautogui.write(text, interval=0.05)
from .input_handler import safe_type_text
safe_type_text(text)
else:
logger.info(f" (Simulated click at {click_x:.0f}, {click_y:.0f})")
logger.info(f" (Simulated typing: {text[:50]}...)")

View File

@@ -0,0 +1,755 @@
"""
Module partagé de saisie texte et gestion des dialogues.
Utilisé par les deux executors :
- VWB executor (visual_workflow_builder/backend/api_v3/execute.py)
- Core executor (core/execution/action_executor.py)
Garantit le même comportement AZERTY/VM/Citrix partout.
"""
import logging
import subprocess
import shutil
import time
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
try:
import pyautogui
PYAUTOGUI_AVAILABLE = True
except ImportError:
PYAUTOGUI_AVAILABLE = False
try:
import mss
MSS_AVAILABLE = True
except ImportError:
MSS_AVAILABLE = False
try:
from PIL import Image as PILImage
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
def safe_type_text(text: str):
"""Saisie de texte compatible VM/Citrix et claviers AZERTY/QWERTY.
Priorité :
1. xdotool type avec refresh layout → traverse les VM spice/QEMU
2. Presse-papier (xclip) + Ctrl+V → fallback
3. pyautogui.write() → dernier recours
"""
if not text:
return
# Méthode 1 : xdotool type avec refresh du layout clavier
if shutil.which('xdotool') and shutil.which('setxkbmap'):
try:
subprocess.run(['setxkbmap', 'fr'], timeout=2)
subprocess.run(
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', text],
timeout=max(30, len(text) * 0.05),
check=True
)
logger.debug(f"Saisie via xdotool type ({len(text)} car.)")
return
except Exception as e:
logger.debug(f"xdotool type échoué: {e}")
# Méthode 2 : Presse-papier
xclip = shutil.which('xclip')
if xclip and PYAUTOGUI_AVAILABLE:
try:
p = subprocess.Popen(
['xclip', '-selection', 'clipboard'],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
p.stdin.write(text.encode('utf-8'))
p.stdin.close()
time.sleep(0.2)
pyautogui.hotkey('ctrl', 'v')
time.sleep(0.3)
logger.debug(f"Saisie via presse-papier ({len(text)} car.)")
return
except Exception as e:
logger.debug(f"xclip échoué: {e}")
# Méthode 3 : pyautogui
if PYAUTOGUI_AVAILABLE:
logger.warning("Saisie via pyautogui.write() (AZERTY non garanti)")
pyautogui.write(text, interval=0.02)
else:
logger.warning(f"Aucune méthode de saisie disponible pour: {text[:50]}")
def check_screen_for_patterns() -> Optional[Dict[str, Any]]:
"""Vérifie si l'écran contient un pattern UI connu (dialogue, popup).
Capture l'écran, extrait le texte via OCR, et cherche un pattern
dans la UIPatternLibrary.
Returns:
Dict avec le pattern trouvé, ou None.
"""
try:
from core.knowledge.ui_patterns import UIPatternLibrary
import mss
from PIL import Image
lib = UIPatternLibrary()
with mss.mss() as sct:
monitor = sct.monitors[0]
screenshot = sct.grab(monitor)
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
try:
# Essayer docTR d'abord (peut être importé depuis différents chemins)
try:
from services.ocr_service import ocr_extract_text
except ImportError:
from core.extraction.field_extractor import FieldExtractor
extractor = FieldExtractor()
ocr_extract_text = lambda img: extractor.extract_text_from_image(img)
ocr_text = ocr_extract_text(screen)
except ImportError:
logger.debug("OCR non disponible pour pattern check")
return None
if not ocr_text or len(ocr_text) < 5:
return None
pattern = lib.find_pattern(ocr_text)
if pattern and pattern['category'] in ('dialog', 'popup'):
print(f"🧠 [PatternCheck] Détecté: '{pattern['pattern']}'{pattern['action']} '{pattern['target']}'")
return pattern
return None
except Exception as e:
print(f"⚠️ [PatternCheck] Erreur: {e}")
return None
def handle_detected_pattern(pattern: Dict[str, Any]) -> bool:
"""Gère automatiquement un pattern UI détecté.
Cherche le bouton cible via OCR (position réelle sur l'écran).
100% vision — zéro coordonnée hardcodée.
Returns:
True si le pattern a été géré avec succès.
"""
if not PYAUTOGUI_AVAILABLE:
logger.warning("pyautogui non disponible — impossible de gérer le pattern")
return False
action = pattern.get('action')
target = pattern.get('target', '')
alternatives = pattern.get('alternatives', [])
if action == 'click':
candidates_labels = [target] + alternatives
print(f"🔧 [Réflexe/handle] Recherche bouton parmi: {candidates_labels}")
try:
import mss
import numpy as np
from PIL import Image
with mss.mss() as sct:
monitor = sct.monitors[0]
screenshot = sct.grab(monitor)
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
# EasyOCR (rapide, bonne qualité GUI) avec fallback docTR.
# gpu=True : harmonisé avec dialog_handler.py et title_verifier.py.
# Coût VRAM ~0.5 GB, sous le budget RTX 5070 (cf. deploy/VRAM_BUDGET.md).
words = []
try:
import easyocr
_reader = easyocr.Reader(['fr', 'en'], gpu=True, verbose=False)
results = _reader.readtext(np.array(screen))
for (bbox_pts, text, conf) in results:
if not text or len(text.strip()) < 1:
continue
x1 = int(min(p[0] for p in bbox_pts))
y1 = int(min(p[1] for p in bbox_pts))
x2 = int(max(p[0] for p in bbox_pts))
y2 = int(max(p[1] for p in bbox_pts))
words.append({'text': text.strip(), 'bbox': [x1, y1, x2, y2]})
except ImportError:
try:
from services.ocr_service import ocr_extract_words
words = ocr_extract_words(screen) or []
except ImportError:
pass
print(f"🔧 [Réflexe/handle] {len(words)} mots OCR détectés")
# Collecter tous les matchs, prendre le plus bas (bouton = bas du dialogue)
all_matches = []
for candidate in candidates_labels:
candidate_lower = candidate.lower()
for word in words:
word_text = word['text'].lower()
if len(word_text) < 2 or len(candidate_lower) < 2:
continue
# Match exact ou inclusion
if word_text == candidate_lower or candidate_lower in word_text or word_text in candidate_lower:
x1, y1, x2, y2 = word['bbox']
all_matches.append({
'text': word['text'],
'x': int((x1 + x2) / 2),
'y': int((y1 + y2) / 2),
'candidate': candidate,
})
if all_matches:
best = max(all_matches, key=lambda m: m['y'])
print(f"✅ [Réflexe/handle] Clic sur '{best['text']}' à ({best['x']}, {best['y']})")
pyautogui.click(best['x'], best['y'])
time.sleep(1.0)
return True
print(f"⚠️ [Réflexe/handle] Bouton '{target}' introuvable parmi {[w['text'] for w in words[:15]]}")
return False
except Exception as e:
print(f"⚠️ [Réflexe/handle] Erreur: {e}")
return False
elif action == 'hotkey':
keys = target.split('+')
logger.info(f"Raccourci automatique: {target}")
pyautogui.hotkey(*keys)
time.sleep(0.5)
return True
return False
def vlm_reason_about_screen(objective: str = "", context: str = "") -> Optional[Dict[str, Any]]:
"""Demande au VLM de raisonner sur l'écran actuel et proposer une action.
Utilisé quand les réflexes (patterns) ne suffisent pas.
Le VLM voit l'écran et décide quoi faire.
Args:
objective: Ce que Léa essaie de faire (ex: "cliquer sur Enregistrer")
context: Contexte additionnel (ex: "un dialogue est apparu")
Returns:
Dict avec 'action', 'target', 'reasoning' ou None si le VLM ne peut pas aider.
"""
try:
import mss
import requests
import json
import base64
import io
import os
from PIL import Image
with mss.mss() as sct:
monitor = sct.monitors[0]
screenshot = sct.grab(monitor)
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
buffer = io.BytesIO()
screen.save(buffer, format='JPEG', quality=70)
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
prompt = f"""Analyse cet écran et dis-moi quoi faire.
Objectif : {objective or "Interagir avec l'interface visible"}
Contexte : {context or "Aucun contexte supplémentaire"}
Réponds en JSON strict :
{{
"action": "click" ou "type" ou "wait" ou "nothing",
"target": "texte exact du bouton ou champ à cliquer",
"reasoning": "explication courte de ton choix"
}}
Si tu vois un dialogue ou une popup, indique quel bouton cliquer.
Si l'écran est normal sans action nécessaire, réponds action="nothing".
Réponds UNIQUEMENT le JSON, pas d'explication."""
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
response = requests.post(
f"{ollama_url}/api/generate",
json={
"model": model,
"prompt": prompt,
"images": [image_b64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 200}
},
timeout=30
)
if response.status_code != 200:
logger.warning(f"VLM reasoning failed: HTTP {response.status_code}")
return None
result = response.json()
text = result.get('response', '').strip()
import re
match = re.search(r'\{[\s\S]*\}', text)
if match:
parsed = json.loads(match.group())
logger.info(f"VLM reasoning: {parsed.get('action')} '{parsed.get('target')}'{parsed.get('reasoning', '')[:80]}")
return parsed
logger.debug(f"VLM response not parseable: {text[:100]}")
return None
except Exception as e:
logger.debug(f"VLM reasoning failed: {e}")
return None
def find_element_on_screen(
target_text: str,
target_description: str = "",
anchor_image_base64: Optional[str] = None,
anchor_bbox: Optional[Dict] = None,
monitor_idx: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""
Cherche un élément sur l'écran en utilisant 3 méthodes en cascade.
Niveau 1 — OCR (rapide, ~1s) : docTR pour trouver le texte exact
Niveau 2 — UI-TARS grounding (~3s) : modèle GUI spécialisé
Niveau 3 — VLM reasoning (~10s) : raisonnement + OCR de confirmation
Args:
target_text: Texte de l'élément à trouver (ex: "Demo", "Enregistrer")
target_description: Description plus longue (ex: "le dossier Demo sur le bureau")
anchor_image_base64: Image de référence de l'ancre (pour CLIP matching, réservé futur)
anchor_bbox: Position originale de l'ancre (pour désambiguïser les matchs multiples)
monitor_idx: Index logique 0..N-1 du monitor à scruter. None = composite legacy.
Returns:
{'x': int, 'y': int, 'method': str, 'confidence': float} ou None
"""
# Si le target_text est vide ou c'est juste le type d'action,
# utiliser le VLM pour décrire l'image de l'ancre
action_types = {'click_anchor', 'double_click_anchor', 'right_click_anchor',
'hover_anchor', 'focus_anchor', 'scroll_to_anchor'}
has_useful_text = target_text and target_text not in action_types
if not has_useful_text and anchor_image_base64:
desc = _describe_anchor_image(anchor_image_base64)
if desc:
logger.info(f"[Grounding] Ancre décrite par VLM: '{desc}'")
target_description = desc
if not has_useful_text:
target_text = desc
if not target_text and not target_description:
logger.debug("find_element_on_screen: ni target_text ni target_description fournis")
return None
# Propager monitor_idx au niveau OCR via anchor_bbox (sans muter l'argument original)
if monitor_idx is not None and anchor_bbox is not None:
anchor_bbox = dict(anchor_bbox) # copie pour ne pas muter l'argument
anchor_bbox["monitor_idx"] = monitor_idx
elif monitor_idx is not None:
anchor_bbox = {"monitor_idx": monitor_idx}
search_label = target_description or target_text
logger.info(f"[Grounding] Recherche élément: '{search_label}' (cascade 3 niveaux)")
# ─── Niveau 1 — OCR (rapide, ~1s) ───
result = _grounding_ocr(target_text, anchor_bbox=anchor_bbox)
if result:
return result
# ─── Niveau 2 — UI-TARS grounding (~3s) ───
result = _grounding_ui_tars(target_text, target_description, monitor_idx=monitor_idx)
if result:
return result
# ─── Niveau 3 — VLM reasoning (~10s) ───
result = _grounding_vlm(target_text, target_description, monitor_idx=monitor_idx)
if result:
return result
logger.warning(f"[Grounding] ÉCHEC total pour '{search_label}' — aucune méthode n'a trouvé l'élément")
return None
def _describe_anchor_image(anchor_image_base64: str) -> Optional[str]:
"""Demande au VLM de décrire l'image de l'ancre en quelques mots.
Utilisé quand le label est vide — le VLM regarde le crop de l'ancre
et décrit ce qu'il voit ("folder icon named Demo", "Save button", etc.)
pour que UI-TARS puisse chercher cet élément sur l'écran complet.
"""
try:
import requests
import os
if ',' in anchor_image_base64:
anchor_image_base64 = anchor_image_base64.split(',', 1)[1]
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = "qwen2.5vl:3b"
logger.info(f"[Grounding] Description ancre via {model}...")
response = requests.post(
f"{ollama_url}/api/generate",
json={
"model": model,
"prompt": "Describe this UI element in 5 words maximum. Just the element name, nothing else. Example: 'folder icon named Demo' or 'Save button' or 'Chrome browser icon'",
"images": [anchor_image_base64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 20}
},
timeout=30
)
if response.status_code == 200:
desc = response.json().get('response', '').strip().strip('"').strip("'")
if desc and len(desc) > 2:
return desc
return None
except Exception as e:
logger.warning(f"[Grounding] Description ancre échouée: {e}")
return None
def _capture_screen(monitor_idx=None):
"""Capture l'écran et retourne (PIL.Image, width, height, offset_x, offset_y).
Args:
monitor_idx: Index logique 0..N-1 du monitor à capturer (cf. screeninfo).
Si None : capture composite (mss.monitors[0]) — comportement legacy.
Returns:
(image, w, h, offset_x, offset_y). offset = (0,0) en mode composite.
"""
try:
with mss.mss() as sct:
if monitor_idx is None:
# Comportement actuel : composite tous écrans
monitor = sct.monitors[0]
offset_x, offset_y = 0, 0
else:
# mss skip monitors[0] (composite). Index logique 0 → mss.monitors[1].
mss_idx = int(monitor_idx) + 1
if mss_idx >= len(sct.monitors):
logger.warning(
"mss.monitors[%d] hors limites (n=%d) — fallback composite",
mss_idx, len(sct.monitors),
)
monitor = sct.monitors[0]
offset_x, offset_y = 0, 0
else:
monitor = sct.monitors[mss_idx]
offset_x = int(monitor.get("left", 0))
offset_y = int(monitor.get("top", 0))
screenshot = sct.grab(monitor)
screen = PILImage.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
return screen, monitor['width'], monitor['height'], offset_x, offset_y
except Exception as e:
logger.debug(f"Capture écran échouée: {e}")
return None, 0, 0, 0, 0
def _grounding_ocr(target_text: str, anchor_bbox: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
"""Niveau 1 — Cherche le texte par OCR (docTR). ~1s.
Collecte TOUS les matchs et choisit le plus pertinent :
- Si anchor_bbox fourni → le plus proche de la position originale
- Sinon → le plus proche du centre de l'écran (zone contenu)
"""
logger.debug(f"[Grounding/OCR] target='{target_text}' bbox={anchor_bbox}")
if not target_text:
return None
try:
monitor_idx_param = anchor_bbox.get("monitor_idx") if anchor_bbox else None
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx_param)
if screen is None:
return None
try:
from services.ocr_service import ocr_extract_words
except ImportError:
from core.extraction.field_extractor import FieldExtractor
extractor = FieldExtractor()
def ocr_extract_words(img):
return extractor.extract_words_from_image(img)
words = ocr_extract_words(screen)
if not words:
logger.debug("[Grounding/OCR] Aucun mot détecté")
return None
target_lower = target_text.lower()
all_matches = []
# Collecter tous les matchs
for word in words:
word_lower = word['text'].lower()
x1, y1, x2, y2 = word['bbox']
cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2)
if word_lower == target_lower:
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'exact', 'conf': 0.95})
elif len(word_lower) >= 3 and len(target_lower) >= 3:
if target_lower in word_lower or word_lower in target_lower:
# Pénaliser les matchs partiels trop courts par rapport au target
ratio = len(word_lower) / max(len(target_lower), 1)
conf = 0.80 if ratio > 0.5 else 0.50
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'partial', 'conf': conf})
# Matching lettre initiale manquante
if not all_matches and len(target_lower) > 3:
partial = target_lower[1:]
for word in words:
if partial in word['text'].lower():
x1, y1, x2, y2 = word['bbox']
all_matches.append({'text': word['text'], 'x': int((x1+x2)/2), 'y': int((y1+y2)/2), 'type': 'partial_cut', 'conf': 0.70})
if not all_matches:
logger.debug(f"[Grounding/OCR] '{target_text}' non trouvé parmi {len(words)} mots")
return None
# Choisir le meilleur match
if len(all_matches) == 1:
best = all_matches[0]
elif anchor_bbox:
# Prendre le plus proche de la position originale de l'ancre
orig_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) / 2
orig_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) / 2
best = min(all_matches, key=lambda m: ((m['x'] - orig_x)**2 + (m['y'] - orig_y)**2))
else:
# Prendre le plus central (zone contenu, pas les barres de titre)
center_x, center_y = screen_w / 2, screen_h / 2
best = min(all_matches, key=lambda m: ((m['x'] - center_x)**2 + (m['y'] - center_y)**2))
for m in all_matches:
sel = " ← CHOISI" if m is best else ""
logger.info(f" [OCR] Candidat: '{m['text']}' à ({m['x']}, {m['y']}) [{m['type']}]{sel}")
return {'x': best['x'] + ox, 'y': best['y'] + oy, 'method': 'ocr', 'confidence': best['conf']}
except Exception as e:
logger.debug(f"[Grounding/OCR] Erreur: {e}")
return None
def _grounding_ui_tars(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
"""Niveau 2 — UI-TARS grounding visuel (~3s)."""
try:
import requests
import base64
import io
import re
import os
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
if screen is None:
return None
# Encoder le screenshot en base64
buffer = io.BytesIO()
screen.save(buffer, format='JPEG', quality=70)
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
# Construire le prompt pour UI-TARS
click_target = target_description or target_text
prompt = f"click on {click_target}"
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = "0000/ui-tars-1.5-7b-q8_0:7b"
logger.info(f"[Grounding/UI-TARS] Envoi à {model}: '{prompt}'")
response = requests.post(
f"{ollama_url}/api/generate",
json={
"model": model,
"prompt": prompt,
"images": [image_b64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 50}
},
timeout=30
)
if response.status_code != 200:
logger.warning(f"[Grounding/UI-TARS] HTTP {response.status_code}")
return None
result = response.json()
text = result.get('response', '').strip()
logger.debug(f"[Grounding/UI-TARS] Réponse brute: {text[:200]}")
# Parser les coordonnées de UI-TARS
coords = _parse_ui_tars_coordinates(text, screen_w, screen_h)
if coords:
x, y = coords
# Valider que les coordonnées sont dans l'écran
if 0 <= x <= screen_w and 0 <= y <= screen_h:
logger.info(f"[Grounding/UI-TARS] Grounding → ({x}, {y})")
return {'x': x + ox, 'y': y + oy, 'method': 'ui_tars', 'confidence': 0.85}
else:
logger.warning(f"[Grounding/UI-TARS] Coordonnées hors écran: ({x}, {y}) pour {screen_w}x{screen_h}")
return None
logger.debug(f"[Grounding/UI-TARS] Pas de coordonnées parsées dans: {text[:100]}")
return None
except Exception as e:
logger.debug(f"[Grounding/UI-TARS] Erreur: {e}")
return None
def _parse_ui_tars_coordinates(text: str, screen_w: int, screen_h: int) -> Optional[tuple]:
"""Parse les coordonnées retournées par UI-TARS.
UI-TARS peut retourner :
- Coordonnées normalisées (0-1000) : "click at (500, 300)"
- Coordonnées en pixels : "click at (960, 540)"
- Format (x, y) ou [x, y] ou x,y
- Format "Action: click\nCoordinate: (500, 300)" ou "[500, 300]"
Returns:
(x_pixel, y_pixel) ou None
"""
import re
# Chercher des patterns de coordonnées
patterns = [
r'Coordinate:\s*\[?\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?\]?',
r'click\s+(?:at\s+)?\[?\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?\]?',
r'\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)',
r'\[\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\]',
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
raw_x = float(match.group(1))
raw_y = float(match.group(2))
# UI-TARS utilise souvent des coordonnées normalisées 0-1000
if raw_x <= 1000 and raw_y <= 1000 and (raw_x > 1 or raw_y > 1):
# Probablement normalisées sur 1000
x = int(raw_x * screen_w / 1000)
y = int(raw_y * screen_h / 1000)
elif raw_x <= 1.0 and raw_y <= 1.0:
# Normalisées 0-1
x = int(raw_x * screen_w)
y = int(raw_y * screen_h)
else:
# Pixels directs
x = int(raw_x)
y = int(raw_y)
return (x, y)
return None
def _grounding_vlm(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
"""Niveau 3 — VLM reasoning + confirmation OCR (~10s)."""
try:
search_label = target_description or target_text
vlm_result = vlm_reason_about_screen(
objective=f"Cliquer sur {search_label}",
context=f"Je cherche l'élément '{target_text}' sur l'écran pour cliquer dessus"
)
if not vlm_result:
logger.debug("[Grounding/VLM] VLM n'a pas retourné de résultat")
return None
if vlm_result.get('action') != 'click' or not vlm_result.get('target'):
logger.debug(f"[Grounding/VLM] VLM action={vlm_result.get('action')}, pas un clic")
return None
vlm_target = vlm_result['target']
logger.info(f"[Grounding/VLM] VLM suggère de cliquer sur: '{vlm_target}'")
# Confirmation par OCR : chercher le target VLM sur l'écran
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
if screen is None:
return None
try:
try:
from services.ocr_service import ocr_extract_words
except ImportError:
from core.extraction.field_extractor import FieldExtractor
extractor = FieldExtractor()
def ocr_extract_words(img):
return extractor.extract_words_from_image(img)
words = ocr_extract_words(screen)
vlm_target_lower = vlm_target.lower()
for word in words:
if vlm_target_lower in word['text'].lower() or word['text'].lower() in vlm_target_lower:
x1, y1, x2, y2 = word['bbox']
x = int((x1 + x2) / 2)
y = int((y1 + y2) / 2)
logger.info(f"[Grounding/VLM] Confirmé par OCR: '{word['text']}' à ({x}, {y})")
return {'x': x + ox, 'y': y + oy, 'method': 'vlm', 'confidence': 0.75}
logger.debug(f"[Grounding/VLM] Target VLM '{vlm_target}' non trouvé par OCR")
return None
except Exception as e:
logger.debug(f"[Grounding/VLM] OCR de confirmation échoué: {e}")
return None
except Exception as e:
logger.debug(f"[Grounding/VLM] Erreur: {e}")
return None
def post_execution_cleanup(execution_mode: str = 'debug'):
"""Vérifie l'écran après exécution et gère les dialogues restants.
Appelé après la dernière étape d'un workflow pour laisser l'écran propre.
"""
if execution_mode not in ('intelligent', 'debug'):
return
logger.info("Vérification écran final...")
time.sleep(1.0)
for _ in range(3):
detected = check_screen_for_patterns()
if detected:
logger.info(f"Dialogue résiduel détecté: {detected.get('pattern')}")
handle_detected_pattern(detected)
time.sleep(1.0)
else:
vlm_result = vlm_reason_about_screen(
objective="Vérifier que l'écran est propre après l'exécution",
context="Le workflow vient de se terminer"
)
if vlm_result and vlm_result.get('action') in ('click', 'type'):
logger.info(f"VLM post-workflow: {vlm_result.get('action')} '{vlm_result.get('target')}'")
break

View File

@@ -40,12 +40,16 @@ class LLMActionHandler:
def __init__(
self,
ollama_endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b",
model: str = None,
temperature: float = 0.1,
timeout: int = 120,
):
self.endpoint = ollama_endpoint.rstrip("/")
self.model = model
if model is not None:
self.model = model
else:
from core.detection.vlm_config import get_vlm_model
self.model = get_vlm_model()
self.temperature = temperature
self.timeout = timeout

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
# Configuration Ollama (coherente avec le reste du projet)
OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:8b")
OLLAMA_DEFAULT_MODEL = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
class FieldExtractor:

View File

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

View File

@@ -2,7 +2,7 @@
GPU Resource Manager - Central orchestrator for GPU resource allocation
Manages dynamic allocation of GPU resources between:
- Ollama VLM (qwen3-vl:8b) - ~10.5 GB VRAM for UI classification
- Ollama VLM (gemma4:e4b par défaut) - ~10 GB VRAM for UI classification
- CLIP (ViT-B-32) - ~500 MB VRAM for embedding matching
Optimizes VRAM usage based on execution mode:
@@ -12,13 +12,14 @@ Optimizes VRAM usage based on execution mode:
"""
import asyncio
import contextlib
import logging
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, Iterator, List, Optional
logger = logging.getLogger(__name__)
@@ -53,7 +54,7 @@ class VRAMInfo:
class GPUResourceConfig:
"""Configuration for GPU resource management."""
ollama_endpoint: str = "http://localhost:11434"
vlm_model: str = "qwen3-vl:8b"
vlm_model: str = "gemma4:e4b"
clip_model: str = "ViT-B-32"
idle_timeout_seconds: int = 300 # 5 minutes
vram_threshold_for_clip_gpu_mb: int = 1024 # 1 GB
@@ -126,6 +127,12 @@ class GPUResourceManager:
# Operation queue for sequential processing
self._operation_queue: asyncio.Queue = asyncio.Queue()
self._operation_lock = asyncio.Lock()
# Lock d'inférence synchrone : sérialise les appels GPU concurrents
# (ScreenAnalyzer.analyze, UIDetector, CLIP.encode) entre
# ExecutionLoop et stream_processor pour éviter la saturation VRAM
# sur RTX 5070 (12 Go). Un seul analyze à la fois sur le GPU.
self._inference_lock = threading.Lock()
# Event callbacks
self._on_resource_changed: List[Callable[[ResourceChangedEvent], None]] = []
@@ -207,7 +214,45 @@ class GPUResourceManager:
def get_execution_mode(self) -> ExecutionMode:
"""Get the current execution mode."""
return self._execution_mode
# =========================================================================
# Inference serialization (sync)
# =========================================================================
@contextlib.contextmanager
def acquire_inference(self, timeout: Optional[float] = None) -> Iterator[bool]:
"""
Context manager synchrone pour sérialiser les inférences GPU.
Garantit qu'un seul appel d'inférence (ScreenAnalyzer.analyze,
UIDetector.detect, CLIP.encode…) tourne à la fois sur le GPU.
Évite la saturation VRAM quand ExecutionLoop et stream_processor
appellent analyze() simultanément sur une RTX 5070 (12 Go).
Args:
timeout: Délai max d'attente (secondes). None = bloquant.
Yields:
True si le lock est acquis, False en cas de timeout.
Example:
>>> with gpu_manager.acquire_inference(timeout=30.0) as acquired:
... if not acquired:
... logger.warning("GPU lock timeout")
... state = analyzer.analyze(path)
"""
if timeout is None:
self._inference_lock.acquire()
acquired = True
else:
acquired = self._inference_lock.acquire(timeout=timeout)
try:
yield acquired
finally:
if acquired:
self._inference_lock.release()
# =========================================================================
# VLM Management
# =========================================================================

View File

@@ -32,7 +32,7 @@ class OllamaManager:
def __init__(
self,
endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b",
model: str = "gemma4:e4b",
default_keep_alive: str = "5m"
):
"""

View File

@@ -0,0 +1,20 @@
# core/grounding — Module de localisation d'éléments UI
#
# Centralise les méthodes de grounding visuel : template matching,
# OCR, VLM, etc. Chaque méthode produit un GroundingResult uniforme.
#
# Le serveur de grounding (server.py) tourne dans un process séparé
# sur le port 8200. Le client HTTP (UITarsGrounder) l'appelle via HTTP.
# Le pipeline (GroundingPipeline) orchestre template → OCR → UI-TARS → static.
from core.grounding.template_matcher import TemplateMatcher, MatchResult
from core.grounding.target import GroundingTarget, GroundingResult
from core.grounding.ui_tars_grounder import UITarsGrounder
from core.grounding.pipeline import GroundingPipeline
__all__ = [
'TemplateMatcher', 'MatchResult',
'GroundingTarget', 'GroundingResult',
'UITarsGrounder',
'GroundingPipeline',
]

View File

@@ -0,0 +1,256 @@
"""
core/grounding/dialog_handler.py — Gestion intelligente des dialogues
Quand un dialogue inattendu apparaît (pHash change après une action) :
1. Lire le titre de la fenêtre (EasyOCR crop 45px, ~130ms)
2. Si titre connu (Enregistrer sous, Confirmer, etc.) → action connue
3. Demander à InfiGUI de cliquer sur le bon bouton (~3s)
4. Vérifier que le dialogue a disparu (pHash)
Pas de patterns prédéfinis pour les boutons. InfiGUI comprend
visuellement le dialogue et clique au bon endroit.
Utilisation :
from core.grounding.dialog_handler import DialogHandler
handler = DialogHandler()
result = handler.handle_if_dialog(screenshot_pil)
if result['handled']:
print(f"Dialogue '{result['title']}' géré → {result['action']}")
"""
from __future__ import annotations
import time
from typing import Any, Dict, Optional
# Titres connus → quelle action demander à InfiGUI.
#
# IMPORTANT — ordre du dict = priorité de matching.
# L'OCR est full-screen et capte souvent le texte du dialog parent ET du popup
# modal qui apparaît par-dessus (ex: "Enregistrer sous" reste visible derrière
# "Confirmer l'enregistrement"). Les popups modaux DOIVENT matcher avant les
# fenêtres principales, sinon Léa clique sur le bouton du parent qui n'a pas
# le focus.
KNOWN_DIALOGS = {
# ── Popups modaux de confirmation (priorité HAUTE) ──────────────────
"voulez-vous le remplacer": {"target": "Oui", "description": "Clique sur Oui pour confirmer le remplacement du fichier"},
"do you want to replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
"existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà et doit être remplacé"},
"already exists": {"target": "Yes", "description": "Click Yes, the file already exists"},
"remplacer": {"target": "Oui", "description": "Clique sur le bouton Oui pour confirmer le remplacement du fichier"},
"replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
"écraser": {"target": "Oui", "description": "Clique sur Oui pour écraser le fichier"},
"overwrite": {"target": "Yes", "description": "Click Yes to overwrite"},
"confirmer l'enregistrement": {"target": "Oui", "description": "Clique sur Oui dans le popup de confirmation d'enregistrement"},
"confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"},
# ── Avertissements/erreurs (priorité haute, 1 seul bouton OK) ───────
"erreur": {"target": "OK", "description": "Clique sur OK pour fermer le message d'erreur"},
"error": {"target": "OK", "description": "Click OK to close the error message"},
"avertissement": {"target": "OK", "description": "Clique sur OK pour fermer l'avertissement"},
"warning": {"target": "OK", "description": "Click OK to close the warning"},
# ── Dialogs principaux de sauvegarde (priorité BASSE — fenêtres parents) ─
"voulez-vous enregistrer": {"target": "Enregistrer", "description": "Clique sur Enregistrer pour sauvegarder les modifications"},
"do you want to save": {"target": "Save", "description": "Click Save to save changes"},
"enregistrer sous": {"target": "Enregistrer", "description": "Clique sur le bouton Enregistrer dans le dialogue Enregistrer sous"},
"save as": {"target": "Save", "description": "Click the Save button in the Save As dialog"},
}
class DialogHandler:
"""Gestion intelligente des dialogues via titre + InfiGUI."""
def __init__(self):
self._easyocr_reader = None
def handle_if_dialog(
self,
screenshot_pil,
previous_title: str = "",
) -> Dict[str, Any]:
"""Vérifie si l'écran montre un dialogue et le gère.
Args:
screenshot_pil: Screenshot PIL actuel.
previous_title: Titre de la fenêtre avant l'action (pour comparaison).
Returns:
Dict avec 'handled' (bool), 'title', 'action', 'position'.
"""
t0 = time.time()
# 1. Lire le titre de la fenêtre
title = self._read_title(screenshot_pil)
if not title or len(title) < 3:
return {'handled': False, 'title': '', 'reason': 'Titre illisible'}
print(f"🔍 [Dialog] Titre lu: '{title}'")
# 2. Chercher si c'est un dialogue connu
matched_dialog = None
for key, action_info in KNOWN_DIALOGS.items():
if key in title.lower():
matched_dialog = (key, action_info)
break
if not matched_dialog:
# Pas un dialogue connu — le workflow continue normalement
return {'handled': False, 'title': title, 'reason': 'Pas un dialogue connu'}
dialog_key, action_info = matched_dialog
target = action_info['target']
description = action_info['description']
print(f"🧠 [Dialog] Dialogue détecté: '{dialog_key}' → clic '{target}'")
# 3. Demander à InfiGUI de cliquer sur le bouton
click_result = self._click_via_infigui(
target, description, screenshot_pil
)
dt = (time.time() - t0) * 1000
if click_result:
print(f"✅ [Dialog] Clic '{target}' à ({click_result['x']}, {click_result['y']}) ({dt:.0f}ms)")
return {
'handled': True,
'title': title,
'dialog_type': dialog_key,
'action': f"click '{target}'",
'position': (click_result['x'], click_result['y']),
'time_ms': dt,
}
else:
# InfiGUI n'a pas trouvé le bouton — essayer le clic direct via OCR
print(f"⚠️ [Dialog] InfiGUI n'a pas trouvé '{target}', essai OCR direct")
ocr_result = self._click_via_ocr(target, screenshot_pil)
dt = (time.time() - t0) * 1000
if ocr_result:
print(f"✅ [Dialog] OCR clic '{target}' à ({ocr_result[0]}, {ocr_result[1]}) ({dt:.0f}ms)")
return {
'handled': True,
'title': title,
'dialog_type': dialog_key,
'action': f"click '{target}' (OCR)",
'position': ocr_result,
'time_ms': dt,
}
print(f"❌ [Dialog] Impossible de cliquer '{target}' ({dt:.0f}ms)")
return {
'handled': False,
'title': title,
'dialog_type': dialog_key,
'reason': f"Bouton '{target}' introuvable",
'time_ms': dt,
}
# ------------------------------------------------------------------
# Lecture titre
# ------------------------------------------------------------------
def _read_title(self, screenshot_pil) -> str:
"""Lit TOUT le texte visible via EasyOCR full-screen (~500ms).
En VM QEMU, la barre de titre Windows est à l'intérieur du framebuffer,
pas en haut absolu de l'écran. On fait l'OCR full-screen et on cherche
les mots-clés des dialogues connus dans le texte complet.
"""
try:
import numpy as np
reader = self._get_easyocr()
if reader is None:
return ""
results = reader.readtext(np.array(screenshot_pil))
full_text = ' '.join(r[1] for r in results if r[1].strip())
return full_text
except Exception as e:
print(f"⚠️ [Dialog] Erreur lecture écran: {e}")
return ""
# ------------------------------------------------------------------
# Clic via InfiGUI (serveur grounding)
# ------------------------------------------------------------------
def _click_via_infigui(
self, target: str, description: str, screenshot_pil
) -> Optional[Dict]:
"""Demande à InfiGUI (subprocess one-shot) de localiser et cliquer sur le bouton."""
try:
from core.grounding.ui_tars_grounder import UITarsGrounder
grounder = UITarsGrounder.get_instance()
result = grounder.ground(
target_text=target,
target_description=description,
screen_pil=screenshot_pil,
)
if result and result.x is not None:
import pyautogui
pyautogui.click(result.x, result.y)
return {'x': result.x, 'y': result.y}
return None
except Exception as e:
print(f"⚠️ [Dialog/InfiGUI] Erreur: {e}")
return None
# ------------------------------------------------------------------
# Clic via OCR (fallback rapide)
# ------------------------------------------------------------------
def _click_via_ocr(self, target: str, screenshot_pil) -> Optional[tuple]:
"""Cherche le bouton par OCR et clique dessus."""
try:
import numpy as np
reader = self._get_easyocr()
if reader is None:
return None
results = reader.readtext(np.array(screenshot_pil))
target_lower = target.lower()
matches = []
for (bbox_pts, text, conf) in results:
if target_lower in text.lower() or text.lower() in target_lower:
x = int(sum(p[0] for p in bbox_pts) / 4)
y = int(sum(p[1] for p in bbox_pts) / 4)
matches.append((x, y, text))
if matches:
# Prendre le match le plus bas (boutons = bas du dialogue)
best = max(matches, key=lambda m: m[1])
import pyautogui
pyautogui.click(best[0], best[1])
return (best[0], best[1])
return None
except Exception as e:
print(f"⚠️ [Dialog/OCR] Erreur: {e}")
return None
# ------------------------------------------------------------------
# EasyOCR singleton
# ------------------------------------------------------------------
def _get_easyocr(self):
if self._easyocr_reader is not None:
return self._easyocr_reader
try:
import easyocr
self._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
return self._easyocr_reader
except ImportError:
return None

View File

@@ -0,0 +1,239 @@
"""
core/grounding/element_signature.py — Signatures d'éléments UI apprises
Chaque élément cliqué avec succès enrichit sa signature :
- texte OCR, type, position relative, voisins contextuels
- nombre de succès/échecs, confiance moyenne
- variantes observées (résolutions, positions)
Les signatures sont stockées en SQLite pour un lookup rapide.
Pattern identique à TargetMemoryStore (validé en prod).
Utilisation :
from core.grounding.element_signature import SignatureStore
store = SignatureStore()
# Après un clic réussi
store.record_success("btn_valider", "notepad_1920x1080", element, confidence=0.92)
# Au replay
sig = store.lookup("btn_valider", "notepad_1920x1080")
if sig:
print(f"Signature connue : {sig['text']} position={sig['relative_position']}")
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import threading
import time
from typing import Any, Dict, List, Optional
from core.grounding.fast_types import DetectedUIElement
# Chemin par défaut de la DB
_DEFAULT_DB = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"data", "learning", "element_signatures.db",
)
class SignatureStore:
"""Stockage SQLite des signatures d'éléments UI appris."""
def __init__(self, db_path: str = _DEFAULT_DB):
self.db_path = db_path
self._lock = threading.Lock()
self._ensure_db()
def _ensure_db(self):
"""Crée la DB et la table si nécessaire."""
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS signatures (
target_key TEXT NOT NULL,
screen_context TEXT NOT NULL,
text TEXT DEFAULT '',
element_type TEXT DEFAULT 'element',
relative_position TEXT DEFAULT '',
neighbors TEXT DEFAULT '[]',
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
avg_confidence REAL DEFAULT 0.0,
last_seen TEXT DEFAULT '',
variants TEXT DEFAULT '[]',
PRIMARY KEY (target_key, screen_context)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_target_key
ON signatures(target_key)
""")
# ------------------------------------------------------------------
# Lookup
# ------------------------------------------------------------------
def lookup(self, target_key: str, screen_context: str = "") -> Optional[Dict[str, Any]]:
"""Cherche une signature connue.
Args:
target_key: Clé unique de la cible (hash du texte + description).
screen_context: Contexte d'écran (hash titre fenêtre + résolution).
Returns:
Dict avec les champs de la signature, ou None.
"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
# Chercher avec le contexte exact d'abord
row = conn.execute(
"SELECT * FROM signatures WHERE target_key = ? AND screen_context = ?",
(target_key, screen_context),
).fetchone()
# Fallback : chercher sans contexte (toutes les variantes)
if row is None and screen_context:
row = conn.execute(
"SELECT * FROM signatures WHERE target_key = ? ORDER BY success_count DESC LIMIT 1",
(target_key,),
).fetchone()
if row is None:
return None
return {
"target_key": row["target_key"],
"screen_context": row["screen_context"],
"text": row["text"],
"element_type": row["element_type"],
"relative_position": row["relative_position"],
"neighbors": json.loads(row["neighbors"]),
"success_count": row["success_count"],
"fail_count": row["fail_count"],
"avg_confidence": row["avg_confidence"],
"last_seen": row["last_seen"],
"variants": json.loads(row["variants"]),
}
# ------------------------------------------------------------------
# Enregistrement
# ------------------------------------------------------------------
def record_success(
self,
target_key: str,
screen_context: str,
element: DetectedUIElement,
confidence: float,
):
"""Enregistre un succès — crée ou enrichit la signature."""
with self._lock:
existing = self.lookup(target_key, screen_context)
now = time.strftime("%Y-%m-%dT%H:%M:%S")
if existing:
# Enrichir la signature existante
n = existing["success_count"]
new_avg = (existing["avg_confidence"] * n + confidence) / (n + 1)
# Ajouter la variante si position différente
variants = existing["variants"]
variant = {
"position": element.relative_position,
"center": list(element.center),
"confidence": confidence,
"timestamp": now,
}
variants.append(variant)
# Garder les 20 dernières variantes max
variants = variants[-20:]
# Mettre à jour les voisins (union)
neighbors = list(set(existing["neighbors"] + element.neighbors))[:10]
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
UPDATE signatures SET
success_count = success_count + 1,
avg_confidence = ?,
last_seen = ?,
neighbors = ?,
variants = ?,
relative_position = ?
WHERE target_key = ? AND screen_context = ?
""", (
new_avg, now,
json.dumps(neighbors),
json.dumps(variants),
element.relative_position,
target_key, screen_context,
))
else:
# Créer une nouvelle signature
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
INSERT INTO signatures
(target_key, screen_context, text, element_type, relative_position,
neighbors, success_count, fail_count, avg_confidence, last_seen, variants)
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?)
""", (
target_key, screen_context,
element.ocr_text,
element.element_type,
element.relative_position,
json.dumps(element.neighbors[:10]),
confidence, now,
json.dumps([{
"position": element.relative_position,
"center": list(element.center),
"confidence": confidence,
"timestamp": now,
}]),
))
print(f"📝 [Signature] '{target_key}' {'enrichie' if existing else 'créée'} "
f"(conf={confidence:.2f}, ctx='{screen_context[:30]}')")
def record_failure(self, target_key: str, screen_context: str):
"""Enregistre un échec pour une signature."""
with self._lock:
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
UPDATE signatures SET fail_count = fail_count + 1, last_seen = ?
WHERE target_key = ? AND screen_context = ?
""", (time.strftime("%Y-%m-%dT%H:%M:%S"), target_key, screen_context))
# ------------------------------------------------------------------
# Utilitaires
# ------------------------------------------------------------------
@staticmethod
def make_target_key(text: str, description: str = "") -> str:
"""Génère une clé unique pour une cible."""
raw = f"{text.lower().strip()}|{description.lower().strip()}"
return hashlib.md5(raw.encode()).hexdigest()[:16]
@staticmethod
def make_screen_context(window_title: str, resolution: tuple = (0, 0)) -> str:
"""Génère un contexte d'écran."""
raw = f"{window_title.lower().strip()}|{resolution[0]}x{resolution[1]}"
return hashlib.md5(raw.encode()).hexdigest()[:12]
def get_stats(self) -> Dict[str, Any]:
"""Statistiques de la base de signatures."""
with sqlite3.connect(self.db_path) as conn:
total = conn.execute("SELECT COUNT(*) FROM signatures").fetchone()[0]
reliable = conn.execute(
"SELECT COUNT(*) FROM signatures WHERE success_count >= 3 AND fail_count = 0"
).fetchone()[0]
return {
"total_signatures": total,
"reliable": reliable,
"db_path": self.db_path,
}

View File

@@ -0,0 +1,326 @@
"""
core/grounding/fast_detector.py — Layer FAST : détection rapide des éléments UI
Capture l'écran, détecte tous les éléments UI via RF-DETR (~120ms),
enrichit chaque élément avec le texte OCR et le contexte spatial.
Produit un ScreenSnapshot utilisable par le SmartMatcher.
Utilisation :
from core.grounding.fast_detector import FastDetector
detector = FastDetector()
snapshot = detector.detect()
print(f"{len(snapshot.elements)} éléments en {snapshot.total_time_ms:.0f}ms")
"""
from __future__ import annotations
import math
import time
from typing import Any, Dict, List, Optional, Tuple
from core.grounding.fast_types import DetectedUIElement, ScreenSnapshot
class FastDetector:
"""Détection rapide de tous les éléments UI visibles sur l'écran.
Combine RF-DETR (détection bbox) + docTR (OCR) pour produire
un ScreenSnapshot enrichi.
Le modèle RF-DETR est un singleton chargé au premier appel (~1s),
puis les appels suivants sont rapides (~120ms).
"""
def __init__(self, detection_threshold: float = 0.30):
self.detection_threshold = detection_threshold
self._last_snapshot: Optional[ScreenSnapshot] = None
self._last_phash: str = ""
def detect(
self,
screenshot_pil: Optional[Any] = None,
phash: str = "",
window_title: str = "",
) -> ScreenSnapshot:
"""Détecte et enrichit tous les éléments UI de l'écran.
Args:
screenshot_pil: Image PIL. Si None, capture via mss.
phash: Hash perceptuel pour le cache. Si identique au dernier, réutilise le cache.
window_title: Titre de la fenêtre active.
Returns:
ScreenSnapshot avec tous les éléments enrichis.
"""
t0 = time.time()
# Cache : même écran → même résultat
if phash and phash == self._last_phash and self._last_snapshot is not None:
print(f"⚡ [FAST] Cache hit (pHash identique)")
return self._last_snapshot
# Capture si pas fourni
if screenshot_pil is None:
screenshot_pil = self._capture_screen()
if screenshot_pil is None:
return ScreenSnapshot(elements=[], ocr_words=[], resolution=(0, 0))
w, h = screenshot_pil.size
# --- Détection RF-DETR (~120ms) ---
t_det = time.time()
raw_elements = self._detect_rfdetr(screenshot_pil)
detection_ms = (time.time() - t_det) * 1000
# --- OCR sur les crops des éléments détectés (pas full screen) ---
t_ocr = time.time()
ocr_words = self._ocr_extract(screenshot_pil)
ocr_ms = (time.time() - t_ocr) * 1000
# --- Enrichissement : attribuer texte + voisins + position ---
enriched = self._enrich_elements(raw_elements, ocr_words, w, h)
total_ms = (time.time() - t0) * 1000
snapshot = ScreenSnapshot(
elements=enriched,
ocr_words=ocr_words,
resolution=(w, h),
window_title=window_title,
phash=phash,
detection_time_ms=detection_ms,
ocr_time_ms=ocr_ms,
total_time_ms=total_ms,
)
# Mettre en cache
if phash:
self._last_phash = phash
self._last_snapshot = snapshot
print(f"⚡ [FAST] {len(enriched)} éléments détectés en {total_ms:.0f}ms "
f"(det={detection_ms:.0f}ms, ocr={ocr_ms:.0f}ms)")
return snapshot
# ------------------------------------------------------------------
# Détection RF-DETR
# ------------------------------------------------------------------
def _detect_rfdetr(self, image) -> List[DetectedUIElement]:
"""Détecte les éléments via RF-DETR (réutilise le singleton existant)."""
try:
import sys
sys.path.insert(0, 'visual_workflow_builder/backend')
from services.ui_detection_service import detect_ui_elements
result = detect_ui_elements(image, threshold=self.detection_threshold)
elements = []
for e in result.elements:
x1 = e.bbox["x1"]
y1 = e.bbox["y1"]
x2 = e.bbox["x2"]
y2 = e.bbox["y2"]
elements.append(DetectedUIElement(
id=e.id,
bbox=(x1, y1, x2, y2),
center=(e.center["x"], e.center["y"]),
confidence=e.confidence,
))
return elements
except Exception as ex:
print(f"⚠️ [FAST/detect] RF-DETR erreur: {ex}")
return []
# ------------------------------------------------------------------
# OCR
# ------------------------------------------------------------------
_easyocr_reader = None # Singleton EasyOCR (chargé une fois)
def _ocr_extract(self, image) -> List[Dict[str, Any]]:
"""Extrait les mots visibles via EasyOCR (GPU, ~500ms).
Fallback sur docTR si EasyOCR non disponible.
"""
try:
import numpy as np
import easyocr
# Singleton : charger le reader une seule fois
if FastDetector._easyocr_reader is None:
print(f"🔍 [FAST/ocr] Chargement EasyOCR (GPU)...")
FastDetector._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
results = FastDetector._easyocr_reader.readtext(np.array(image))
words = []
for (bbox_pts, text, conf) in results:
if not text or len(text.strip()) < 1:
continue
# bbox_pts = [[x1,y1],[x2,y1],[x2,y2],[x1,y2]]
x1 = int(min(p[0] for p in bbox_pts))
y1 = int(min(p[1] for p in bbox_pts))
x2 = int(max(p[0] for p in bbox_pts))
y2 = int(max(p[1] for p in bbox_pts))
words.append({
'text': text.strip(),
'bbox': [x1, y1, x2, y2],
'confidence': float(conf),
})
return words
except ImportError:
# Fallback docTR
try:
import sys
sys.path.insert(0, 'visual_workflow_builder/backend')
from services.ocr_service import ocr_extract_words
return ocr_extract_words(image) or []
except Exception:
return []
except Exception as ex:
print(f"⚠️ [FAST/ocr] EasyOCR erreur: {ex}")
return []
# ------------------------------------------------------------------
# Enrichissement
# ------------------------------------------------------------------
def _enrich_elements(
self,
elements: List[DetectedUIElement],
ocr_words: List[Dict[str, Any]],
screen_w: int,
screen_h: int,
) -> List[DetectedUIElement]:
"""Enrichit chaque élément avec texte OCR, voisins et position relative."""
for elem in elements:
# 1. Attribuer le texte OCR par intersection bbox
elem.ocr_text = self._assign_ocr_text(elem, ocr_words)
# 2. Position relative dans l'écran (grille 3x3)
elem.relative_position = self._compute_relative_position(
elem.center, screen_w, screen_h
)
# 3. Classifier le type d'élément (heuristique taille + ratio)
elem.element_type = self._classify_element_type(elem)
# 4. Calculer les voisins (texte des éléments proches)
for elem in elements:
elem.neighbors = self._find_neighbors(elem, elements)
return elements
def _assign_ocr_text(
self,
elem: DetectedUIElement,
ocr_words: List[Dict[str, Any]],
) -> str:
"""Attribue le texte OCR à un élément par intersection géométrique."""
x1, y1, x2, y2 = elem.bbox
# Élargir la bbox de 20% pour capturer le texte autour
margin_x = int((x2 - x1) * 0.2)
margin_y = int((y2 - y1) * 0.2)
ex1, ey1 = x1 - margin_x, y1 - margin_y
ex2, ey2 = x2 + margin_x, y2 + margin_y
texts = []
for word in ocr_words:
wb = word.get('bbox', [0, 0, 0, 0])
if len(wb) < 4:
continue
wx1, wy1, wx2, wy2 = wb[0], wb[1], wb[2], wb[3]
# Intersection ?
if wx1 < ex2 and wx2 > ex1 and wy1 < ey2 and wy2 > ey1:
text = word.get('text', '').strip()
if text and len(text) > 1:
texts.append(text)
return ' '.join(texts)
@staticmethod
def _compute_relative_position(
center: Tuple[int, int],
screen_w: int,
screen_h: int,
) -> str:
"""Calcule la position relative dans une grille 3x3."""
cx, cy = center
col = "left" if cx < screen_w / 3 else ("right" if cx > 2 * screen_w / 3 else "center")
row = "top" if cy < screen_h / 3 else ("bottom" if cy > 2 * screen_h / 3 else "middle")
return f"{row}_{col}"
@staticmethod
def _classify_element_type(elem: DetectedUIElement) -> str:
"""Classifie le type d'élément par heuristique taille/ratio."""
w, h = elem.width, elem.height
if w == 0 or h == 0:
return "element"
ratio = w / h
area = w * h
# Petit carré → icône
if area < 5000 and 0.5 < ratio < 2.0:
return "icon"
# Large et fin → bouton ou champ
if ratio > 3.0 and h < 60:
return "input"
if ratio > 2.0 and h < 50:
return "button"
# Grand bloc → zone de contenu
if area > 50000:
return "container"
return "element"
@staticmethod
def _find_neighbors(
elem: DetectedUIElement,
all_elements: List[DetectedUIElement],
max_neighbors: int = 5,
) -> List[str]:
"""Trouve les textes OCR des éléments proches (rayon 1.5x diagonale)."""
diag = math.sqrt(elem.width**2 + elem.height**2)
radius = max(diag * 1.5, 100) # minimum 100px
neighbors = []
for other in all_elements:
if other.id == elem.id or not other.ocr_text:
continue
dx = other.center[0] - elem.center[0]
dy = other.center[1] - elem.center[1]
dist = math.sqrt(dx**2 + dy**2)
if dist < radius:
neighbors.append(other.ocr_text)
return neighbors[:max_neighbors]
# ------------------------------------------------------------------
# Capture écran
# ------------------------------------------------------------------
@staticmethod
def _capture_screen():
"""Capture l'écran via mss."""
try:
import mss
from PIL import Image
with mss.mss() as sct:
mon = sct.monitors[0]
grab = sct.grab(mon)
return Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX')
except Exception as ex:
print(f"⚠️ [FAST/capture] Erreur: {ex}")
return None

View File

@@ -0,0 +1,216 @@
"""
core/grounding/fast_pipeline.py — Pipeline FAST → SMART → THINK
Orchestrateur central : détecte les éléments (FAST), matche avec la cible (SMART),
et demande au VLM de trancher si le score est trop bas (THINK).
Seuils de confiance :
≥ 0.90 → action directe (FAST/SMART)
0.60-0.90 → VLM confirme (THINK)
< 0.60 → VLM cherche seul (THINK)
L'ancien GroundingPipeline est utilisé en fallback si tout échoue.
Utilisation :
from core.grounding.fast_pipeline import FastSmartThinkPipeline
from core.grounding.target import GroundingTarget
pipeline = FastSmartThinkPipeline()
result = pipeline.locate(GroundingTarget(text="Valider"))
if result:
print(f"({result.x}, {result.y}) via {result.method} en {result.time_ms:.0f}ms")
"""
from __future__ import annotations
import time
import threading
from typing import Optional
from core.grounding.target import GroundingTarget, GroundingResult
from core.grounding.fast_types import LocateResult
from core.grounding.fast_detector import FastDetector
from core.grounding.smart_matcher import SmartMatcher
from core.grounding.think_arbiter import ThinkArbiter
from core.grounding.element_signature import SignatureStore
# Singleton
_instance: Optional[FastSmartThinkPipeline] = None
_instance_lock = threading.Lock()
class FastSmartThinkPipeline:
"""Pipeline FAST → SMART → THINK pour la localisation d'éléments UI.
Chaque appel à locate() suit la cascade :
1. FAST : détection RF-DETR + OCR enrichissement (~120ms+1s)
2. SMART : matching texte/type/position/voisins (< 1ms)
3. THINK : VLM arbitre si score insuffisant (~3-5s)
4. Fallback : ancien pipeline si tout échoue
"""
def __init__(
self,
confidence_direct: float = 0.90,
confidence_think: float = 0.60,
enable_think: bool = True,
enable_learning: bool = True,
):
self.confidence_direct = confidence_direct
self.confidence_think = confidence_think
self.enable_think = enable_think
self.enable_learning = enable_learning
self._detector = FastDetector()
self._matcher = SmartMatcher()
self._arbiter = ThinkArbiter()
self._signatures = SignatureStore()
self._fallback_pipeline = None
@classmethod
def get_instance(cls) -> FastSmartThinkPipeline:
"""Retourne l'instance singleton."""
global _instance
if _instance is None:
with _instance_lock:
if _instance is None:
_instance = cls()
return _instance
def set_fallback_pipeline(self, pipeline) -> None:
"""Configure l'ancien pipeline comme safety net."""
self._fallback_pipeline = pipeline
# ------------------------------------------------------------------
# API principale
# ------------------------------------------------------------------
def locate(
self,
target: GroundingTarget,
screenshot_pil=None,
phash: str = "",
window_title: str = "",
) -> Optional[GroundingResult]:
"""Localise un élément UI via la cascade FAST → SMART → THINK.
Args:
target: Ce qu'on cherche (texte, description, bbox d'origine).
screenshot_pil: Image PIL. Si None, capture via mss.
phash: Hash perceptuel pour le cache.
window_title: Titre de la fenêtre active.
Returns:
GroundingResult compatible avec le pipeline existant, ou None.
"""
t0 = time.time()
# --- FAST : détecter tous les éléments ---
snapshot = self._detector.detect(
screenshot_pil=screenshot_pil,
phash=phash,
window_title=window_title,
)
if not snapshot.elements:
print(f"⚡ [Pipeline] FAST : aucun élément détecté")
return self._try_fallback(target)
# --- Lookup signature apprise ---
target_key = SignatureStore.make_target_key(
target.text or "", target.description or ""
)
screen_ctx = SignatureStore.make_screen_context(
window_title, snapshot.resolution
)
signature = self._signatures.lookup(target_key, screen_ctx)
# --- SMART : matcher avec la cible ---
candidate = self._matcher.match(snapshot, target, signature)
if candidate:
dt = (time.time() - t0) * 1000
# Score suffisant → action directe
if candidate.score >= self.confidence_direct:
print(f"✅ [Pipeline] FAST→SMART direct : '{candidate.element.ocr_text}' "
f"score={candidate.score:.3f} ({candidate.method}) "
f"→ ({candidate.element.center[0]}, {candidate.element.center[1]}) "
f"en {dt:.0f}ms")
# Apprentissage
if self.enable_learning:
self._signatures.record_success(
target_key, screen_ctx,
candidate.element, candidate.score,
)
return GroundingResult(
x=candidate.element.center[0],
y=candidate.element.center[1],
method=f"fast_{candidate.method}",
confidence=candidate.score,
time_ms=dt,
)
# Score moyen → demander au VLM de confirmer
if candidate.score >= self.confidence_think and self.enable_think:
print(f"🤔 [Pipeline] SMART score={candidate.score:.3f} — THINK pour confirmer")
think_result = self._arbiter.arbitrate(
target,
candidates=[candidate],
screenshot_pil=screenshot_pil or snapshot.elements[0] if False else screenshot_pil,
)
dt = (time.time() - t0) * 1000
if think_result:
# VLM a confirmé
if self.enable_learning:
self._signatures.record_success(
target_key, screen_ctx,
candidate.element, think_result.confidence,
)
return GroundingResult(
x=think_result.x, y=think_result.y,
method="smart_think_confirmed",
confidence=think_result.confidence,
time_ms=dt,
)
# --- THINK : score trop bas ou pas de candidat → VLM cherche seul ---
if self.enable_think:
score_info = f"score={candidate.score:.3f}" if candidate else "aucun candidat"
print(f"🤔 [Pipeline] {score_info} — THINK recherche complète")
think_result = self._arbiter.arbitrate(
target, candidates=[], screenshot_pil=screenshot_pil,
)
dt = (time.time() - t0) * 1000
if think_result:
return GroundingResult(
x=think_result.x, y=think_result.y,
method="think_vlm",
confidence=think_result.confidence,
time_ms=dt,
)
# --- Fallback : ancien pipeline ---
return self._try_fallback(target)
# ------------------------------------------------------------------
# Fallback
# ------------------------------------------------------------------
def _try_fallback(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Tente l'ancien pipeline en dernier recours."""
if self._fallback_pipeline is None:
print(f"❌ [Pipeline] Aucune méthode n'a trouvé '{target.text}'")
return None
print(f"⚠️ [Pipeline] Fallback ancien pipeline pour '{target.text}'")
try:
return self._fallback_pipeline.locate(target)
except Exception as ex:
print(f"⚠️ [Pipeline] Fallback échoué: {ex}")
return None

View File

@@ -0,0 +1,81 @@
"""
core/grounding/fast_types.py — Structures de données pour le pipeline FAST→SMART→THINK
Utilisées exclusivement par le pipeline de localisation rapide.
Compatibles avec GroundingTarget/GroundingResult existants via conversion.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
@dataclass
class DetectedUIElement:
"""Élément UI détecté par le layer FAST (RF-DETR) puis enrichi par OCR."""
id: int
bbox: Tuple[int, int, int, int] # (x1, y1, x2, y2) pixels absolus
center: Tuple[int, int] # (cx, cy)
confidence: float # confidence détecteur (0-1)
element_type: str = "element" # "button", "input", "icon", "text", "element"
ocr_text: str = "" # texte OCR extrait de la région
neighbors: List[str] = field(default_factory=list) # textes des éléments proches
relative_position: str = "" # "top_left", "center", "bottom_right", etc.
@property
def width(self) -> int:
return self.bbox[2] - self.bbox[0]
@property
def height(self) -> int:
return self.bbox[3] - self.bbox[1]
@property
def area(self) -> int:
return self.width * self.height
@dataclass
class ScreenSnapshot:
"""État complet de l'écran à un instant t — sortie du layer FAST."""
elements: List[DetectedUIElement]
ocr_words: List[Dict[str, Any]] # mots OCR bruts [{text, bbox}]
resolution: Tuple[int, int] # (width, height)
window_title: str = ""
phash: str = ""
detection_time_ms: float = 0.0
ocr_time_ms: float = 0.0
total_time_ms: float = 0.0
@dataclass
class MatchCandidate:
"""Résultat du matching SMART pour un élément candidat."""
element: DetectedUIElement
score: float # score combiné (0-1)
score_detail: Dict[str, float] = field(default_factory=dict)
method: str = "" # "exact_text", "fuzzy_text", "position", etc.
@dataclass
class LocateResult:
"""Résultat final du pipeline FAST→SMART→THINK."""
x: int
y: int
confidence: float
method: str # "fast_exact", "fast_fuzzy", "smart_vote", "think_vlm"
time_ms: float
tier: str = "fast" # "fast", "smart", "think"
element: Optional[DetectedUIElement] = None
candidates_count: int = 0
def to_grounding_result(self):
"""Conversion vers GroundingResult pour compatibilité."""
from core.grounding.target import GroundingResult
return GroundingResult(
x=self.x, y=self.y,
method=self.method,
confidence=self.confidence,
time_ms=self.time_ms,
)

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Worker InfiGUI — process indépendant, communication par fichiers.
Charge le modèle, surveille /tmp/infigui_request.json, infère, écrit /tmp/infigui_response.json.
Lancement :
cd ~/ai/rpa_vision_v3
.venv/bin/python3 -m core.grounding.infigui_worker
"""
import json
import math
import os
import re
import sys
import time
import gc
import warnings
warnings.filterwarnings("ignore")
import torch
REQUEST_FILE = "/tmp/infigui_request.json"
RESPONSE_FILE = "/tmp/infigui_response.json"
READY_FILE = "/tmp/infigui_ready"
def load_model():
"""Charge InfiGUI-G1-3B en 4-bit NF4."""
torch.cuda.empty_cache()
gc.collect()
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
model_id = "InfiX-ai/InfiGUI-G1-3B"
print(f"[infigui-worker] Chargement {model_id}...")
bnb = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,
)
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model_id, quantization_config=bnb, device_map={"": "cuda:0"},
)
model.eval()
processor = AutoProcessor.from_pretrained(
model_id, padding_side="left",
min_pixels=100 * 28 * 28, max_pixels=5600 * 28 * 28,
)
vram = torch.cuda.memory_allocated() / 1e9
print(f"[infigui-worker] Prêt — VRAM: {vram:.2f}GB")
# Signal "prêt"
with open(READY_FILE, "w") as f:
f.write(f"ready {vram:.2f}GB")
return model, processor
def infer(model, processor, req):
"""Fait une inférence.
Modes :
- texte seul (target/description) : grounding classique
- fusionné (anchor_image_path présent) : on passe en plus le crop d'ancre
comme image de référence et le modèle doit retrouver cet élément sur
le screenshot. Évite la double passe describe→ground.
"""
from PIL import Image
from qwen_vl_utils import process_vision_info
target = req.get("target", "")
description = req.get("description", "")
label = f"{target}{description}" if description else target
# Image principale (screenshot complet)
image_path = req.get("image_path", "")
if image_path and os.path.exists(image_path):
img = Image.open(image_path).convert("RGB")
else:
import mss
with mss.mss() as sct:
grab = sct.grab(sct.monitors[0])
img = Image.frombytes("RGB", grab.size, grab.bgra, "raw", "BGRX")
# Image d'ancre (optionnelle) — mode fusionné describe+ground
anchor_image_path = req.get("anchor_image_path", "")
anchor_img = None
if anchor_image_path and os.path.exists(anchor_image_path):
anchor_img = Image.open(anchor_image_path).convert("RGB")
if not label.strip() and anchor_img is None:
return {"x": None, "y": None, "error": "target ou anchor_image requis"}
W, H = img.size
factor = 28
rH = max(factor, round(H / factor) * factor)
rW = max(factor, round(W / factor) * factor)
system = (
"You FIRST think about the reasoning process as an internal monologue "
"and then provide the final answer.\n"
"The reasoning process MUST BE enclosed within <think> </think> tags."
)
# Construction du prompt selon le mode
if anchor_img is not None:
# Mode fusionné : Image1 = crop d'ancre, Image2 = screenshot
hint = f' Hint: this element looks like "{label}".' if label.strip() else ""
user_text = (
f"The first image is a small crop of a UI element captured previously. "
f"The second image is the current screen ({rW}x{rH}).{hint}\n"
f"Locate on the second image the UI element that visually matches the first image. "
f"Output the coordinates using JSON format: "
f'[{{"point_2d": [x, y]}}, ...]'
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": [
{"type": "image", "image": anchor_img},
{"type": "image", "image": img},
{"type": "text", "text": user_text},
]},
]
else:
# Mode classique : texte seul
user_text = (
f'The screen\'s resolution is {rW}x{rH}.\n'
f'Locate the UI element(s) for "{label}", '
f'output the coordinates using JSON format: '
f'[{{"point_2d": [x, y]}}, ...]'
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": [
{"type": "image", "image": img},
{"type": "text", "text": user_text},
]},
]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(
text=[text], images=image_inputs, videos=video_inputs,
padding=True, return_tensors="pt",
).to(model.device)
t0 = time.time()
with torch.no_grad():
gen = model.generate(**inputs, max_new_tokens=512)
infer_ms = (time.time() - t0) * 1000
trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, gen)]
raw = processor.batch_decode(
trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False,
)[0].strip()
mode_str = "fused" if anchor_img is not None else "text"
print(f"[infigui-worker] [{mode_str}] '{label[:40]}' ({infer_ms:.0f}ms)")
# Parser JSON point_2d
json_part = raw.split("</think>")[-1] if "</think>" in raw else raw
json_part = json_part.replace("```json", "").replace("```", "").strip()
px, py = None, None
try:
parsed = json.loads(json_part)
if isinstance(parsed, list) and len(parsed) > 0:
pt = parsed[0].get("point_2d", [])
if len(pt) >= 2:
px = int(pt[0] * W / rW)
py = int(pt[1] * H / rH)
except json.JSONDecodeError:
m = re.search(r'"point_2d"\s*:\s*\[(\d+),\s*(\d+)\]', raw)
if m:
px = int(int(m.group(1)) * W / rW)
py = int(int(m.group(2)) * H / rH)
return {
"x": px, "y": py,
"method": "infigui",
"confidence": 0.90 if px else 0.0,
"time_ms": round(infer_ms, 1),
}
def main():
"""Mode one-shot : lit une requête sur stdin, infère, écrit le résultat sur stdout."""
# Lire la requête
input_data = sys.stdin.read().strip()
if not input_data:
print(json.dumps({"x": None, "y": None, "error": "pas de requête"}))
return
try:
req = json.loads(input_data)
except json.JSONDecodeError:
print(json.dumps({"x": None, "y": None, "error": "JSON invalide"}))
return
model, processor = load_model()
result = infer(model, processor, req)
print(json.dumps(result))
if __name__ == "__main__":
main()

190
core/grounding/pipeline.py Normal file
View File

@@ -0,0 +1,190 @@
"""
core/grounding/pipeline.py — Pipeline de grounding en cascade
Orchestre les methodes de localisation dans l'ordre :
1. Template matching (TemplateMatcher, local, ~80ms)
2. OCR (docTR via input_handler, local, ~1s)
3. UI-TARS (HTTP vers serveur grounding, ~3s)
4. Static fallback (coordonnees d'origine du workflow)
Chaque methode est essayee dans l'ordre. Des qu'une reussit, on retourne
le resultat. Cela permet un equilibre entre vitesse (template) et robustesse
(UI-TARS pour les elements qui ont change de position/apparence).
Utilisation :
from core.grounding.pipeline import GroundingPipeline
from core.grounding.target import GroundingTarget
pipeline = GroundingPipeline()
result = pipeline.locate(GroundingTarget(
text="Valider",
description="bouton vert en bas",
template_b64=screenshot_b64,
original_bbox={"x": 100, "y": 200, "width": 80, "height": 30},
))
if result:
print(f"Trouve a ({result.x}, {result.y}) via {result.method}")
"""
from __future__ import annotations
import time
from typing import Optional
from core.grounding.target import GroundingTarget, GroundingResult
class GroundingPipeline:
"""Pipeline de localisation en cascade : template -> OCR -> UI-TARS -> static."""
def __init__(self, template_threshold: float = 0.75, enable_uitars: bool = True):
self.template_threshold = template_threshold
self.enable_uitars = enable_uitars
def locate(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Localise un element UI en essayant les methodes en cascade.
Args:
target: description de l'element a localiser
Returns:
GroundingResult ou None si aucune methode ne trouve l'element
"""
t0 = time.time()
# --- Methode 1 : Template matching (~80ms) ---
result = self._try_template(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
# --- Methode 2 : OCR texte (~1s) ---
result = self._try_ocr(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
# --- Methode 3 : UI-TARS via serveur HTTP (~3s) ---
if self.enable_uitars:
result = self._try_uitars(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
# --- Methode 4 : Fallback statique ---
result = self._try_static(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
print(f"[GroundingPipeline] ECHEC: '{target.text}' introuvable "
f"(toutes methodes epuisees, {(time.time() - t0) * 1000:.0f}ms)")
return None
# ------------------------------------------------------------------
# Methodes individuelles
# ------------------------------------------------------------------
def _try_template(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Template matching — rapide, exact, mais sensible aux changements visuels."""
if not target.template_b64:
return None
try:
from core.grounding.template_matcher import TemplateMatcher
matcher = TemplateMatcher(threshold=self.template_threshold)
match = matcher.match_screen(anchor_b64=target.template_b64)
if match:
print(f"[GroundingPipeline/template] score={match.score:.3f} "
f"pos=({match.x},{match.y}) ({match.time_ms:.0f}ms)")
return GroundingResult(
x=match.x,
y=match.y,
method='template',
confidence=match.score,
time_ms=match.time_ms,
)
else:
diag = matcher.match_screen_diagnostic(anchor_b64=target.template_b64)
print(f"[GroundingPipeline/template] pas de match — best={diag}")
except Exception as e:
print(f"[GroundingPipeline/template] ERREUR: {e}")
return None
def _try_ocr(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""OCR : cherche le texte cible sur l'ecran via docTR."""
if not target.text:
return None
try:
from core.execution.input_handler import _grounding_ocr
bbox = target.original_bbox if target.original_bbox else None
result = _grounding_ocr(target.text, anchor_bbox=bbox)
if result:
print(f"[GroundingPipeline/OCR] '{target.text}' -> ({result['x']}, {result['y']})")
return GroundingResult(
x=result['x'],
y=result['y'],
method='ocr',
confidence=result.get('confidence', 0.80),
time_ms=result.get('time_ms', 0),
)
else:
print(f"[GroundingPipeline/OCR] '{target.text}' non trouve")
except Exception as e:
print(f"[GroundingPipeline/OCR] ERREUR: {e}")
return None
def _try_uitars(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""UI-TARS via serveur HTTP — robust, gere les changements de layout."""
if not target.text and not target.description:
return None
try:
from core.grounding.ui_tars_grounder import UITarsGrounder
grounder = UITarsGrounder.get_instance()
result = grounder.ground(
target_text=target.text,
target_description=target.description,
)
if result:
print(f"[GroundingPipeline/UI-TARS] ({result.x}, {result.y}) "
f"conf={result.confidence:.2f} ({result.time_ms:.0f}ms)")
return result
else:
print(f"[GroundingPipeline/UI-TARS] pas de resultat")
except Exception as e:
print(f"[GroundingPipeline/UI-TARS] ERREUR: {e}")
return None
def _try_static(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Fallback : coordonnees d'origine du workflow (centre du bounding box)."""
bbox = target.original_bbox
if not bbox:
return None
w = bbox.get('width', 0)
h = bbox.get('height', 0)
if not w or not h:
return None
x = int(bbox.get('x', 0) + w / 2)
y = int(bbox.get('y', 0) + h / 2)
print(f"[GroundingPipeline/static] fallback ({x}, {y}) "
f"depuis bbox {bbox}")
return GroundingResult(
x=x,
y=y,
method='static_fallback',
confidence=0.30,
time_ms=0.0,
)

113
core/grounding/server.py Normal file
View File

@@ -0,0 +1,113 @@
"""Serveur grounding minimaliste — Flask single-thread, même contexte CUDA."""
import base64, io, json, math, os, re, time, gc
import torch
from flask import Flask, request, jsonify
from PIL import Image
app = Flask(__name__)
MODEL_ID = os.environ.get("GROUNDING_MODEL", "InfiX-ai/InfiGUI-G1-3B")
MIN_PIXELS = 100 * 28 * 28
MAX_PIXELS = 5600 * 28 * 28
_model = None
_processor = None
def _smart_resize(h, w, factor=28):
h_bar = max(factor, round(h/factor)*factor)
w_bar = max(factor, round(w/factor)*factor)
if h_bar*w_bar > MAX_PIXELS:
beta = math.sqrt((h*w)/MAX_PIXELS)
h_bar = math.floor(h/beta/factor)*factor
w_bar = math.floor(w/beta/factor)*factor
elif h_bar*w_bar < MIN_PIXELS:
beta = math.sqrt(MIN_PIXELS/(h*w))
h_bar = math.ceil(h*beta/factor)*factor
w_bar = math.ceil(w*beta/factor)*factor
return h_bar, w_bar
def load_model():
global _model, _processor
if _model is not None:
return
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
torch.cuda.empty_cache(); gc.collect()
print(f"[grounding] Chargement {MODEL_ID}...")
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_ID, quantization_config=bnb, device_map="auto")
_model.eval()
_processor = AutoProcessor.from_pretrained(MODEL_ID, min_pixels=MIN_PIXELS, max_pixels=MAX_PIXELS, padding_side="left")
print(f"[grounding] Prêt — VRAM: {torch.cuda.memory_allocated()/1e9:.2f}GB")
@app.route('/health')
def health():
return jsonify({"status": "ok", "model": MODEL_ID, "model_loaded": _model is not None,
"cuda_available": torch.cuda.is_available(),
"vram_allocated_gb": round(torch.cuda.memory_allocated()/1e9, 2)})
@app.route('/ground', methods=['POST'])
def ground():
if _model is None:
return jsonify({"error": "Modèle pas chargé"}), 503
from qwen_vl_utils import process_vision_info
data = request.json
target = data.get('target_text', '')
desc = data.get('target_description', '')
label = f"{target}{desc}" if desc else target
if not label.strip():
return jsonify({"error": "target_text requis"}), 400
# Image
if data.get('image_b64'):
raw = data['image_b64'].split(',')[1] if ',' in data['image_b64'] else data['image_b64']
img = Image.open(io.BytesIO(base64.b64decode(raw))).convert('RGB')
else:
import mss
with mss.mss() as sct:
grab = sct.grab(sct.monitors[0])
img = Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX')
W, H = img.size
rH, rW = _smart_resize(H, W)
user_text = f'The screen\'s resolution is {rW}x{rH}.\nLocate the UI element(s) for "{label}", output the coordinates using JSON format: [{{"point_2d": [x, y]}}, ...]'
system = "You FIRST think about the reasoning process as an internal monologue and then provide the final answer.\nThe reasoning process MUST BE enclosed within <think> </think> tags."
messages = [{"role": "system", "content": system},
{"role": "user", "content": [{"type": "image", "image": img}, {"type": "text", "text": user_text}]}]
text = _processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, video_inputs = process_vision_info(messages)
inputs = _processor(text=[text], images=image_inputs, videos=video_inputs, padding=True, return_tensors="pt").to(_model.device)
t0 = time.time()
with torch.no_grad():
gen = _model.generate(**inputs, max_new_tokens=512)
infer_ms = (time.time()-t0)*1000
trimmed = [o[len(i):] for i,o in zip(inputs.input_ids, gen)]
raw = _processor.batch_decode(trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False)[0].strip()
print(f"[grounding] '{label[:40]}'{raw[:100]} ({infer_ms:.0f}ms)")
# Parser JSON point_2d
json_part = raw.split("</think>")[-1] if "</think>" in raw else raw
json_part = json_part.replace("```json","").replace("```","").strip()
px, py = None, None
try:
parsed = json.loads(json_part)
if isinstance(parsed, list) and len(parsed) > 0:
pt = parsed[0].get("point_2d", [])
if len(pt) >= 2:
px, py = int(pt[0]*W/rW), int(pt[1]*H/rH)
except json.JSONDecodeError:
m = re.search(r'"point_2d"\s*:\s*\[(\d+),\s*(\d+)\]', raw)
if m:
px, py = int(int(m.group(1))*W/rW), int(int(m.group(2))*H/rH)
return jsonify({"x": px, "y": py, "method": "infigui", "confidence": 0.90 if px else 0.0,
"time_ms": round(infer_ms, 1), "raw_output": raw[:300]})
if __name__ == '__main__':
load_model()
app.run(host='0.0.0.0', port=8200, threaded=False)

View File

@@ -0,0 +1,156 @@
"""
core/grounding/shadow_learning_hook.py — Hook d'apprentissage Shadow
Connecte le ShadowObserver au SignatureStore : chaque clic observé pendant
une session Shadow enrichit la base de signatures d'éléments.
L'humain clique quelque part → on détecte quel élément UI est sous le clic →
on stocke sa signature (texte, type, position, voisins) pour le replay.
Ce module est un HOOK optionnel — il ne modifie pas le ShadowObserver,
il s'y branche via callback.
Utilisation :
from core.grounding.shadow_learning_hook import ShadowLearningHook
hook = ShadowLearningHook()
# Dans le ShadowObserver ou l'API de capture :
hook.on_click_observed(
click_x=542, click_y=318,
screenshot_pil=screen,
window_title="Bloc-notes",
target_label="Bouton Valider",
)
"""
from __future__ import annotations
import threading
import time
from typing import Any, Dict, Optional
from core.grounding.element_signature import SignatureStore
from core.grounding.fast_types import DetectedUIElement
class ShadowLearningHook:
"""Hook d'apprentissage pour le mode Shadow.
À chaque clic humain observé, détecte l'élément sous le clic
et enrichit le SignatureStore.
"""
def __init__(self, signature_store: Optional[SignatureStore] = None):
self._store = signature_store or SignatureStore()
self._detector = None # Lazy load pour ne pas charger RF-DETR au startup
self._lock = threading.Lock()
def on_click_observed(
self,
click_x: int,
click_y: int,
screenshot_pil: Optional[Any] = None,
window_title: str = "",
target_label: str = "",
target_description: str = "",
) -> Optional[Dict[str, Any]]:
"""Appelé quand un clic humain est observé pendant le Shadow.
Args:
click_x, click_y: Position du clic (pixels écran).
screenshot_pil: Capture d'écran PIL au moment du clic.
window_title: Titre de la fenêtre active.
target_label: Label de l'étape (si connu).
target_description: Description de l'élément (si connue).
Returns:
Dict avec la signature créée/enrichie, ou None si échec.
"""
t0 = time.time()
try:
# Lazy load du détecteur
if self._detector is None:
from core.grounding.fast_detector import FastDetector
self._detector = FastDetector()
# Détecter les éléments sur l'écran
snapshot = self._detector.detect(screenshot_pil=screenshot_pil)
if not snapshot.elements:
print(f"📝 [Shadow/learn] Aucun élément détecté à ({click_x}, {click_y})")
return None
# Trouver l'élément sous le clic
clicked_element = self._find_element_at(click_x, click_y, snapshot.elements)
if clicked_element is None:
print(f"📝 [Shadow/learn] Aucun élément sous ({click_x}, {click_y})")
return None
# Construire la clé de la cible
target_key = SignatureStore.make_target_key(
target_label or clicked_element.ocr_text,
target_description,
)
screen_ctx = SignatureStore.make_screen_context(
window_title, snapshot.resolution,
)
# Enregistrer la signature
self._store.record_success(
target_key=target_key,
screen_context=screen_ctx,
element=clicked_element,
confidence=1.0, # L'humain a cliqué → confiance maximale
)
dt = (time.time() - t0) * 1000
print(f"📝 [Shadow/learn] Signature '{clicked_element.ocr_text}' "
f"type={clicked_element.element_type} "
f"pos={clicked_element.relative_position} "
f"voisins={clicked_element.neighbors[:3]} ({dt:.0f}ms)")
return {
"target_key": target_key,
"text": clicked_element.ocr_text,
"element_type": clicked_element.element_type,
"relative_position": clicked_element.relative_position,
"neighbors": clicked_element.neighbors,
"center": clicked_element.center,
}
except Exception as e:
print(f"⚠️ [Shadow/learn] Erreur: {e}")
return None
@staticmethod
def _find_element_at(
x: int, y: int,
elements: list,
margin: int = 20,
) -> Optional[DetectedUIElement]:
"""Trouve l'élément dont la bbox contient le point (x, y).
Si aucun match exact, prend le plus proche dans un rayon de `margin` pixels.
"""
# Match exact : le clic est dans la bbox
for elem in elements:
x1, y1, x2, y2 = elem.bbox
if x1 <= x <= x2 and y1 <= y <= y2:
return elem
# Match par proximité : le clic est proche du centre
best_elem = None
best_dist = float('inf')
for elem in elements:
dx = abs(elem.center[0] - x)
dy = abs(elem.center[1] - y)
dist = (dx**2 + dy**2) ** 0.5
if dist < margin and dist < best_dist:
best_dist = dist
best_elem = elem
return best_elem

View File

@@ -0,0 +1,263 @@
"""
core/grounding/smart_matcher.py — Layer SMART : matching déterministe/probabiliste
Étant donné un ScreenSnapshot (tous les éléments détectés) et un GroundingTarget
(ce qu'on cherche), trouve l'élément correspondant avec un score de confiance.
Pipeline de matching (court-circuit au premier match haute confiance) :
1. Texte exact (2ms) → score 0.95
2. Texte fuzzy ratio (5ms) → score 0.70-0.90
3. Type + position (2ms) → bonus/malus
4. Voisins contextuels (5ms) → bonus
5. Score combiné → MatchCandidate
Utilisation :
from core.grounding.smart_matcher import SmartMatcher
from core.grounding.fast_types import ScreenSnapshot
from core.grounding.target import GroundingTarget
matcher = SmartMatcher()
candidate = matcher.match(snapshot, GroundingTarget(text="Valider"))
if candidate and candidate.score >= 0.90:
print(f"Match direct : ({candidate.element.center}) score={candidate.score}")
"""
from __future__ import annotations
import re
from difflib import SequenceMatcher
from typing import Dict, List, Optional
from core.grounding.fast_types import DetectedUIElement, MatchCandidate, ScreenSnapshot
from core.grounding.target import GroundingTarget
class SmartMatcher:
"""Matching intelligent entre une cible et les éléments détectés.
Combine plusieurs signaux (texte, type, position, voisins) en un score
de confiance unique pour chaque candidat.
"""
def __init__(
self,
weight_text: float = 0.50,
weight_type: float = 0.10,
weight_position: float = 0.15,
weight_neighbors: float = 0.25,
):
self.w_text = weight_text
self.w_type = weight_type
self.w_position = weight_position
self.w_neighbors = weight_neighbors
def match(
self,
snapshot: ScreenSnapshot,
target: GroundingTarget,
signature: Optional[Dict] = None,
) -> Optional[MatchCandidate]:
"""Trouve le MEILLEUR élément correspondant à la cible.
Returns:
Le MatchCandidate avec le score le plus élevé, ou None si aucun match.
"""
candidates = self.match_all(snapshot, target, signature)
if not candidates:
return None
return candidates[0]
def match_all(
self,
snapshot: ScreenSnapshot,
target: GroundingTarget,
signature: Optional[Dict] = None,
) -> List[MatchCandidate]:
"""Trouve TOUS les candidats triés par score décroissant.
Args:
snapshot: État de l'écran (éléments détectés + OCR).
target: Ce qu'on cherche (texte, description, bbox d'origine).
signature: Signature apprise (optionnel, enrichit le matching).
Returns:
Liste de MatchCandidate triée par score décroissant.
"""
if not snapshot.elements:
return []
target_text = (target.text or "").strip()
target_desc = (target.description or "").strip()
search_text = target_text or target_desc
if not search_text:
return []
candidates = []
search_lower = self._normalize(search_text)
for elem in snapshot.elements:
score_detail: Dict[str, float] = {}
method = ""
# --- 1. Score texte ---
text_score = self._score_text(search_lower, elem.ocr_text)
score_detail["text"] = text_score
if text_score >= 0.95:
method = "exact_text"
elif text_score >= 0.70:
method = "fuzzy_text"
# --- 2. Score type (si signature connue) ---
type_score = 0.5 # neutre par défaut
if signature and signature.get("element_type"):
if elem.element_type == signature["element_type"]:
type_score = 1.0
elif elem.element_type == "element":
type_score = 0.5 # non classifié, neutre
else:
type_score = 0.2
score_detail["type"] = type_score
# --- 3. Score position (si bbox d'origine connue) ---
position_score = 0.5 # neutre
if target.original_bbox:
position_score = self._score_position(
elem.center, target.original_bbox,
snapshot.resolution[0], snapshot.resolution[1],
)
elif signature and signature.get("relative_position"):
if elem.relative_position == signature["relative_position"]:
position_score = 0.9
else:
position_score = 0.3
score_detail["position"] = position_score
# --- 4. Score voisins (si signature connue) ---
neighbor_score = 0.5 # neutre
if signature and signature.get("neighbors"):
neighbor_score = self._score_neighbors(
elem.neighbors, signature["neighbors"]
)
score_detail["neighbors"] = neighbor_score
# --- Score combiné ---
combined = (
self.w_text * text_score
+ self.w_type * type_score
+ self.w_position * position_score
+ self.w_neighbors * neighbor_score
)
# Seuil minimum : pas de candidat si le texte ne matche pas du tout
if text_score < 0.30:
continue
if not method:
method = "combined"
candidates.append(MatchCandidate(
element=elem,
score=combined,
score_detail=score_detail,
method=method,
))
# Trier par score décroissant
candidates.sort(key=lambda c: c.score, reverse=True)
return candidates
# ------------------------------------------------------------------
# Scoring texte
# ------------------------------------------------------------------
def _score_text(self, search: str, ocr_text: str) -> float:
"""Score de similarité textuelle (0-1)."""
if not ocr_text:
return 0.0
ocr_lower = self._normalize(ocr_text)
# Match exact
if search == ocr_lower:
return 1.0
# Inclusion (l'un contient l'autre)
if search in ocr_lower or ocr_lower in search:
overlap = min(len(search), len(ocr_lower))
total = max(len(search), len(ocr_lower))
if total > 0:
return 0.70 + 0.25 * (overlap / total)
# Fuzzy matching (SequenceMatcher, standard library)
ratio = SequenceMatcher(None, search, ocr_lower).ratio()
if ratio >= 0.60:
return 0.50 + 0.40 * ratio
return ratio * 0.3
# ------------------------------------------------------------------
# Scoring position
# ------------------------------------------------------------------
@staticmethod
def _score_position(
center: tuple,
original_bbox: dict,
screen_w: int,
screen_h: int,
) -> float:
"""Score de proximité par rapport à la position d'origine (0-1)."""
if not original_bbox:
return 0.5
orig_x = original_bbox.get("x", 0) + original_bbox.get("width", 0) / 2
orig_y = original_bbox.get("y", 0) + original_bbox.get("height", 0) / 2
dx = abs(center[0] - orig_x) / max(screen_w, 1)
dy = abs(center[1] - orig_y) / max(screen_h, 1)
distance_norm = (dx**2 + dy**2) ** 0.5
# distance 0 = score 1.0, distance 0.5 (demi-écran) = score ~0.2
return max(0.0, 1.0 - distance_norm * 2.0)
# ------------------------------------------------------------------
# Scoring voisins
# ------------------------------------------------------------------
@staticmethod
def _score_neighbors(
current_neighbors: List[str],
expected_neighbors: List[str],
) -> float:
"""Score Jaccard sur les ensembles de mots voisins (0-1)."""
if not expected_neighbors:
return 0.5
current_set = {n.lower().strip() for n in current_neighbors if n}
expected_set = {n.lower().strip() for n in expected_neighbors if n}
if not current_set and not expected_set:
return 0.5
intersection = current_set & expected_set
union = current_set | expected_set
if not union:
return 0.5
return len(intersection) / len(union)
# ------------------------------------------------------------------
# Utilitaires
# ------------------------------------------------------------------
@staticmethod
def _normalize(text: str) -> str:
"""Normalise un texte pour la comparaison."""
text = text.lower().strip()
text = re.sub(r'[_\-\./\\]', ' ', text)
text = re.sub(r'\s+', ' ', text)
return text

48
core/grounding/target.py Normal file
View File

@@ -0,0 +1,48 @@
"""
core/grounding/target.py — Types partagés pour le grounding visuel
Dataclasses décrivant une cible à localiser (GroundingTarget) et
le résultat d'une localisation (GroundingResult).
Ces types sont la brique commune pour tous les modules de grounding :
template matching, OCR, VLM, CLIP, etc.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class GroundingTarget:
"""Description d'un élément UI à localiser sur l'écran.
Attributs :
text : texte visible de l'élément (bouton, label, etc.)
description : description sémantique libre (ex: "le bouton Valider en bas à droite")
template_b64 : capture visuelle de l'élément, encodée en base64 PNG/JPEG
original_bbox : position d'origine lors de la capture {x, y, width, height}
"""
text: str = ""
description: str = ""
template_b64: str = ""
original_bbox: Optional[Dict[str, int]] = field(default=None)
@dataclass
class GroundingResult:
"""Résultat d'une localisation d'élément UI.
Attributs :
x : coordonnée X du centre de l'élément trouvé (pixels écran)
y : coordonnée Y du centre de l'élément trouvé (pixels écran)
method : méthode ayant produit le résultat ('template', 'ocr', 'vlm', 'clip', etc.)
confidence : score de confiance [0.0 1.0]
time_ms : temps de recherche en millisecondes
"""
x: int
y: int
method: str
confidence: float
time_ms: float

View File

@@ -0,0 +1,350 @@
"""
core/grounding/template_matcher.py — Template matching centralisé
Fournit une classe TemplateMatcher qui localise une ancre visuelle (image template)
dans un screenshot via cv2.matchTemplate. Supporte single-scale et multi-scale.
Remplace les implémentations dupliquées dans :
- core/execution/observe_reason_act.py (~1348-1375)
- visual_workflow_builder/backend/api_v3/execute.py (~930-963)
- visual_workflow_builder/backend/catalog_routes_v2_vlm.py (~339-381)
- visual_workflow_builder/backend/services/intelligent_executor.py (~131-210)
- core/detection/omniparser_adapter.py (~330)
Utilisation :
from core.grounding import TemplateMatcher, MatchResult
matcher = TemplateMatcher(threshold=0.75)
result = matcher.match_screen(anchor_b64="...")
if result:
print(f"Trouvé à ({result.x}, {result.y}) score={result.score:.3f}")
"""
from __future__ import annotations
import base64
import io
import logging
import time
from dataclasses import dataclass
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
# Imports optionnels — le module se charge même sans cv2/PIL/mss
try:
import cv2
_CV2 = True
except ImportError:
_CV2 = False
try:
import numpy as np
_NP = True
except ImportError:
_NP = False
try:
from PIL import Image
_PIL = True
except ImportError:
_PIL = False
try:
import mss as mss_lib
_MSS = True
except ImportError:
_MSS = False
# ---------------------------------------------------------------------------
# Résultat d'un match
# ---------------------------------------------------------------------------
@dataclass
class MatchResult:
"""Résultat d'un template matching."""
x: int
y: int
score: float
method: str # 'template' | 'template_multiscale'
time_ms: float
scale: float = 1.0 # Échelle à laquelle le meilleur match a été trouvé
# ---------------------------------------------------------------------------
# TemplateMatcher
# ---------------------------------------------------------------------------
class TemplateMatcher:
"""Localise une ancre visuelle dans un screenshot via template matching.
Paramètres :
threshold : score minimum pour accepter un match (défaut 0.75)
multiscale : active le matching multi-échelle (défaut False)
scales : liste d'échelles à tester en mode multi-scale
method : méthode cv2 (défaut cv2.TM_CCOEFF_NORMED)
grayscale : convertir en niveaux de gris avant matching (défaut False)
"""
# Échelles par défaut pour le mode multi-scale, ordonnées par
# probabilité décroissante (1.0 en premier = rapide si ça matche)
DEFAULT_SCALES: List[float] = [1.0, 0.95, 1.05, 0.9, 1.1, 0.85, 1.15, 0.8, 1.2]
def __init__(
self,
threshold: float = 0.75,
multiscale: bool = False,
scales: Optional[List[float]] = None,
grayscale: bool = False,
):
self.threshold = threshold
self.multiscale = multiscale
self.scales = scales or self.DEFAULT_SCALES
self.grayscale = grayscale
# cv2.TM_CCOEFF_NORMED est la méthode utilisée partout dans le projet
self._cv2_method = cv2.TM_CCOEFF_NORMED if _CV2 else None
# ------------------------------------------------------------------
# API publique
# ------------------------------------------------------------------
def match_screen(
self,
anchor_b64: Optional[str] = None,
anchor_pil: Optional["Image.Image"] = None,
screen_pil: Optional["Image.Image"] = None,
) -> Optional[MatchResult]:
"""Cherche l'ancre dans le screenshot courant (ou fourni).
L'ancre peut être passée en base64 ou en PIL Image.
Le screenshot est capturé via mss si non fourni.
Retourne un MatchResult ou None si aucun match >= seuil.
"""
if not (_CV2 and _NP and _PIL):
logger.debug("[TemplateMatcher] cv2/numpy/PIL non disponible")
return None
# --- Préparer l'ancre ---
anchor_img = self._decode_anchor(anchor_b64, anchor_pil)
if anchor_img is None:
return None
# --- Préparer le screenshot ---
if screen_pil is None:
screen_pil = self._capture_screen()
if screen_pil is None:
return None
# --- Convertir en arrays cv2 ---
screen_cv = cv2.cvtColor(np.array(screen_pil), cv2.COLOR_RGB2BGR)
anchor_cv = cv2.cvtColor(np.array(anchor_img), cv2.COLOR_RGB2BGR)
# --- Matching ---
if self.multiscale:
return self._match_multiscale(screen_cv, anchor_cv)
else:
return self._match_single(screen_cv, anchor_cv)
def match_in_region(
self,
region_cv: "np.ndarray",
anchor_cv: "np.ndarray",
threshold: Optional[float] = None,
) -> Optional[MatchResult]:
"""Match dans une région déjà découpée (arrays BGR).
Utilisé par les pipelines qui font leur propre capture/découpe.
"""
if not (_CV2 and _NP):
return None
thr = threshold if threshold is not None else self.threshold
if self.multiscale:
return self._match_multiscale(region_cv, anchor_cv, threshold_override=thr)
else:
return self._match_single(region_cv, anchor_cv, threshold_override=thr)
def match_screen_diagnostic(
self,
anchor_b64: Optional[str] = None,
anchor_pil: Optional["Image.Image"] = None,
screen_pil: Optional["Image.Image"] = None,
) -> str:
"""Retourne un diagnostic textuel (score + position) même sans match."""
if not (_CV2 and _NP and _PIL):
return "cv2/numpy/PIL non dispo"
anchor_img = self._decode_anchor(anchor_b64, anchor_pil)
if anchor_img is None:
return "ancre non décodable"
if screen_pil is None:
screen_pil = self._capture_screen()
if screen_pil is None:
return "capture écran échouée"
screen_cv = cv2.cvtColor(np.array(screen_pil), cv2.COLOR_RGB2BGR)
anchor_cv = cv2.cvtColor(np.array(anchor_img), cv2.COLOR_RGB2BGR)
if anchor_cv.shape[0] >= screen_cv.shape[0] or anchor_cv.shape[1] >= screen_cv.shape[1]:
return f"ancre {anchor_cv.shape[:2]} >= écran {screen_cv.shape[:2]}"
s_img, a_img = self._maybe_grayscale(screen_cv, anchor_cv)
result_tm = cv2.matchTemplate(s_img, a_img, self._cv2_method)
_, max_val, _, max_loc = cv2.minMaxLoc(result_tm)
return f"{max_val:.3f} pos={max_loc}"
# ------------------------------------------------------------------
# Méthodes internes
# ------------------------------------------------------------------
def _match_single(
self,
screen_cv: "np.ndarray",
anchor_cv: "np.ndarray",
threshold_override: Optional[float] = None,
) -> Optional[MatchResult]:
"""Template matching single-scale."""
threshold = threshold_override if threshold_override is not None else self.threshold
if anchor_cv.shape[0] >= screen_cv.shape[0] or anchor_cv.shape[1] >= screen_cv.shape[1]:
logger.debug("[TemplateMatcher] Ancre plus grande que le screen")
return None
s_img, a_img = self._maybe_grayscale(screen_cv, anchor_cv)
t0 = time.time()
result_tm = cv2.matchTemplate(s_img, a_img, self._cv2_method)
_, max_val, _, max_loc = cv2.minMaxLoc(result_tm)
elapsed_ms = (time.time() - t0) * 1000
logger.debug(
"[TemplateMatcher] score=%.3f pos=%s (%.0fms)",
max_val, max_loc, elapsed_ms,
)
if max_val >= threshold:
cx = max_loc[0] + anchor_cv.shape[1] // 2
cy = max_loc[1] + anchor_cv.shape[0] // 2
return MatchResult(
x=cx,
y=cy,
score=float(max_val),
method='template',
time_ms=elapsed_ms,
scale=1.0,
)
return None
def _match_multiscale(
self,
screen_cv: "np.ndarray",
anchor_cv: "np.ndarray",
threshold_override: Optional[float] = None,
) -> Optional[MatchResult]:
"""Template matching multi-scale."""
threshold = threshold_override if threshold_override is not None else self.threshold
best_score = -1.0
best_loc = None
best_scale = 1.0
best_anchor_shape = anchor_cv.shape
t0 = time.time()
for scale in self.scales:
if scale == 1.0:
scaled = anchor_cv
else:
new_w = int(anchor_cv.shape[1] * scale)
new_h = int(anchor_cv.shape[0] * scale)
if new_w < 8 or new_h < 8:
continue
if new_h >= screen_cv.shape[0] or new_w >= screen_cv.shape[1]:
continue
scaled = cv2.resize(anchor_cv, (new_w, new_h), interpolation=cv2.INTER_AREA)
if scaled.shape[0] >= screen_cv.shape[0] or scaled.shape[1] >= screen_cv.shape[1]:
continue
s_img, a_img = self._maybe_grayscale(screen_cv, scaled)
result_tm = cv2.matchTemplate(s_img, a_img, self._cv2_method)
_, max_val, _, max_loc = cv2.minMaxLoc(result_tm)
if max_val > best_score:
best_score = max_val
best_loc = max_loc
best_scale = scale
best_anchor_shape = scaled.shape
elapsed_ms = (time.time() - t0) * 1000
logger.debug(
"[TemplateMatcher/multiscale] best_score=%.3f scale=%.2f (%.0fms)",
best_score, best_scale, elapsed_ms,
)
if best_score >= threshold and best_loc is not None:
cx = best_loc[0] + best_anchor_shape[1] // 2
cy = best_loc[1] + best_anchor_shape[0] // 2
return MatchResult(
x=cx,
y=cy,
score=float(best_score),
method='template_multiscale',
time_ms=elapsed_ms,
scale=best_scale,
)
return None
def _maybe_grayscale(
self,
screen: "np.ndarray",
anchor: "np.ndarray",
) -> Tuple["np.ndarray", "np.ndarray"]:
"""Convertit en niveaux de gris si self.grayscale est True."""
if not self.grayscale:
return screen, anchor
s = cv2.cvtColor(screen, cv2.COLOR_BGR2GRAY) if len(screen.shape) == 3 else screen
a = cv2.cvtColor(anchor, cv2.COLOR_BGR2GRAY) if len(anchor.shape) == 3 else anchor
return s, a
@staticmethod
def _decode_anchor(
anchor_b64: Optional[str],
anchor_pil: Optional["Image.Image"],
) -> Optional["Image.Image"]:
"""Décode l'ancre depuis base64 ou retourne le PIL directement."""
if anchor_pil is not None:
return anchor_pil
if anchor_b64 is None:
logger.debug("[TemplateMatcher] Ni anchor_b64 ni anchor_pil fourni")
return None
try:
raw = anchor_b64.split(',')[1] if ',' in anchor_b64 else anchor_b64
data = base64.b64decode(raw)
return Image.open(io.BytesIO(data))
except Exception as e:
logger.debug("[TemplateMatcher] Erreur décodage ancre: %s", e)
return None
@staticmethod
def _capture_screen() -> Optional["Image.Image"]:
"""Capture l'écran complet via mss (moniteur 0 = tous les écrans)."""
if not _MSS:
logger.debug("[TemplateMatcher] mss non disponible")
return None
try:
with mss_lib.mss() as sct:
mon = sct.monitors[0]
grab = sct.grab(mon)
return Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX')
except Exception as e:
logger.debug("[TemplateMatcher] Erreur capture écran: %s", e)
return None

View File

@@ -0,0 +1,103 @@
"""
core/grounding/think_arbiter.py — Layer THINK : VLM arbitre (InfiGUI via subprocess)
Appelé UNIQUEMENT quand le SmartMatcher n'a pas assez confiance.
Utilise le subprocess worker InfiGUI (pas de serveur HTTP).
Utilisation :
from core.grounding.think_arbiter import ThinkArbiter
arbiter = ThinkArbiter()
result = arbiter.arbitrate(target, candidates, screenshot)
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
from core.grounding.fast_types import LocateResult, MatchCandidate
from core.grounding.target import GroundingTarget
class ThinkArbiter:
"""Arbitre VLM — appelle InfiGUI via subprocess worker."""
def __init__(self):
self._grounder = None
def _get_grounder(self):
if self._grounder is None:
from core.grounding.ui_tars_grounder import UITarsGrounder
self._grounder = UITarsGrounder.get_instance()
return self._grounder
@property
def available(self) -> bool:
"""Toujours disponible — le worker se lance à la demande."""
return True
def arbitrate(
self,
target: GroundingTarget,
candidates: List[MatchCandidate],
screenshot_pil: Optional[Any] = None,
) -> Optional[LocateResult]:
"""Demande au VLM de trancher.
Si target.template_b64 est fourni, on bascule en mode fusionné :
le crop est passé comme image de référence à InfiGUI, ce qui évite
une description Ollama qwen2.5vl coûteuse en VRAM.
"""
t0 = time.time()
# Décodage du crop d'ancre si disponible (mode fusionné)
anchor_pil = None
if target.template_b64:
try:
import base64
import io
from PIL import Image
raw_b64 = target.template_b64
if ',' in raw_b64:
raw_b64 = raw_b64.split(',', 1)[1]
anchor_pil = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGB")
except Exception as ex:
print(f"⚠️ [THINK] Décodage anchor échoué: {ex}")
anchor_pil = None
try:
grounder = self._get_grounder()
result = grounder.ground(
target_text=target.text or "",
target_description=target.description or "",
screen_pil=screenshot_pil,
anchor_pil=anchor_pil,
)
dt = (time.time() - t0) * 1000
if result is None:
label = target.text or "<crop>"
print(f"🤔 [THINK] VLM n'a pas trouvé '{label}' ({dt:.0f}ms)")
return None
method = "think_vlm_fused" if anchor_pil is not None else "think_vlm"
locate = LocateResult(
x=result.x,
y=result.y,
confidence=result.confidence,
method=method,
time_ms=dt,
tier="think",
candidates_count=len(candidates),
)
print(f"🤔 [THINK/{method}] ({result.x}, {result.y}) conf={result.confidence:.2f} ({dt:.0f}ms)")
return locate
except Exception as ex:
dt = (time.time() - t0) * 1000
print(f"⚠️ [THINK] Erreur: {ex} ({dt:.0f}ms)")
return None

View File

@@ -0,0 +1,174 @@
"""
core/grounding/title_verifier.py — Vérification post-action par titre de fenêtre
Après chaque action (clic, double-clic), vérifie que la fenêtre active
a changé de manière attendue en lisant le titre via OCR sur un crop
de 45px en haut de l'écran.
Léger (~120ms), non-bloquant (échec = warning + retry, pas stop).
Utilisation :
from core.grounding.title_verifier import TitleVerifier
verifier = TitleVerifier()
title = verifier.read_title(screenshot_pil)
changed = verifier.has_title_changed(title_before, title_after)
"""
from __future__ import annotations
import time
from difflib import SequenceMatcher
from typing import Optional
class TitleVerifier:
"""Vérifie le titre de la fenêtre active via OCR sur crop."""
# Hauteur du crop pour la barre de titre Windows
TITLE_BAR_HEIGHT = 45
def __init__(self):
self._ocr_fn = None # Lazy load
def read_title(self, screenshot_pil) -> str:
"""Lit le titre de la fenêtre active via OCR sur le crop supérieur.
Args:
screenshot_pil: Image PIL du screenshot complet.
Returns:
Texte du titre (peut être vide si OCR échoue).
"""
t0 = time.time()
try:
w, h = screenshot_pil.size
# Crop la barre de titre (45px du haut)
title_crop = screenshot_pil.crop((0, 0, w, min(self.TITLE_BAR_HEIGHT, h)))
# OCR sur le petit crop
ocr_fn = self._get_ocr()
if ocr_fn is None:
return ""
text = ocr_fn(title_crop)
dt = (time.time() - t0) * 1000
# Nettoyer le texte
title = text.strip() if text else ""
if title:
print(f"📋 [TitleVerify] Titre lu: '{title[:60]}' ({dt:.0f}ms)")
return title
except Exception as e:
print(f"⚠️ [TitleVerify] Erreur lecture titre: {e}")
return ""
def has_title_changed(self, title_before: str, title_after: str) -> bool:
"""Vérifie si le titre a changé de manière significative."""
if not title_before and not title_after:
return False
if not title_before or not title_after:
return True # Un des deux est vide = changement
# Comparaison fuzzy — les titres peuvent avoir des variations mineures
ratio = SequenceMatcher(None, title_before.lower(), title_after.lower()).ratio()
return ratio < 0.85 # Changement si < 85% similaire
def verify_action(
self,
screenshot_before,
screenshot_after,
action_type: str,
) -> dict:
"""Vérifie qu'une action a produit l'effet attendu sur le titre.
Args:
screenshot_before: Screenshot PIL avant l'action.
screenshot_after: Screenshot PIL après l'action.
action_type: Type d'action ("double_click", "click", "type", "hotkey").
Returns:
Dict avec success, title_before, title_after, changed.
"""
# Les actions qui ne changent pas le titre
if action_type in ('type_text', 'keyboard_shortcut', 'wait_for_anchor', 'hover'):
return {
'success': True,
'title_before': '',
'title_after': '',
'changed': False,
'reason': f"Action '{action_type}' — vérification titre non requise",
}
title_before = self.read_title(screenshot_before)
title_after = self.read_title(screenshot_after)
changed = self.has_title_changed(title_before, title_after)
# Pour un double-clic (ouverture fichier/dossier), le titre DOIT changer
# Mais seulement si les titres lus sont significatifs (> 3 chars)
# docTR sur un crop 45px dans une VM peut donner du bruit ('o', 'a', etc.)
if action_type in ('double_click_anchor',) and not changed:
if len(title_before) > 3 and len(title_after) > 3:
return {
'success': False,
'title_before': title_before,
'title_after': title_after,
'changed': False,
'reason': f"Double-clic sans changement de titre ('{title_after[:40]}')",
}
# Titres trop courts = bruit OCR, on ne peut pas conclure
return {
'success': True,
'title_before': title_before,
'title_after': title_after,
'changed': False,
'reason': f"Titre trop court pour vérifier ('{title_after}')",
}
# Pour un clic simple, le changement est optionnel
return {
'success': True,
'title_before': title_before,
'title_after': title_after,
'changed': changed,
'reason': 'Titre changé' if changed else 'Titre identique (acceptable)',
}
_easyocr_reader = None # Singleton partagé
def _get_ocr(self):
"""Lazy load de la fonction OCR (EasyOCR prioritaire, fallback docTR)."""
if self._ocr_fn is not None:
return self._ocr_fn
# EasyOCR (rapide, bonne qualité GUI)
try:
import easyocr
import numpy as np
if TitleVerifier._easyocr_reader is None:
TitleVerifier._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
def _easyocr_extract_text(img):
results = TitleVerifier._easyocr_reader.readtext(np.array(img))
return ' '.join(r[1] for r in results if r[1].strip())
self._ocr_fn = _easyocr_extract_text
return self._ocr_fn
except ImportError:
pass
# Fallback docTR
try:
import sys
sys.path.insert(0, 'visual_workflow_builder/backend')
from services.ocr_service import ocr_extract_text
self._ocr_fn = ocr_extract_text
return self._ocr_fn
except ImportError:
return None

View File

@@ -0,0 +1,161 @@
"""
core/grounding/ui_tars_grounder.py — Grounding via script one-shot InfiGUI
Chaque appel lance un subprocess Python qui charge le modèle, infère, et quitte.
Lent (~15s) mais fiable — pas de crash CUDA en process persistant.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
import time
from typing import Optional
from core.grounding.target import GroundingResult
_instance: Optional[UITarsGrounder] = None
_instance_lock = threading.Lock()
class UITarsGrounder:
"""Grounding via script one-shot InfiGUI."""
def __init__(self):
self._lock = threading.Lock()
self._project_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
@classmethod
def get_instance(cls) -> UITarsGrounder:
global _instance
if _instance is None:
with _instance_lock:
if _instance is None:
_instance = cls()
return _instance
@property
def available(self) -> bool:
return True # Toujours disponible — le script se lance à la demande
def ground(
self,
target_text: str = "",
target_description: str = "",
screen_pil=None,
anchor_pil=None,
) -> Optional[GroundingResult]:
"""Localise un élément UI via un script one-shot InfiGUI.
Args:
target_text: nom textuel de la cible (peut être vide si anchor_pil fourni).
target_description: description sémantique libre.
screen_pil: screenshot complet (PIL.Image).
anchor_pil: crop visuel de l'ancre capturée précédemment (PIL.Image).
Si fourni, le worker passe en mode fusionné : Image1=crop, Image2=screen,
"trouve sur l'image 2 l'élément visuel de l'image 1".
"""
t0 = time.time()
try:
with self._lock:
# Sauver l'image principale
image_path = "/tmp/infigui_screen.png"
if screen_pil is not None:
screen_pil.save(image_path)
# Sauver l'image d'ancre (mode fusionné)
anchor_image_path = ""
if anchor_pil is not None:
anchor_image_path = "/tmp/infigui_anchor.png"
anchor_pil.save(anchor_image_path)
# Construire la requête JSON
req = json.dumps({
"target": target_text,
"description": target_description,
"image_path": image_path,
"anchor_image_path": anchor_image_path,
})
mode_str = "fused" if anchor_pil is not None else "text"
label_short = target_text[:30] if target_text else "<crop only>"
print(f"🎯 [InfiGUI] Lancement one-shot [{mode_str}]: '{label_short}'")
# Lancer le script one-shot
# IMPORTANT: depuis un service systemd où le parent a déjà chargé CUDA,
# le subprocess hérite d'un état GPU cassé (No CUDA GPUs available).
# Solutions : start_new_session=True (nouveau cgroup) + forcer
# CUDA_VISIBLE_DEVICES=0 explicitement pour bypass l'héritage parent.
_child_env = {**os.environ}
_child_env["PYTHONDONTWRITEBYTECODE"] = "1"
_child_env["CUDA_VISIBLE_DEVICES"] = "0"
_child_env["NVIDIA_VISIBLE_DEVICES"] = "all"
# Supprimer les variables Python qui pourraient pointer sur l'état parent
_child_env.pop("PYTORCH_NVML_BASED_CUDA_CHECK", None)
result = subprocess.run(
[sys.executable, "-m", "core.grounding.infigui_worker"],
input=req + "\n",
capture_output=True,
text=True,
timeout=60,
cwd=self._project_root,
env=_child_env,
start_new_session=True, # nouveau session group, isole du parent
close_fds=True,
)
if result.returncode != 0:
stderr_lines = (result.stderr or '').strip().split('\n')
# Afficher les dernières lignes significatives du stderr
last_err = [l for l in stderr_lines[-5:] if l.strip()]
print(f"⚠️ [InfiGUI] Script échoué (code {result.returncode})")
for l in last_err:
print(f"{l}")
return None
# Parser la sortie — chercher la ligne JSON de résultat
data = None
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
parsed = json.loads(line)
if "x" in parsed:
data = parsed
except json.JSONDecodeError:
continue
if data is None:
print(f"⚠️ [InfiGUI] Pas de réponse JSON dans la sortie")
return None
dt = (time.time() - t0) * 1000
if data.get("x") is not None:
method_name = "infigui_fused" if anchor_pil is not None else "infigui"
print(f"🎯 [InfiGUI/{method_name}] ({data['x']}, {data['y']}) "
f"conf={data.get('confidence', 0):.2f} ({dt:.0f}ms)")
return GroundingResult(
x=data["x"], y=data["y"],
method=method_name,
confidence=data.get("confidence", 0.90),
time_ms=dt,
)
else:
print(f"⚠️ [InfiGUI] Pas trouvé ({dt:.0f}ms)")
return None
except subprocess.TimeoutExpired:
print(f"⚠️ [InfiGUI] Timeout 60s")
return None
except Exception as e:
print(f"⚠️ [InfiGUI] Erreur: {e}")
return None

View File

View File

@@ -0,0 +1,523 @@
"""
Base de connaissances des patterns d'interface utilisateur.
Donne à Léa des "réflexes natifs" : quand elle reconnaît un pattern UI
connu (dialogue OK/Annuler, menu, barre d'outils), elle sait immédiatement
quoi faire sans avoir besoin de l'apprendre par observation.
Sources :
- GUI-R1 dataset (3K exemples annotés, ritzzai/GUI-R1)
- Patterns Windows/Linux courants
- Conventions UI universelles
Utilisation :
from core.knowledge.ui_patterns import UIPatternLibrary
lib = UIPatternLibrary()
match = lib.find_pattern("Voulez-vous enregistrer ?")
# → {'action': 'click', 'target': 'Enregistrer', 'zone': 'dialog_center', ...}
"""
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@dataclass
class UIPattern:
"""Un pattern d'interface connu."""
name: str
category: str
triggers: List[str]
action: str
target: str
typical_zone: str
typical_bbox: Optional[List[float]] = None
os: str = "any"
confidence: float = 0.9
metadata: Dict[str, Any] = field(default_factory=dict)
# Patterns Windows natifs — réflexes de base
BUILTIN_PATTERNS: List[Dict[str, Any]] = [
# === DIALOGUES DE CONFIRMATION ===
{
"name": "dialog_save",
"category": "dialog",
"triggers": [
"voulez-vous enregistrer", "do you want to save",
"save changes", "enregistrer les modifications",
"enregistrer sous", "save as",
"sauvegarder", "unsaved changes",
],
"action": "click",
"target": "Enregistrer",
"alternatives": ["Save", "Oui", "Yes"],
"typical_zone": "dialog_center",
"typical_bbox": [0.35, 0.55, 0.50, 0.65],
"os": "any",
},
{
"name": "dialog_cancel",
"category": "dialog",
"triggers": [
"annuler", "cancel", "abandonner", "discard",
],
"action": "click",
"target": "Annuler",
"alternatives": ["Cancel", "Non", "No"],
"typical_zone": "dialog_center",
"typical_bbox": [0.50, 0.55, 0.65, 0.65],
"os": "any",
},
{
"name": "dialog_ok",
"category": "dialog",
"triggers": [
"ok", "d'accord", "compris", "information",
"erreur", "error", "warning", "avertissement",
],
"action": "click",
"target": "OK",
"alternatives": ["Fermer", "Close", "Compris"],
"typical_zone": "dialog_center",
"typical_bbox": [0.45, 0.60, 0.55, 0.70],
"os": "any",
},
{
"name": "dialog_yes_no",
"category": "dialog",
"triggers": [
"êtes-vous sûr", "are you sure", "confirmer",
"confirm", "supprimer", "delete",
],
"action": "click",
"target": "Oui",
"alternatives": ["Yes", "Confirmer", "Confirm"],
"typical_zone": "dialog_center",
"typical_bbox": [0.35, 0.60, 0.45, 0.68],
"os": "any",
},
{
"name": "dialog_overwrite",
"category": "dialog",
"triggers": [
"voulez-vous remplacer", "voulez-vous écraser",
"remplacer le fichier", "replace existing",
"fichier existe déjà", "already exists",
"overwrite", "écraser",
],
"action": "click",
"target": "Oui",
"alternatives": ["Yes", "Remplacer", "Replace", "Confirmer"],
"typical_zone": "dialog_center",
"os": "any",
},
{
"name": "dialog_dont_save",
"category": "dialog",
"triggers": [
"ne pas enregistrer", "don't save",
"ne pas sauvegarder", "quitter sans enregistrer",
"discard changes",
],
"action": "click",
"target": "Ne pas enregistrer",
"alternatives": ["Don't Save", "Ne pas sauvegarder", "Non"],
"typical_zone": "dialog_center",
"os": "any",
},
# === NAVIGATION FENÊTRE ===
{
"name": "window_close",
"category": "window",
"triggers": ["fermer la fenêtre", "close window"],
"action": "click",
"target": "X",
"typical_zone": "titlebar",
"typical_bbox": [0.96, 0.0, 1.0, 0.04],
"os": "windows",
},
{
"name": "window_minimize",
"category": "window",
"triggers": ["minimiser", "minimize"],
"action": "click",
"target": "_",
"typical_zone": "titlebar",
"typical_bbox": [0.90, 0.0, 0.94, 0.04],
"os": "windows",
},
{
"name": "window_maximize",
"category": "window",
"triggers": ["maximiser", "maximize", "agrandir"],
"action": "click",
"target": "",
"typical_zone": "titlebar",
"typical_bbox": [0.94, 0.0, 0.96, 0.04],
"os": "windows",
},
# === MENUS ===
{
"name": "menu_file",
"category": "menu",
"triggers": ["menu fichier", "menu file", "ouvrir fichier", "open file"],
"action": "click",
"target": "Fichier",
"alternatives": ["File"],
"typical_zone": "menu_toolbar",
"typical_bbox": [0.0, 0.03, 0.06, 0.06],
"os": "any",
},
{
"name": "menu_edit",
"category": "menu",
"triggers": ["édition", "edit", "modifier"],
"action": "click",
"target": "Édition",
"alternatives": ["Edit"],
"typical_zone": "menu_toolbar",
"typical_bbox": [0.06, 0.03, 0.12, 0.06],
"os": "any",
},
# === FORMULAIRES ===
{
"name": "form_submit",
"category": "form",
"triggers": [
"valider", "submit", "envoyer", "send",
"connexion", "login", "se connecter", "sign in",
],
"action": "click",
"target": "Valider",
"alternatives": ["Submit", "Envoyer", "Connexion", "Login", "OK"],
"typical_zone": "content",
"typical_bbox": [0.35, 0.70, 0.65, 0.80],
"os": "any",
},
{
"name": "form_search",
"category": "form",
"triggers": ["rechercher", "search", "chercher", "find"],
"action": "click",
"target": "Rechercher",
"alternatives": ["Search", "🔍", "Go"],
"typical_zone": "menu_toolbar",
"typical_bbox": [0.30, 0.03, 0.70, 0.06],
"os": "any",
},
# === NAVIGATION WEB ===
{
"name": "cookie_accept",
"category": "popup",
"triggers": [
"accepter les cookies", "accept cookies",
"utilise des cookies", "uses cookies",
"j'accepte", "accept all", "tout accepter",
"consent", "consentement",
],
"action": "click",
"target": "Accepter",
"alternatives": ["Accept", "Accept All", "Tout accepter", "J'accepte"],
"typical_zone": "content",
"typical_bbox": [0.30, 0.80, 0.70, 0.90],
"os": "any",
},
# === RACCOURCIS UNIVERSELS ===
{
"name": "shortcut_save",
"category": "shortcut",
"triggers": ["sauvegarder", "enregistrer", "save"],
"action": "hotkey",
"target": "ctrl+s",
"typical_zone": "keyboard",
"os": "any",
},
{
"name": "shortcut_undo",
"category": "shortcut",
"triggers": ["annuler action", "undo", "défaire"],
"action": "hotkey",
"target": "ctrl+z",
"typical_zone": "keyboard",
"os": "any",
},
{
"name": "shortcut_copy",
"category": "shortcut",
"triggers": ["copier", "copy"],
"action": "hotkey",
"target": "ctrl+c",
"typical_zone": "keyboard",
"os": "any",
},
{
"name": "shortcut_paste",
"category": "shortcut",
"triggers": ["coller", "paste"],
"action": "hotkey",
"target": "ctrl+v",
"typical_zone": "keyboard",
"os": "any",
},
]
class UIPatternLibrary:
"""Bibliothèque de patterns UI connus.
Fournit des "réflexes natifs" à Léa : quand un pattern
est reconnu dans le texte OCR ou le contexte visuel,
elle sait immédiatement quoi faire.
"""
# Chemins par défaut des fichiers de patterns additionnels
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_GUI_R1_PATTERNS_PATH = _PROJECT_ROOT / "data" / "gui_r1_ui_patterns.json"
_LEARNED_PATTERNS_PATH = _PROJECT_ROOT / "data" / "learned_patterns.json"
def __init__(self, extra_patterns_path: Optional[str] = None):
self._patterns: List[UIPattern] = []
self._load_builtin()
# Charger les patterns extraits de GUI-R1 (statiques, générés une fois)
self._load_from_file(str(self._GUI_R1_PATTERNS_PATH))
# Charger les patterns appris par observation Shadow (dynamiques)
self._load_from_file(str(self._LEARNED_PATTERNS_PATH))
# Fichier custom fourni explicitement
if extra_patterns_path:
self._load_from_file(extra_patterns_path)
logger.info(f"UIPatternLibrary: {len(self._patterns)} patterns chargés")
def _load_builtin(self):
for p in BUILTIN_PATTERNS:
self._patterns.append(UIPattern(
name=p["name"],
category=p["category"],
triggers=p["triggers"],
action=p["action"],
target=p["target"],
typical_zone=p.get("typical_zone", "content"),
typical_bbox=p.get("typical_bbox"),
os=p.get("os", "any"),
metadata={
"alternatives": p.get("alternatives", []),
"source": "builtin",
},
))
def _load_from_file(self, path: str):
filepath = Path(path)
if not filepath.exists():
logger.debug(f"Fichier patterns non trouvé (OK si premier lancement): {path}")
return
try:
with open(filepath) as f:
data = json.load(f)
for p in data.get("patterns", []):
# Construire metadata en incluant source/learned_at/gui_r1_id si présents
meta = dict(p.get("metadata", {}))
if "source" in p:
meta["source"] = p["source"]
if "learned_at" in p:
meta["learned_at"] = p["learned_at"]
if "gui_r1_id" in p:
meta["gui_r1_id"] = p["gui_r1_id"]
self._patterns.append(UIPattern(
name=p["name"],
category=p.get("category", "custom"),
triggers=p.get("triggers", []),
action=p.get("action", "click"),
target=p.get("target", ""),
typical_zone=p.get("typical_zone", "content"),
typical_bbox=p.get("typical_bbox"),
os=p.get("os", "any"),
confidence=p.get("confidence", 0.9),
metadata=meta,
))
logger.info(f"Chargé {len(data.get('patterns', []))} patterns depuis {path}")
except Exception as e:
logger.error(f"Erreur chargement patterns: {e}")
def find_pattern(
self,
text: str,
os_filter: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Cherche un pattern UI dans du texte (OCR, titre fenêtre, etc.).
Args:
text: Texte à analyser (peut contenir du bruit OCR)
os_filter: Filtrer par OS ("windows", "linux", None=tous)
Returns:
Dict avec action, target, confidence, etc. ou None
"""
text_lower = text.lower()
best_match = None
best_score = 0
for pattern in self._patterns:
if os_filter and pattern.os not in ("any", os_filter):
continue
score = 0
matched_trigger = None
for trigger in pattern.triggers:
if len(trigger) <= 3:
import re
if re.search(r'\b' + re.escape(trigger) + r'\b', text_lower):
trigger_score = len(trigger) / max(len(text_lower), 1)
if trigger_score > score:
score = trigger_score
matched_trigger = trigger
elif trigger in text_lower:
trigger_score = len(trigger) / max(len(text_lower), 1)
if trigger_score > score:
score = trigger_score
matched_trigger = trigger
if score > best_score and matched_trigger is not None:
best_score = score
best_match = {
"pattern": pattern.name,
"category": pattern.category,
"action": pattern.action,
"target": pattern.target,
"alternatives": pattern.metadata.get("alternatives", []),
"typical_zone": pattern.typical_zone,
"typical_bbox": pattern.typical_bbox,
"confidence": min(pattern.confidence * (1 + score), 1.0),
"matched_trigger": matched_trigger,
"os": pattern.os,
}
return best_match
def find_by_category(self, category: str) -> List[Dict[str, Any]]:
"""Retourne tous les patterns d'une catégorie."""
return [
{
"name": p.name,
"action": p.action,
"target": p.target,
"triggers": p.triggers,
"typical_zone": p.typical_zone,
}
for p in self._patterns
if p.category == category
]
def get_dialog_handler(self, dialog_text: str) -> Optional[Dict[str, Any]]:
"""Raccourci : cherche un pattern de dialogue."""
match = self.find_pattern(dialog_text)
if match and match["category"] == "dialog":
return match
return self.find_pattern(dialog_text)
def add_pattern(self, pattern_dict: Dict[str, Any]):
"""Ajoute un pattern dynamiquement (ex: appris par observation)."""
self._patterns.append(UIPattern(
name=pattern_dict["name"],
category=pattern_dict.get("category", "learned"),
triggers=pattern_dict.get("triggers", []),
action=pattern_dict.get("action", "click"),
target=pattern_dict.get("target", ""),
typical_zone=pattern_dict.get("typical_zone", "content"),
typical_bbox=pattern_dict.get("typical_bbox"),
os=pattern_dict.get("os", "any"),
confidence=pattern_dict.get("confidence", 0.7),
metadata={"source": "learned"},
))
def save_to_file(self, path: str):
"""Sauvegarde tous les patterns (builtin + appris) dans un fichier."""
data = {
"patterns": [
{
"name": p.name,
"category": p.category,
"triggers": p.triggers,
"action": p.action,
"target": p.target,
"typical_zone": p.typical_zone,
"typical_bbox": p.typical_bbox,
"os": p.os,
"confidence": p.confidence,
"metadata": p.metadata,
}
for p in self._patterns
]
}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(f"Sauvegardé {len(self._patterns)} patterns dans {path}")
def save_learned_pattern(self, pattern_dict: Dict[str, Any]):
"""Persiste un pattern appris par observation Shadow dans learned_patterns.json.
Le pattern est ajouté en mémoire ET sauvegardé sur disque.
Le fichier est créé s'il n'existe pas, ou les patterns existants sont préservés.
"""
from datetime import datetime as dt
# Charger le fichier existant ou créer la structure
filepath = self._LEARNED_PATTERNS_PATH
filepath.parent.mkdir(parents=True, exist_ok=True)
existing: Dict[str, Any] = {"patterns": []}
if filepath.exists():
try:
with open(filepath, encoding="utf-8") as f:
existing = json.load(f)
except (json.JSONDecodeError, OSError):
logger.warning(f"Fichier {filepath} corrompu, recréation")
# Vérifier qu'on ne duplique pas (même trigger + même target)
new_triggers = set(t.lower() for t in pattern_dict.get("triggers", []))
new_target = pattern_dict.get("target", "").lower()
for existing_p in existing.get("patterns", []):
existing_triggers = set(t.lower() for t in existing_p.get("triggers", []))
if existing_triggers == new_triggers and existing_p.get("target", "").lower() == new_target:
logger.debug(f"Pattern déjà connu, skip: triggers={new_triggers}, target={new_target}")
return
# Numéroter automatiquement et construire l'entrée complète
count = len(existing.get("patterns", []))
entry = {
"name": pattern_dict.get("name", f"learned_dialog_{count + 1:03d}"),
"category": pattern_dict.get("category", "dialog"),
"triggers": pattern_dict.get("triggers", []),
"action": pattern_dict.get("action", "click"),
"target": pattern_dict.get("target", ""),
"os": pattern_dict.get("os", "windows"),
"source": "shadow_learning",
"learned_at": dt.now().isoformat(timespec="seconds"),
"confidence": pattern_dict.get("confidence", 0.8),
}
# Ajouter en mémoire (avec le nom auto-généré)
self.add_pattern(entry)
existing.setdefault("patterns", []).append(entry)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(existing, f, indent=2, ensure_ascii=False)
logger.info(f"Pattern appris sauvegardé: {entry['name']}{entry['target']}")
@property
def stats(self) -> Dict[str, int]:
from collections import Counter
cats = Counter(p.category for p in self._patterns)
return {"total": len(self._patterns), "by_category": dict(cats)}

15
core/llm/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""Modules LLM (clients Ollama et décisionnels métier) + extracteur OCR."""
from .t2a_decision import (
PROMPT_TEMPLATE,
DEFAULT_MODEL,
analyze_dpi,
)
from .ocr_extractor import extract_text_from_image
__all__ = [
"PROMPT_TEMPLATE",
"DEFAULT_MODEL",
"analyze_dpi",
"extract_text_from_image",
]

71
core/llm/ocr_extractor.py Normal file
View File

@@ -0,0 +1,71 @@
"""Extracteur OCR — texte depuis une image (screenshot d'écran).
Utilise EasyOCR fr+en. Singleton (chargement modèle ~3s au premier appel).
Conçu pour le pipeline streaming serveur (action `extract_text`) : récupère
un screenshot fresh (dernier heartbeat ou capture forcée), applique l'OCR,
retourne le texte concaténé pour analyse downstream (ex: t2a_decision).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
_easyocr_reader = None
def _get_reader():
"""Initialise EasyOCR fr+en au premier appel (singleton)."""
global _easyocr_reader
if _easyocr_reader is None:
import easyocr
try:
_easyocr_reader = easyocr.Reader(['fr', 'en'], gpu=True, verbose=False)
logger.info("EasyOCR initialisé (fr+en, GPU)")
except Exception as e:
logger.warning("EasyOCR GPU indisponible (%s), fallback CPU", e)
_easyocr_reader = easyocr.Reader(['fr', 'en'], gpu=False, verbose=False)
return _easyocr_reader
def extract_text_from_image(
image_path: str,
region: Optional[Tuple[int, int, int, int]] = None,
paragraph: bool = True,
) -> str:
"""Extrait le texte d'une image via EasyOCR.
Args:
image_path: chemin du PNG sur disque.
region: (x, y, w, h) pour cropper avant OCR. None = image entière.
paragraph: True pour regrouper les lignes en paragraphes (lisible),
False pour blocs séparés (granulaire).
Returns:
Texte concaténé. Chaque ligne / paragraphe est séparé par un saut de ligne.
En cas d'erreur, retourne une chaîne vide et log un warning.
"""
path = Path(image_path)
if not path.exists():
logger.warning("extract_text: 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=0, paragraph=paragraph)
return "\n".join(str(r).strip() for r in results if r)
except Exception as e:
logger.warning("extract_text échoué sur %s : %s", image_path, e)
return ""

168
core/llm/t2a_decision.py Normal file
View File

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

View File

@@ -1,100 +0,0 @@
{
"workflow_id": "demo_calculator",
"name": "Demo - Calculatrice",
"description": "Ouvre la calculatrice et effectue un calcul simple",
"version": "1.0.0",
"created_at": "2024-11-29T10:00:00",
"updated_at": "2024-11-29T10:00:00",
"learning_state": "OBSERVATION",
"execution_count": 0,
"entry_nodes": ["start"],
"end_nodes": ["end"],
"nodes": [
{
"node_id": "start",
"name": "Desktop",
"description": "Écran de départ",
"template": {
"title_pattern": ".*"
},
"is_entry": true,
"is_end": false,
"metadata": {}
},
{
"node_id": "calc_open",
"name": "Calculatrice ouverte",
"description": "La calculatrice est visible",
"template": {
"title_pattern": ".*(calc|gnome-calculator).*"
},
"is_entry": false,
"is_end": false,
"metadata": {}
},
{
"node_id": "end",
"name": "Calcul effectué",
"description": "Le calcul est affiché",
"template": {
"title_pattern": ".*"
},
"is_entry": false,
"is_end": true,
"metadata": {}
}
],
"edges": [
{
"edge_id": "open_calc",
"source_node": "start",
"target_node": "calc_open",
"action": {
"type": "compound",
"target": {
"by_role": null,
"selection_policy": "first"
},
"parameters": {
"steps": [
{"type": "key_press", "key": "super"},
{"type": "wait", "duration_ms": 500},
{"type": "text_input", "text": "calculator"},
{"type": "key_press", "key": "Return"}
]
}
},
"constraints": {
"timeout_ms": 5000
},
"confidence_threshold": 0.7
},
{
"edge_id": "do_calc",
"source_node": "calc_open",
"target_node": "end",
"action": {
"type": "text_input",
"target": {
"by_role": "button",
"selection_policy": "first"
},
"parameters": {
"text": "${expression}=",
"defaults": {
"expression": "2+2"
}
}
},
"constraints": {
"timeout_ms": 3000
},
"confidence_threshold": 0.8
}
],
"metadata": {
"author": "RPA Vision V3",
"tags": ["demo", "calculator"],
"difficulty": "easy"
}
}

View File

@@ -0,0 +1,19 @@
# ============================================================
# Configuration Lea — Poste Dev / Chef de projet (Windows)
# ============================================================
#
# Poste : PC dev chef de projet
# Objectif : enrichir connaissance Windows, evaluer robustesse
# Serveur : 192.168.1.40:5005 (RTX 5070)
#
# ============================================================
RPA_SERVER_URL=http://192.168.1.40:5005/api/v1
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
RPA_MACHINE_ID=DEV_WINDOWS
RPA_USER_LABEL=Dev
# --- Parametres avances (ne pas modifier sauf indication) ---
# RPA_OLLAMA_HOST=localhost
RPA_BLUR_SENSITIVE=false
RPA_LOG_RETENTION_DAYS=180

View File

@@ -0,0 +1,18 @@
# ============================================================
# Configuration Lea — PC fixe Windows (LAN)
# ============================================================
#
# Poste : PC fixe Windows de Dom
# Serveur : 192.168.1.40:5005 (RTX 5070)
#
# ============================================================
RPA_SERVER_URL=http://192.168.1.40:5005/api/v1
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
RPA_MACHINE_ID=PC_WINDOWS_dOM
RPA_USER_LABEL=Dom
# --- Parametres avances (ne pas modifier sauf indication) ---
# RPA_OLLAMA_HOST=localhost
RPA_BLUR_SENSITIVE=false
RPA_LOG_RETENTION_DAYS=180

View File

@@ -0,0 +1,19 @@
# ============================================================
# Configuration Lea — Poste TIM Pauline (LAN Anoust)
# ============================================================
#
# Poste : PC de Pauline (TIM urgences)
# Objectif : apprentissage outil metier (DPI OSIRIS)
# Serveur : 192.168.1.40:5005 (RTX 5070)
#
# ============================================================
RPA_SERVER_URL=http://192.168.1.40:5005/api/v1
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
RPA_MACHINE_ID=TIM_PAULINE
RPA_USER_LABEL=Pauline
# --- Parametres avances (ne pas modifier sauf indication) ---
# RPA_OLLAMA_HOST=localhost
RPA_BLUR_SENSITIVE=true
RPA_LOG_RETENTION_DAYS=180

View File

@@ -0,0 +1,18 @@
# ============================================================
# Configuration Lea — VM Windows (LAN)
# ============================================================
#
# Poste : VM Windows 11 en reseau local
# Serveur : 192.168.1.40:5005 (RTX 5070)
#
# ============================================================
RPA_SERVER_URL=http://192.168.1.40:5005/api/v1
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
RPA_MACHINE_ID=windows_vm
RPA_USER_LABEL=Dom2
# --- Parametres avances (ne pas modifier sauf indication) ---
# RPA_OLLAMA_HOST=localhost
RPA_BLUR_SENSITIVE=false
RPA_LOG_RETENTION_DAYS=180

View File

@@ -22,6 +22,6 @@ USER_NAME=Prenom Nom
USER_EMAIL=prenom.nom@aivanov.com
USER_ID=
# Connexion serveur (valeurs par defaut deja pre-remplies)
SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
# Connexion serveur (remplacer les valeurs CONFIGURE_ME avant utilisation)
SERVER_URL=CONFIGURE_ME
API_TOKEN=CONFIGURE_ME

View File

@@ -8,36 +8,33 @@
#
# Les lignes commencant par # sont des commentaires (ignorees).
#
# IMPORTANT : remplacez toutes les valeurs CONFIGURE_ME
# avant de lancer Lea. L'agent refusera de demarrer sinon.
#
# Pour obtenir un config.txt pre-rempli, utilisez le dashboard
# Fleet (Menu → Fleet → Telecharger le ZIP d'un agent).
#
# ============================================================
# Adresse du serveur Lea (URL complete avec /api/v1)
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
# Adresse du serveur Lea (obligatoire — remplacer avant utilisation)
# Exemples :
# LAN interne : http://192.168.1.40:5005/api/v1
# Internet : https://lea.labs.laurinebazin.design/api/v1
# Dev local : http://localhost:5005/api/v1
RPA_SERVER_URL=CONFIGURE_ME
# Cle d'authentification (fournie par l'administrateur)
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
RPA_API_TOKEN=CONFIGURE_ME
# Nom du serveur (sans https://, sans /api/v1)
RPA_SERVER_HOST=lea.labs.laurinebazin.design
# Host Ollama (defaut localhost, ne pas modifier sauf configuration speciale)
# RPA_OLLAMA_HOST=localhost
# ============================================================
# Parametres avances (ne pas modifier sauf indication)
# ============================================================
# Identifiant unique de ce poste
RPA_MACHINE_ID=CONFIGURE_ME
# Flouter les zones de texte dans les captures cote CLIENT.
#
# DEPUIS AVRIL 2026 : LE BLUR CLIENT EST DESACTIVE PAR DEFAUT.
# Le floutage des donnees sensibles (noms, adresses, telephones, NIR, email)
# est desormais effectue cote SERVEUR via EDS-NLP + OCR dans le module
# core/anonymisation/pii_blur.py.
#
# Avantages du blur server-side :
# - Cible precisement les PII (PERSON/LOCATION/PHONE/NIR/EMAIL)
# - Ne casse plus les codes CIM, montants PMSI, identifiants techniques
# - Deux versions stockees : _raw (entrainement) + _blurred (affichage)
#
# Ne remettre a 'true' que si un deploiement specifique l'exige explicitement
# (ex : reseau non chiffre entre agent et serveur).
# Nom du collaborateur associe
RPA_USER_LABEL=CONFIGURE_ME
# --- Parametres avances (ne pas modifier sauf indication) ---
RPA_BLUR_SENSITIVE=false
# Duree de conservation des logs en jours (minimum 180 pour conformite)
RPA_LOG_RETENTION_DAYS=180

View File

@@ -0,0 +1,28 @@
[Unit]
Description=Maquette Easily Assure (démo GHT Sud 95) - serveur statique HTTP
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dom
Group=dom
WorkingDirectory=/home/dom/ai/rpa_vision_v3/docs/clients/ght_sud_95/mockup_easily_assure
ExecStart=/usr/bin/python3 -m http.server 8765 --bind 0.0.0.0
Restart=on-failure
RestartSec=3
TimeoutStopSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadOnlyPaths=/home/dom/ai/rpa_vision_v3/docs/clients/ght_sud_95/mockup_easily_assure
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rpa-mockup-easily
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,39 @@
[Unit]
Description=RPA Vision V3 - Streaming Server (FastAPI, port 5005)
Documentation=https://lea.labs.laurinebazin.design
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# ---- Runtime ----
User=dom
Group=dom
WorkingDirectory=/home/dom/ai/rpa_vision_v3
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
Environment="PYTHONUNBUFFERED=1"
Environment="RPA_SERVICE_NAME=rpa-streaming"
# Lancement via le module Python (même commande que svc.sh)
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 -m agent_v0.server_v1.api_stream
# ---- Resilience ----
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
# Envoyer SIGTERM d'abord, puis SIGKILL après TimeoutStopSec
KillMode=mixed
KillSignal=SIGTERM
# ---- Hardening (raisonnable pour un poste de dev/prod) ----
NoNewPrivileges=true
PrivateTmp=true
# Logs -> journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rpa-streaming
[Install]
WantedBy=multi-user.target

View File

@@ -7,32 +7,29 @@ Wants=network-online.target
Type=simple
# ---- Runtime ----
User=rpa
Group=rpa
WorkingDirectory=/opt/rpa_vision_v3/server
EnvironmentFile=/etc/rpa_vision_v3/rpa_vision_v3.env
User=dom
Group=dom
WorkingDirectory=/home/dom/ai/rpa_vision_v3
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
Environment="PYTHONUNBUFFERED=1"
Environment="ENVIRONMENT=production"
Environment="RPA_SERVICE_NAME=rpa-vision-v3-api"
# Sécurité : valide les secrets (exit !=0 => systemd restart)
ExecStart=/opt/rpa_vision_v3/venv_v3/bin/python api_upload.py
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 server/api_upload.py
# ---- Resilience ----
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
# ---- Hardening (raisonnable pour un MVP) ----
# ---- Hardening ----
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/rpa_vision_v3/data /opt/rpa_vision_v3/logs
# Logs -> journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rpa-vision-v3-api
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -3,8 +3,8 @@ Description=RPA Vision V3 - Artifact retention / rotation
[Service]
Type=oneshot
User=rpa
Group=rpa
WorkingDirectory=/opt/rpa_vision_v3
EnvironmentFile=/etc/rpa_vision_v3/rpa_vision_v3.env
ExecStart=/opt/rpa_vision_v3/venv_v3/bin/python -m core.system.artifact_retention
User=dom
Group=dom
WorkingDirectory=/home/dom/ai/rpa_vision_v3
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 -m core.system.artifact_retention

View File

@@ -5,14 +5,14 @@ Wants=network-online.target
[Service]
Type=simple
User=rpa
Group=rpa
WorkingDirectory=/opt/rpa_vision_v3
EnvironmentFile=/etc/rpa_vision_v3/rpa_vision_v3.env
User=dom
Group=dom
WorkingDirectory=/home/dom/ai/rpa_vision_v3
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
Environment="PYTHONUNBUFFERED=1"
Environment="ENVIRONMENT=production"
Environment="RPA_SERVICE_NAME=rpa-vision-v3-dashboard"
ExecStart=/opt/rpa_vision_v3/venv_v3/bin/python web_dashboard/app.py
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 web_dashboard/app.py
Restart=on-failure
RestartSec=3
@@ -20,12 +20,10 @@ TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/rpa_vision_v3/data /opt/rpa_vision_v3/logs
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rpa-vision-v3-dashboard
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -8,9 +8,9 @@ OnFailure=rpa-vision-v3-recover.service
[Service]
Type=oneshot
WorkingDirectory=/opt/rpa_vision_v3
EnvironmentFile=/etc/rpa_vision_v3/rpa_vision_v3.env
ExecStart=/opt/rpa_vision_v3/server/healthcheck.sh
WorkingDirectory=/home/dom/ai/rpa_vision_v3
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
ExecStart=/home/dom/ai/rpa_vision_v3/server/healthcheck.sh
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -5,4 +5,4 @@ Description=RPA Vision V3 - Recover stack (restart services)
Type=oneshot
# Important: nécessite root pour systemctl
User=root
ExecStart=/bin/bash -lc 'systemctl restart rpa-vision-v3-api.service rpa-vision-v3-dashboard.service rpa-vision-v3-worker.service || true'
ExecStart=/bin/bash -lc 'systemctl restart rpa-streaming.service rpa-vision-v3-api.service rpa-vision-v3-dashboard.service rpa-vision-v3-worker.service || true'

View File

@@ -5,12 +5,12 @@ Wants=network-online.target
[Service]
Type=simple
User=rpa
Group=rpa
WorkingDirectory=/opt/rpa_vision_v3/server
EnvironmentFile=/etc/rpa_vision_v3/rpa_vision_v3.env
User=dom
Group=dom
WorkingDirectory=/home/dom/ai/rpa_vision_v3
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
Environment="PYTHONUNBUFFERED=1"
ExecStart=/opt/rpa_vision_v3/venv_v3/bin/python worker_daemon.py
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 server/worker_daemon.py
Restart=on-failure
RestartSec=3
@@ -18,12 +18,10 @@ TimeoutStopSec=60
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/rpa_vision_v3/data /opt/rpa_vision_v3/logs
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rpa-vision-v3-worker
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -1,4 +1,11 @@
# /etc/rpa_vision_v3/rpa_vision_v3.env
# /home/dom/ai/rpa_vision_v3/.env.local
# Chargé par tous les services systemd via EnvironmentFile=
#
# IMPORTANT : format systemd EnvironmentFile
# - Pas de "export" devant les variables
# - Pas de guillemets autour des valeurs (sauf si espaces)
# - Commentaires avec #
# - Une variable par ligne : CLE=valeur
# --- Secrets (OBLIGATOIRES en prod) ---
ENCRYPTION_PASSWORD=CHANGE_ME
@@ -7,33 +14,45 @@ SECRET_KEY=CHANGE_ME
# --- Runtime ---
ENVIRONMENT=production
# --- Fiche #24 - Observabilité ---
# Label Prometheus (surcouche). En prod, les unités systemd posent déjà une valeur par service.
# RPA_SERVICE_NAME=rpa-vision-v3
# --- Token API fixe (streaming server + agent) ---
# Générer avec : python3 -c "import secrets; print(secrets.token_hex(32))"
# OBLIGATOIRE : si vide en prod, le serveur de streaming refuse de démarrer
# (fail-closed P0-C). Pour désactiver l'auth en dev local : RPA_AUTH_DISABLED=true
RPA_API_TOKEN=CHANGE_ME
# Worker mode:
# --- Auth dashboard Flask (port 5001, Fix P0-A) ---
# HTTP Basic Auth obligatoire sur tous les endpoints sauf healthchecks.
# OBLIGATOIRE en prod. Pour désactiver en dev : DASHBOARD_AUTH_DISABLED=true
DASHBOARD_USER=lea
DASHBOARD_PASSWORD=CHANGE_ME
# --- Worker mode ---
# thread -> worker intégré à l'API
# external -> worker dans rpa-vision-v3-worker.service (recommandé prod)
# disabled -> API upload only
RPA_PROCESSING_WORKER=external
# Ports (healthcheck.sh les utilise)
# --- Ports (healthcheck.sh les utilise) ---
RPA_API_HOST=127.0.0.1
RPA_API_PORT=8000
RPA_DASHBOARD_HOST=127.0.0.1
RPA_DASHBOARD_PORT=5001
RPA_CHECK_DASHBOARD=1
# Worker heartbeat (si worker external)
# --- Worker heartbeat ---
RPA_WORKER_HEARTBEAT_PATH=data/runtime/health/worker_heartbeat.json
RPA_WORKER_HEARTBEAT_MAX_AGE_S=60
# Retention / rotation
# --- Retention / rotation ---
RPA_DATA_DIR=data
RPA_RETENTION_FAILURE_CASES_DAYS=14
RPA_RETENTION_ARCHIVE_FAILURE_CASES=true
RPA_RETENTION_WATCHDOG_DAYS=7
RPA_RETENTION_GUARD_REPORTS_DAYS=30
# Healthcheck - disque
RPA_MIN_FREE_MB=1024
# --- Healthcheck - disque ---
RPA_MIN_FREE_MB=1024
# --- VLM (modèle de vision local) ---
RPA_VLM_MODEL=qwen3-vl:8b
VLM_MODEL=qwen3-vl:8b

897
docs/AUDIT_20260404.md Normal file
View File

@@ -0,0 +1,897 @@
# Audit Complet — RPA Vision V3
**Date** : 4 avril 2026
**Auditeur** : Claude Sonnet 4.6 + 5 agents d'exploration spécialisés
**Périmètre** : Projet complet (code source, tests, sécurité, déploiement, qualité)
**Environnement** : Ubuntu 24.04, Python 3.12.3, NVIDIA RTX 5070 (12 Go VRAM)
---
## Table des matières
1. [Synthèse exécutive](#1-synthèse-exécutive)
2. [Métriques clés](#2-métriques-clés)
3. [Architecture](#3-architecture)
4. [Modules core — Analyse détaillée](#4-modules-core--analyse-détaillée)
5. [Composants web](#5-composants-web)
6. [Agent V0/V1 — Streaming](#6-agent-v0v1--streaming)
7. [Tests](#7-tests)
8. [Sécurité](#8-sécurité)
9. [Déploiement & Infrastructure](#9-déploiement--infrastructure)
10. [Qualité du code](#10-qualité-du-code)
11. [Performances](#11-performances)
12. [Gestion des dépendances](#12-gestion-des-dépendances)
13. [Documentation](#13-documentation)
14. [Espace disque](#14-espace-disque)
15. [Points forts](#15-points-forts)
16. [Points faibles & Risques](#16-points-faibles--risques)
17. [Recommandations](#17-recommandations)
18. [Score global](#18-score-global)
---
## 1. Synthèse exécutive
RPA Vision V3 est un système d'automatisation RPA 100% basé sur la vision (pas d'accessibilité, pas de sélecteurs DOM). Il utilise CLIP, FAISS, Ollama (VLM local), SomEngine (YOLO + docTR) et le template matching pour identifier et interagir avec les éléments d'interface.
**État** : Phase 0 complète, Phase 1 (streaming agent) en stabilisation.
**Maturité** : Prototype avancé / pré-production.
**Risque principal** : Tokens de production hardcodés dans le code source.
Le projet est fonctionnel : le replay visuel fonctionne sur Windows, le VWB permet de construire des workflows, le dashboard de monitoring est opérationnel. Cependant, la dette technique s'accumule (fichiers monolithiques, 47 Go de venvs dupliqués, code mort) et des failles de sécurité critiques doivent être corrigées avant toute mise en production.
---
## 2. Métriques clés
### Volume de code
| Métrique | Valeur |
|----------|--------|
| Fichiers Python (hors venvs/archives) | 1 094 |
| Lignes de code source | 190 382 |
| Lignes de tests | 63 114 |
| Lignes TypeScript/JavaScript (frontend) | 39 868 (103 fichiers) |
| **Total lignes de code** | **~293 000** |
| Ratio tests/source | 33,2% |
| Commits | 123 |
| Contributeur unique | Dom |
| Période de développement | 7 jan → 4 avril 2026 (88 jours) |
### Répartition du code source par module
| Module | Lignes | % du total |
|--------|--------|------------|
| `core/` | 74 555 | 39,2% |
| `visual_workflow_builder/` | 45 830 | 24,1% |
| `agent_v0/` | 23 637 | 12,4% |
| `scripts/` | 16 525 | 8,7% |
| `deploy/` | 7 097 | 3,7% |
| `agent_chat/` | 6 937 | 3,6% |
| `examples/` | 4 510 | 2,4% |
| `server/` | 2 897 | 1,5% |
| `web_dashboard/` | 2 430 | 1,3% |
| Autres (cli, gui, i18n, etc.) | 5 964 | 3,1% |
### Sous-modules core/ (top 10 par taille)
| Sous-module | Lignes | Rôle |
|-------------|--------|------|
| `execution/` | 12 503 | Exécution d'actions, DAG, target resolver |
| `visual/` | 5 493 | Screen analyzer, SomEngine, visual matching |
| `analytics/` | 5 230 | Métriques, rapports, statistiques |
| `workflow/` | 4 328 | Gestion workflows, scheduler |
| `detection/` | 4 202 | UI detector, Ollama client, VLM config |
| `models/` | 3 492 | Modèles de données (workflow graph, etc.) |
| `security/` | 3 365 | API tokens, rate limiting, audit trail |
| `embedding/` | 2 914 | CLIP embedder, FAISS manager |
| `system/` | 2 862 | Safety switch, auto-heal, hooks |
| `corrections/` | 2 780 | Corrections BBOX, sniper mode |
---
## 3. Architecture
### Architecture 5 couches
```
RawSession → ScreenState → UIElement → StateEmbedding → WorkflowGraph
(1) (2) (3) (4) (5)
```
1. **RawSession** : Capture brute (screenshots + événements souris/clavier)
2. **ScreenState** : État d'écran analysé (éléments détectés, OCR)
3. **UIElement** : Éléments d'interface identifiés (boutons, champs, menus)
4. **StateEmbedding** : Vecteurs CLIP/FAISS pour recherche similaire
5. **WorkflowGraph** : Graphe de workflow exécutable
### Services (8 services, gérés par `svc.sh`)
| Port | Service | Type | Framework |
|------|---------|------|-----------|
| 8000 | API Server (upload/processing) | required | FastAPI |
| 5001 | Web Dashboard | required | Flask + SocketIO |
| 5002 | VWB Backend | required | Flask + SQLAlchemy |
| 5003 | Monitoring | optional | Flask |
| 5004 | Agent Chat | optional | Flask + SocketIO |
| 5005 | Streaming Server (Agent V1) | optional | FastAPI |
| 5099 | Worker (polling) | optional | Python script |
| 3002 | VWB Frontend | required | React 19 + Vite |
### Points d'entrée
| Fichier | Rôle |
|---------|------|
| `run.sh` | Chef d'orchestre — lance les composants selon les flags |
| `svc.sh` | Gestionnaire de services (systemd + legacy PID) |
| `cli.py` | CLI interactif (660 lignes) |
| `services.conf` | Source de vérité des ports et commandes |
### Diagramme de flux principal
```
[Agent V1 Windows]
↓ (capture screenshots + events)
↓ HTTP POST /upload_batch
[Streaming Server :5005]
↓ stream_processor.py
↓ (ScreenAnalyzer → CLIP → FAISS → GraphBuilder)
[Core Pipeline]
↓ build_replay() → resolve_target()
↓ (SomEngine → VLM grounding → template matching)
[Replay Engine]
↓ HTTP → Agent V1
↓ executor.py
[Agent V1 Windows]
↓ PyAutoGUI (Bézier mouse + char-by-char typing)
```
---
## 4. Modules core — Analyse détaillée
### 4.1 Détection (`core/detection/` — 10 fichiers, 4 202 lignes)
| Fichier | Lignes | Rôle |
|---------|--------|------|
| `ui_detector.py` | ~800 | Détecteur principal (CLIP + template matching) |
| `ollama_client.py` | ~600 | Client Ollama pour VLM (gemma4:e4b) |
| `vlm_config.py` | ~200 | Configuration VLM (modèle, endpoint) |
| `screen_analyzer.py` | ~500 | Analyse complète d'un screenshot |
| `som_engine.py` | ~315 | Set-of-Mark (YOLO + docTR), singleton thread-safe |
| `owl_detector.py` | ~300 | OWL-ViT v2 pour détection zero-shot |
| `template_matcher.py` | ~400 | Template matching OpenCV |
**Stratégie de résolution** (cascade) :
1. **Grounding VLM** (Qwen2.5-VL GPU) — pour éléments avec texte OCR
2. **Template matching** (OpenCV) — pour icônes sans texte
3. **SomEngine + VLM** — fallback multi-étapes
**Imports lourds** : `torch`, `transformers`, `open_clip_torch`, `cv2`, `PIL`
### 4.2 Exécution (`core/execution/` — 15 fichiers, 12 503 lignes)
**Fichiers critiques** :
| Fichier | Lignes | Rôle |
|---------|--------|------|
| `target_resolver.py` | 3 495 | Résolution multi-stratégie de cibles |
| `execution_loop.py` | 1 361 | Boucle principale d'exécution |
| `action_executor.py` | 1 171 | Exécuteur d'actions individuelles |
| `dag_executor.py` | ~800 | Exécution de DAG (workflows parallèles) |
| `llm_actions.py` | ~600 | Actions LLM (analyse, traduction, extraction) |
| `memory_cache.py` | 1 059 | Cache mémoire pour optimisation |
**⚠️ `target_resolver.py`** est le fichier le plus complexe du core. Il implémente 5+ stratégies de résolution : texte OCR, ancrage visuel, template matching, SomEngine, VLM grounding. À surveiller pour la maintenabilité.
**⚠️ `dag_executor.py:532`** utilise `eval()` pour évaluer des conditions de workflow :
```python
result = bool(eval(condition, {"__builtins__": {}}, eval_context))
```
Le `__builtins__: {}` limite les risques mais ne les élimine pas (contournement possible via `type.__subclasses__`).
### 4.3 GPU (`core/gpu/` — 6 fichiers, 1 735 lignes)
| Fichier | Rôle |
|---------|------|
| `gpu_resource_manager.py` | Orchestrateur GPU (modes RECORDING/AUTOPILOT/IDLE) |
| `ollama_manager.py` | Gestion cycle de vie modèles Ollama (async) |
| `clip_manager.py` | Gestion modèle CLIP (lazy load, GPU↔CPU) |
**Architecture GPU** :
- Mode **RECORDING** : VLM sur GPU, CLIP sur CPU
- Mode **AUTOPILOT** : VLM déchargé, CLIP sur GPU
- Seuil VRAM CLIP : 1 024 Mo
- Timeout inactivité : 300s
### 4.4 Authentification (`core/auth/` — 5 fichiers, 1 223 lignes)
| Fichier | Rôle |
|---------|------|
| `credential_vault.py` | Coffre-fort chiffré (Fernet AES + PBKDF2 600k itérations) |
| `totp_generator.py` | TOTP RFC 6238 (30s, 6 digits) |
| `auth_handler.py` | Orchestration authentification multi-facteur |
**⚠️ Fallback non sécurisé** : si `cryptography` n'est pas installé, le vault utilise un simple encodage base64.
### 4.5 Fédération (`core/federation/` — 3 fichiers, 1 339 lignes)
Export/import de LearningPacks anonymisés entre instances. Merge FAISS global. Endpoints REST dédiés.
### 4.6 Graph Builder (`core/graph/` — 4 fichiers, 1 949 lignes)
Construit le WorkflowGraph à partir des sessions d'enregistrement. `graph_builder.py` (1 616 lignes) accepte `precomputed_states` pour skip ScreenAnalyzer.
### 4.7 Autres modules notables
| Module | Fichiers | Lignes | Rôle |
|--------|----------|--------|------|
| `healing/` | 13 | 2 343 | Auto-correction, learning packs |
| `monitoring/` | 8 | 1 967 | Triggers, chain manager, scheduler |
| `security/` | 10 | 3 365 | API tokens, rate limiting, audit trail |
| `pipeline/` | 4 | 1 695 | Pipeline de traitement principal |
| `training/` | 6 | 1 999 | Entraînement et adaptation |
| `analytics/` | 25 | 5 230 | Reporting, métriques, dashboard data |
---
## 5. Composants web
### 5.1 Visual Workflow Builder (VWB)
**Backend** (`visual_workflow_builder/backend/`) :
- Framework : Flask + SQLAlchemy + Flask-SocketIO
- Base de données : `workflows.db` (SQLite)
- Routes principales : `catalog_routes_v2_vlm.py` (2 836 lignes — **monolithique**)
- API v3 : `dag_execute.py` (1 058 lignes), `execute.py` (1 173 lignes)
- VLM Provider : `vlm_provider.py` — interface Ollama pour détection visuelle
- Actions disponibles : 15+ catégories (data, intelligence, navigation, validation, vision_ui)
**Frontend** :
- Framework : React 19 + TypeScript + MUI 7 + Redux Toolkit
- Flow editor : `@xyflow/react` v12
- WebSocket : `socket.io-client`
- 103 fichiers TS/TSX (39 868 lignes)
- **⚠️ 2 dossiers frontend** : `frontend/` (1,3 Go avec node_modules) et `frontend_v4/` (79 Mo)
### 5.2 Web Dashboard (`web_dashboard/`)
- Framework : Flask + SocketIO
- Fichier unique : `app.py` (2 430 lignes — **monolithique**)
- 65 routes Flask
- Fonctionnalités : monitoring sessions, replay, métriques, proxy streaming
- **⚠️ `cors_allowed_origins="*"`** — pas de restriction CORS
### 5.3 Agent Chat (`agent_chat/`)
- Framework : Flask + SocketIO (6 937 lignes, 8 fichiers)
- `app.py` (2 570 lignes — **monolithique**)
- `autonomous_planner.py` — planification autonome de workflows
- Interface conversationnelle pour le pilotage RPA
---
## 6. Agent V0/V1 — Streaming
### 6.1 Client Agent V1 (`agent_v0/agent_v1/`)
Déployé sur la machine Windows cible. Léger, sans GPU.
| Fichier | Rôle |
|---------|------|
| `main.py` | Point d'entrée, configuration |
| `core/executor.py` | Exécution actions (PyAutoGUI, Bézier, char-by-char) |
| `vision/capturer.py` | Capture screenshots (mss) |
| `network/streamer.py` | Streaming vers serveur (HTTP batch upload) |
| `ui/notifications.py` | Notifications utilisateur |
| `window_info_crossplatform.py` | Info fenêtre active (Windows/Linux) |
### 6.2 Serveur Streaming (`agent_v0/server_v1/`)
Tourne sur le serveur avec GPU (RTX 5070).
| Fichier | Lignes | Rôle |
|---------|--------|------|
| `api_stream.py` | **5 612** | API FastAPI (27 endpoints) + replay + résolution + admin |
| `stream_processor.py` | **4 656** | Orchestrateur central (analyse, CLIP, FAISS, graph) |
| `live_session_manager.py` | ~600 | Gestion sessions en mémoire |
| `worker_stream.py` | ~400 | Worker polling + API directe |
| `replay_failure_logger.py` | ~200 | Logger d'échecs replay |
| `vm_controller.py` | ~150 | Contrôle VM (virsh) |
**⚠️ `api_stream.py` et `stream_processor.py`** totalisent **10 268 lignes** à eux deux. C'est le fichier le plus urgent à découper.
---
## 7. Tests
### 7.1 Vue d'ensemble
| Métrique | Valeur |
|----------|--------|
| Tests collectés (hors property) | 1 463 |
| Tests passants | **1 401** |
| Tests échoués | **9** |
| Tests skippés | 43 |
| Tests xfailed | 4 |
| Tests xpassed | 1 |
| Durée totale | 318s (~5min18) |
| **Taux de succès** | **95,8%** (hors skips : 99,4%) |
### 7.2 Répartition des fichiers de test
| Catégorie | Fichiers | Rôle |
|-----------|----------|------|
| `unit/` | 70 | Tests unitaires isolés |
| `integration/` | 47 | Tests d'intégration (services, API) |
| `smoke/` | 1 | Smoke test E2E minimal |
| `performance/` | 1 | Benchmarks |
| `property/` | 7 | Tests basés sur propriétés (Hypothesis) — **CASSÉS** |
| Racine `tests/` | 10 | Tests E2E pipeline, correction packs, coaching |
| `utils/` | 1 | Utilitaires de test |
### 7.3 Tests en échec (9 tests)
| Test | Raison |
|------|--------|
| `test_diagnostic_actions_manquantes_vwb` (×3) | Actions VWB manquantes dans le catalogue |
| `test_fiche11_multi_anchor_constraints` (×1) | Déterminisme tie-breaking non garanti |
| `test_vwb_actions_09jan2026` (×5) | Mock executor obsolète |
### 7.4 Tests non collectables (erreurs de collection)
| Fichier | Erreur |
|---------|--------|
| `tests/property/*.py` (7 fichiers) | Imports cassés (modules supprimés/renommés) |
| `tests/integration/test_visual_rpa_checkpoint.py` | Import `VisualMetadata` inexistant |
### 7.5 Couverture par module core
| Module | Couverture | Module | Couverture |
|--------|-----------|--------|-----------|
| `models/` | Excellente (129 imports) | `execution/` | Excellente (50 imports) |
| `workflow/` | Excellente (49 imports) | `capture/` | Bonne (29 imports) |
| `visual/` | Bonne (21 imports) | `detection/` | Bonne (19 imports) |
| `embedding/` | Bonne (18 imports) | `pipeline/` | Bonne (23 imports) |
| `healing/` | Modérée (10 imports) | `analytics/` | Modérée (11 imports) |
| `auth/` | Faible (3 imports) | `security/` | Très faible (1 import) |
| `gpu/` | Très faible (2 imports) | `extraction/` | Très faible (2 imports) |
| **`supervision/`** | **AUCUNE** | **`matching/`** | **AUCUNE** |
| **`variants/`** | **AUCUNE** | | |
3 modules sur 31 n'ont **aucun test** : `supervision`, `matching`, `variants`.
### 7.5 Configuration pytest
```ini
testpaths = tests
addopts = -q --tb=short --strict-markers
markers = unit, integration, performance, slow, smoke, fiche1..fiche10
filterwarnings = ignore::DeprecationWarning
```
**⚠️ Le Makefile pointe vers `venv_v3/bin/pytest`** au lieu de `.venv/bin/pytest` (le venv actif).
### 7.7 Marqueurs pytest sous-utilisés
6 marqueurs `fiche` sur 10 sont réellement utilisés (fiche4, fiche6, fiche7, fiche8, fiche9, fiche10). Les marqueurs fiche1, fiche2, fiche3, fiche5 sont déclarés mais jamais appliqués à aucun test.
---
## 8. Sécurité
### 8.1 Vulnérabilités CRITIQUES
#### 🔴 Clés API cloud en clair dans `.env.local`
**Fichier** : `.env.local` (gitignored mais sur disque)
Le fichier contient en clair :
- `ANTHROPIC_API_KEY=sk-ant-api03-...` (clé Anthropic complète)
- `OPENAI_API_KEY=sk-proj-...` (clé OpenAI complète)
- `GOOGLE_API_KEY=AIzaSy...` (clé Google complète)
- `DEEPSEEK_API_KEY=3d7b...` (clé Deepseek complète)
- `ENCRYPTION_PASSWORD`, `SECRET_KEY`, `RPA_TOKEN_ADMIN`, `AUTOHEAL_ADMIN_TOKEN`, `RPA_API_TOKEN`
**Impact** : Si le disque est compromis ou si le fichier fuite (backup, copie), toutes les clés cloud sont exposées. Les clés Anthropic/OpenAI ont un coût financier direct.
**Remédiation** :
- Révoquer et régénérer toutes les clés cloud immédiatement
- Utiliser un gestionnaire de secrets (Vault, systèmes de credentials)
- A minima, permissions `chmod 600` et propriétaire `dom:dom` uniquement
#### 🔴 Tokens de production hardcodés
**Fichier** : `core/security/api_tokens.py:93-94`
```python
# Temporary fix: Add production tokens directly
prod_admin_token = "73cf0db73f9a5064e79afebba96c85338be65cc2060b9c1d42c3ea5dd7d4e490"
prod_readonly_token = "7eea1de415cc69c02381ce09ff63aeebf3e1d9b476d54aa6730ba9de849e3dc6"
```
Ces tokens **admin** sont dans le code source, visibles dans git. Ils donnent un accès complet à l'API de streaming (port 5005) exposé sur Internet via `lea.labs.laurinebazin.design`.
**Impact** : Un attaquant peut prendre le contrôle total de l'agent RPA et exécuter des actions arbitraires sur la machine cible.
**Remédiation immédiate** : Révoquer ces tokens, les déplacer dans `.env`, régénérer.
#### 🔴 `eval()` dans le DAG executor
**Fichier** : `core/execution/dag_executor.py:532`
```python
result = bool(eval(condition, {"__builtins__": {}}, eval_context))
```
Même avec `__builtins__: {}`, `eval()` est contournable via introspection Python. Si `condition` provient d'une entrée utilisateur (workflow JSON), c'est une injection de code.
**Remédiation** : Remplacer par un parser AST sécurisé ou une grammaire restreinte.
#### 🔴 Clé de chiffrement par défaut
**Fichier** : `core/security/api_tokens.py:80`
```python
self.secret_key = os.getenv("TOKEN_SECRET_KEY", "dev-token-secret-change-in-production")
```
En production sans la variable d'environnement, la clé de signature des tokens est connue.
### 8.2 Vulnérabilités HAUTES
#### 🟠 Désérialisation `pickle.load()` non sécurisée
**Fichiers** :
- `core/embedding/faiss_manager.py:517,534`
- `core/visual/visual_embedding_manager.py`
```python
with open(metadata_path, 'rb') as f:
pickle.load(f) # Pas de restriction
```
`pickle.load()` sans restrictions permet l'exécution de code arbitraire si un fichier `.pkl` est compromis (fichier metadata FAISS). Si un attaquant peut placer un fichier `.pkl` malveillant dans `data/embeddings/`, il obtient une exécution de code.
**Remédiation** : Migrer vers JSON/msgpack pour les métadonnées, ou valider l'intégrité des fichiers avec HMAC.
#### 🟠 `shell=True` dans subprocess (11 occurrences)
**Fichier** : `agent_v0/server_v1/vm_controller.py` (10 occurrences)
```python
subprocess.run(f"virsh start {self.domain_name}", shell=True, check=True)
```
Si `domain_name` est contrôlé par l'utilisateur, c'est une injection de commandes.
**Autres occurrences** :
- `web_dashboard/app.py:1851``lsof -ti :{port} | xargs -r kill`
- `visual_workflow_builder/backend/catalog_routes_v2_vlm.py:2181``os.system('echo ...')`
#### 🟠 `os.system()` avec variables non sanitisées
- `agent_v0/agent_v1/ui/smart_tray.py:557``os.system(f'xdg-open "{sessions_path}"')`
Si `sessions_path` contient des guillemets ou des caractères shell, injection possible.
#### 🟠 CORS permissif
- `web_dashboard/app.py:41``cors_allowed_origins="*"` (accepte toutes les origines)
- Le streaming server a une liste blanche configurable (mieux)
#### 🟠 Logs contenant des tokens partiels
**Fichier** : `core/security/api_tokens.py:73-76`
```python
logger.info(f"RPA_TOKEN_ADMIN value: {admin_token[:8]}...")
```
Les 8 premiers caractères du token sont loggés. Insuffisant pour une compromission directe mais réduit l'entropie.
### 8.3 Vulnérabilités MOYENNES
| Problème | Fichiers | Impact |
|----------|----------|--------|
| `bare except:` (69 occurrences) | Tout le projet | Masque les erreurs, empêche le debugging |
| `except Exception:` (191 occurrences) | Tout le projet | Trop large, capture des erreurs inattendues |
| Fallback base64 dans credential vault | `core/auth/credential_vault.py` | Pas de chiffrement réel sans `cryptography` |
| Bearer token fixe (pas de rotation) | `core/security/api_tokens.py` | Token compromis = accès permanent |
| Logs partiels de tokens (8 premiers chars) | `core/security/api_tokens.py:73-76` | Réduit l'entropie |
| Variables globales VLM non thread-safe | `core/detection/vlm_config.py` | Race condition possible |
### 8.4 Points positifs sécurité
- Credential Vault avec Fernet AES + PBKDF2 (600k itérations, conforme OWASP 2023)
- TOTP RFC 6238 pour 2FA
- Rate limiting configurable
- Audit trail (retention 180 jours)
- Floutage des données sensibles dans les replays
- HTTPS via Let's Encrypt en production
- Bearer token obligatoire sur les endpoints exposés
---
## 9. Déploiement & Infrastructure
### 9.1 Gestion des services
- **`svc.sh`** : Gestionnaire centralisé (systemd + fallback PID files)
- **`services.conf`** : Source de vérité (8 services, ports, commandes)
- **7 services systemd** dans `deploy/systemd/` (user-level)
### 9.2 Packaging Windows
- `deploy/build_package.sh` : Vérifie 26 fichiers requis
- Package "Léa" pour collaborateurs non-techniques
- Auto-stop enregistrement (1h max, notification à 50min)
- DPI awareness (SetProcessDpiAwareness(2))
### 9.3 Exposition Internet
| URL | Service | Auth |
|-----|---------|------|
| `lea.labs.laurinebazin.design` | Streaming :5005 | Bearer token |
| `vwb.labs.laurinebazin.design` | VWB frontend :3002 | HTTP Basic (lea/Medecin2026!) |
Reverse proxy : NPM (Nginx Proxy Manager) via Docker.
### 9.4 Duplication dans deploy/
Le dossier `deploy/build/Lea/` contient une **copie complète** de l'agent V1 (executor.py, chat_window.py, etc.) qui **diverge** du code source :
- `executor.py` : 1 576 lignes (deploy) vs 1 653 lignes (source) — manque le `NotificationManager`
- `TARGETED_CROP_SIZE` : 400×400 (deploy) vs 80×80 (source)
---
## 10. Qualité du code
### 10.1 Fichiers monolithiques (> 2 000 lignes)
| Fichier | Lignes | Responsabilités mélangées |
|---------|--------|---------------------------|
| `api_stream.py` | 5 612 | API + replay + résolution + admin + healthcheck |
| `stream_processor.py` | 4 656 | Orchestration + nettoyage + replay builder + enrichissement |
| `target_resolver.py` | 3 495 | 5+ stratégies de résolution mélangées |
| `catalog_routes_v2_vlm.py` | 2 836 | Routes API + logique VLM + actions |
| `agent_chat/app.py` | 2 570 | Serveur Flask + logique chat + WebSocket |
| `web_dashboard/app.py` | 2 430 | Dashboard + 65 routes + proxy |
### 10.2 Debug print() en production
| Zone | Nombre de `print()` |
|------|---------------------|
| `visual_workflow_builder/` | ~1 500 |
| `scripts/` | ~800 |
| `examples/` | ~600 |
| `core/` | ~500 |
| `agent_v0/` | ~400 |
| `deploy/` | ~300 |
| `agent_chat/` | ~150 |
| `cli.py` | 130 |
| **Total** | **~4 350** |
La majorité provient de scripts de démonstration/diagnostic, mais ~500 sont dans le core et ~400 dans l'agent, utilisés en production.
### 10.3 TODO / FIXME / HACK
**50 marqueurs** dans le code actif (hors venvs) :
| Fichier | Nombre | Exemple |
|---------|--------|---------|
| `stream_processor.py` | 12 | Nettoyage, refactoring, edge cases |
| `auto_heal_manager.py` | 4 | Logique de récupération |
| `cli.py` | 3 | Fonctionnalités manquantes |
| `api_stream.py` | 3 | Optimisations pending |
### 10.4 Cohérence du code
#### Bug réel : `_MODIFIER_ONLY_KEYS` divergent
```python
# core/graph/graph_builder.py — 12 entrées
_MODIFIER_ONLY_KEYS = {
"ctrl", "ctrl_l", "ctrl_r",
"alt", "alt_l", "alt_r",
"shift", "shift_l", "shift_r",
"win", "cmd", "cmd_l", "cmd_r",
"meta", "super", "super_l", "super_r",
}
# agent_v0/server_v1/stream_processor.py — 20 entrées
_MODIFIER_ONLY_KEYS = {
"ctrl", "ctrl_l", "ctrl_r", "control", "control_l", "control_r",
"alt", "alt_l", "alt_r", "alt_gr",
"shift", "shift_l", "shift_r",
"win", "win_l", "win_r", "cmd", "cmd_l", "cmd_r",
"meta", "meta_l", "meta_r", "super", "super_l", "super_r",
}
```
Le `graph_builder.py` ne reconnaît pas `control`, `control_l`, `control_r`, `alt_gr`, `win_l`, `win_r`, `meta_l`, `meta_r` comme des modificateurs. Cela peut causer des actions fantômes dans les workflows construits à partir des sessions enregistrées sur Windows.
### 10.5 Imports circulaires
**Aucun import circulaire détecté** entre les sous-modules de `core/`. C'est un point positif qui témoigne d'une bonne architecture en couches.
### 10.6 Code mort
- `_a_trier/` : **561 Mo**, 261 fichiers Python orphelins non triés
- `archives/` : 21 Mo de code archivé
- `scripts/` : 39 fichiers (16 525 lignes) de scripts de diagnostic/validation datés de janvier 2026, probablement obsolètes
- `examples/` : 29 fichiers de démonstration, certains avec des imports cassés
- 2 frontends VWB (`frontend/` 1,3 Go et `frontend_v4/` 79 Mo)
- `visual_workflow_builder/backend/app_lightweight.py` (1 451 lignes) et `app_catalogue_simple.py` (1 370 lignes) — alternatives apparemment non utilisées
---
## 11. Performances
### 11.1 Performances mesurées (31 mars 2026)
| Méthode | Précision | Vitesse | Usage |
|---------|-----------|---------|-------|
| Template matching 80×80 | dist=0.000 (parfait) | 0,1s | Icônes sans texte |
| Grounding Qwen2.5-VL GPU | dist<0.04 (exact) | 2-5s | Éléments avec texte OCR |
| SomEngine CPU (build_replay) | 80% détection | 1,4s | Enrichissement enregistrement |
### 11.2 Replay E2E Windows (meilleur résultat)
- 19/20 actions correctes (Word ouvert, texte tapé, document enregistré)
- 0 retries
- Temps moyen : 2,4s/clic
- Point faible : icônes sans texte OCR sur écrans différents
### 11.3 Tests (durée d'exécution)
- 1 457 tests en ~318s (5min18) avec `-m "not slow"`
- 6 tests marqués `@slow` (GPU-dépendants)
---
## 12. Gestion des dépendances
### 12.1 requirements.txt principal
176 dépendances pinnées, incluant :
| Catégorie | Packages clés |
|-----------|--------------|
| ML/IA | `torch==2.9.1`, `transformers==4.57.3`, `open_clip_torch==3.2.0`, `timm==1.0.24` |
| Vision | `opencv-python==4.12.0.88`, `pillow==12.1.0`, `python-doctr==1.0.1` |
| Recherche | `faiss-cpu==1.13.2`, `scikit-learn==1.8.0` |
| Web | `fastapi==0.128.0`, `Flask==3.0.0`, `uvicorn==0.40.0` |
| Automatisation | `PyAutoGUI==0.9.54`, `pynput==1.8.1`, `mss==10.1.0` |
| GUI | `PyQt5==5.15.11` |
| Sécurité | `cryptography==46.0.3` |
| NVIDIA | `nvidia-cublas-cu12`, `nvidia-cudnn-cu12`, etc. (CUDA 12.8) |
### 12.2 Fichiers requirements multiples
7 fichiers `requirements*.txt` (hors archives) pour différents sous-projets. Risque de désynchronisation.
### 12.3 setup.py minimal
```python
install_requires=["numpy", "pillow", "faiss-cpu", "scikit-learn", "open_clip_torch"]
```
Ne reflète pas les dépendances réelles (manque torch, transformers, fastapi, flask, etc.). Le `setup.py` est vestigial.
### 12.4 Pas de pyproject.toml
Le projet utilise `setup.py` + `pytest.ini` au lieu du standard moderne `pyproject.toml`. Pas de linter configuré (ruff, black, mypy ne sont pas dans la CI).
---
## 13. Documentation
### 13.1 Volume
- **136 fichiers** dans `docs/` (dont ~100 rapports de sessions/corrections de janvier 2026)
- Documentation structurée dans `docs/reference/`, `docs/specs/`, `docs/fiches/`, `docs/guides/`
- `docs/README.md` — index bien organisé
### 13.2 Documents clés
| Document | Contenu |
|----------|---------|
| `docs/reference/ARCHITECTURE_VISION_COMPLETE.md` | Architecture 5 couches complète |
| `docs/specs/requirements.md` | 15 requirements, 89 critères d'acceptation |
| `docs/specs/design.md` | Design détaillé, 20 correctness properties |
| `docs/specs/tasks.md` | Plan d'implémentation 13 phases, 60+ tâches |
| `docs/CONFORMITE_AI_ACT.md` | Conformité Règlement IA européen |
| `docs/PLAYBOOK_DSI_RSSI.md` | Playbook pour DSI/RSSI |
| `docs/DOSSIER_COMMISSAIRE_AUX_APPORTS.md` | Dossier d'évaluation financière |
### 13.3 Points d'attention
- ~100 fichiers de rapports de sessions datés (janvier 2026) polluent le dossier `docs/`
- Pas de documentation API auto-générée (Swagger/OpenAPI non configuré malgré FastAPI)
- Pas de CONTRIBUTING.md ou CHANGELOG.md formels
- Les commentaires dans le code sont en français (cohérent avec la convention du projet)
---
## 14. Espace disque
### 14.1 Taille totale : 61 Go
| Élément | Taille | % |
|---------|--------|---|
| `.venv/` (principal) | 9,0 Go | 14,8% |
| `visual_workflow_builder/backend/venv` | 8,3 Go | 13,6% |
| `venv_v3/` (legacy) | 7,8 Go | 12,8% |
| `venv/` (legacy) | 7,5 Go | 12,3% |
| `visual_workflow_builder/venv` | 7,3 Go | 12,0% |
| `agent_v0/.venv` | 7,1 Go | 11,6% |
| **Total venvs** | **47,0 Go** | **77,0%** |
| `data/` | 3,2 Go | 5,2% |
| `frontend/node_modules` | 1,3 Go | 2,1% |
| `.git/` | 633 Mo | 1,0% |
| `_a_trier/` | 561 Mo | 0,9% |
| `models/` | 511 Mo | 0,8% |
| Code source + docs + reste | ~400 Mo | 0,7% |
### 14.2 Venvs dupliqués — problème critique
**6 environnements virtuels** pour un seul projet, totalisant **47 Go**. Chacun contient probablement PyTorch (~2 Go), transformers, etc. en doublon.
**Venvs actifs** :
- `.venv/` — principal (utilisé par pytest, svc.sh)
- `visual_workflow_builder/backend/venv` — backend VWB
**Venvs probablement inutiles** :
- `venv/` — ancien, probablement jamais nettoyé
- `venv_v3/` — ancien (référencé dans le Makefile mais plus utilisé)
- `visual_workflow_builder/venv` — probablement remplacé par `backend/venv`
- `agent_v0/.venv` — l'agent V1 est déployé séparément sur Windows
**Recommandation** : Supprimer les venvs inutilisés pour gagner ~30 Go.
---
## 15. Points forts
1. **Architecture 5 couches claire** : Séparation nette des responsabilités, 30 sous-modules core sans imports circulaires
2. **100% vision** : Approche unique et cohérente, pas de raccourcis (accessibility API, DOM selectors)
3. **Suite de tests conséquente** : 1 463 tests, 95,8% de succès, couverture des modules critiques
4. **SomEngine bien conçu** : 315 lignes, singleton thread-safe, lazy loading, documentation
5. **Gestion GPU sophistiquée** : Modes RECORDING/AUTOPILOT, arbitrage VRAM automatique
6. **Sécurité crypto solide** : Fernet AES + PBKDF2 600k, TOTP RFC 6238
7. **Conformité réglementaire** : Rétention 180j, floutage, audit trail, dossier AI Act
8. **Packaging Windows robuste** : Vérification des 26 fichiers, auto-stop, DPI awareness
9. **Anti-détection** : Bézier mouse movement + frappe caractère par caractère
10. **Commits conventionnels** : Préfixes `feat:/fix:/refactor:/chore:` respectés
11. **Infrastructure as Code** : systemd services, svc.sh, services.conf
12. **Cascade de résolution intelligente** : VLM → template matching → SomEngine (fail-safe)
---
## 16. Points faibles & Risques
### 16.1 Risques critiques (P0)
| # | Risque | Impact | Fichier |
|---|--------|--------|---------|
| 1 | **Clés API cloud en clair** (Anthropic, OpenAI, Google, Deepseek) | Compromission financière + accès APIs | `.env.local` |
| 2 | Tokens admin hardcodés dans le code | Compromission complète de l'API exposée sur Internet | `core/security/api_tokens.py:93-94` |
| 3 | `eval()` sur conditions workflow | Injection de code arbitraire | `core/execution/dag_executor.py:532` |
| 4 | Clé de signature par défaut | Forge de tokens en production | `core/security/api_tokens.py:80` |
### 16.2 Risques hauts (P1)
| # | Risque | Impact |
|---|--------|--------|
| 5 | `pickle.load()` sans restrictions | Exécution de code via fichiers `.pkl` malveillants |
| 6 | 11 `subprocess(shell=True)` avec variables | Injection de commandes |
| 7 | `_MODIFIER_ONLY_KEYS` divergent entre modules | Actions fantômes dans les workflows |
| 8 | Executor dupliqué et divergent (source vs deploy) | Comportement différent en prod |
| 9 | 36+ fichiers modifiés non commités | Perte de travail potentielle |
### 16.3 Risques moyens (P2)
| # | Risque | Impact |
|---|--------|--------|
| 8 | Fichiers monolithiques (api_stream.py : 5 612 lignes) | Maintenabilité, risque de régression |
| 9 | 47 Go de venvs (77% de l'espace disque) | Espace disque, confusion |
| 10 | 4 350 print() en production | Pas de logging structuré, debug en prod |
| 11 | 69 bare except:, 191 except Exception: | Erreurs masquées |
| 12 | 7 tests property cassés | Fausse couverture |
| 13 | Makefile pointe vers mauvais venv | DX cassée |
| 14 | `setup.py` ne reflète pas les vraies dépendances | Installation cassée |
| 15 | CORS `*` sur le dashboard | Pas de restriction cross-origin |
### 16.4 Dette technique (P3)
| # | Problème | Volume |
|---|----------|--------|
| 16 | `_a_trier/` non trié | 561 Mo, 261 fichiers Python |
| 17 | Scripts de diagnostic datés (jan 2026) | 39 fichiers, 16 525 lignes |
| 18 | 2 frontends VWB | 1,3 Go vs 79 Mo |
| 19 | ~100 rapports de sessions dans docs/ | Pollution documentation |
| 20 | 50 TODO/FIXME dans le code actif | Travail non terminé |
| 21 | Pas de CI/CD (linter, tests automatiques) | Qualité non vérifiée automatiquement |
| 22 | Pas de pyproject.toml | Configuration fragmentée |
---
## 17. Recommandations
### Immédiat (cette semaine) — Sécurité & Risque de perte
| # | Action | Effort | Impact |
|---|--------|--------|--------|
| 1 | **Révoquer toutes les clés API cloud** (Anthropic, OpenAI, Google, Deepseek dans `.env.local`) et régénérer | 1h | 🔴 Critique |
| 2 | **Supprimer les tokens hardcodés** de `api_tokens.py`, les charger uniquement depuis `.env` | 30min | 🔴 Critique |
| 3 | **Remplacer `eval()` par `ast.literal_eval`** ou un parser restreint | 2h | 🔴 Critique |
| 4 | **Commiter les 36+ fichiers modifiés** ou les stasher | 15min | 🔴 Perte de travail |
| 5 | **Supprimer la clé par défaut** dans `TOKEN_SECRET_KEY` | 15min | 🔴 Critique |
| 6 | **Corriger `cors_allowed_origins="*"`** dans web_dashboard | 10min | 🟠 Haut |
### Court terme (1-2 semaines) — Cohérence & Hygiène
| # | Action | Effort | Impact |
|---|--------|--------|--------|
| 7 | Unifier `_MODIFIER_ONLY_KEYS` dans un module partagé | 1h | 🟠 Bug réel |
| 8 | Corriger le Makefile (`venv_v3``.venv`) | 5min | 🟡 DX |
| 9 | Supprimer les 4 venvs inutilisés (~30 Go) | 10min | 🟡 Espace |
| 10 | Remplacer `subprocess(shell=True)` par des listes d'arguments | 2h | 🟠 Injection |
| 11 | Remplacer `pickle.load()` par JSON/msgpack dans faiss_manager | 2h | 🟠 Sécurité |
| 12 | Supprimer la copie divergente dans `deploy/build/Lea/` | 1h | 🟠 Cohérence |
| 13 | Corriger les 9 tests en échec | 4h | 🟡 Qualité |
### Moyen terme (1-2 mois) — Maintenabilité
| # | Action | Effort | Impact |
|---|--------|--------|--------|
| 12 | Découper `api_stream.py` (5 612L) en 4+ modules | 2j | 🟡 Maintenabilité |
| 13 | Découper `stream_processor.py` (4 656L) | 2j | 🟡 Maintenabilité |
| 14 | Remplacer les `print()` par `logging` (core + agent) | 1j | 🟡 Observabilité |
| 15 | Nettoyer `_a_trier/` (561 Mo) | 2h | 🟡 Hygiène |
| 16 | Supprimer/archiver les scripts de diagnostic de jan 2026 | 1h | 🟡 Hygiène |
| 17 | Migrer vers `pyproject.toml` | 2h | 🟡 Standards |
| 18 | Configurer CI (ruff + pytest + pre-commit) | 4h | 🟡 Qualité |
| 19 | Activer Swagger/OpenAPI pour FastAPI | 1h | 🟡 Documentation |
| 20 | Réparer ou supprimer les 7 tests property | 4h | 🟡 Couverture |
### Long terme (3+ mois) — Scalabilité
| # | Action | Effort |
|---|--------|--------|
| 21 | Containeriser avec Docker (multi-stage builds) |
| 22 | Implémenter la rotation de tokens API |
| 23 | Ajouter des health checks automatisés pour chaque service |
| 24 | Mettre en place un pipeline CI/CD complet (build → test → deploy) |
| 25 | Implémenter le monitoring Prometheus/Grafana |
---
## 18. Score global
| Axe | Note | Commentaire |
|-----|------|-------------|
| **Fonctionnalité** | 8/10 | Pipeline complet, replay fonctionnel, VWB opérationnel |
| **Architecture** | 7/10 | 5 couches bien séparées, mais fichiers monolithiques |
| **Tests** | 7/10 | 1 463 tests, 95,8% succès, mais property tests cassés |
| **Sécurité** | 2/10 | Clés API cloud en clair + tokens hardcodés + eval() + pickle + shell=True |
| **Cohérence** | 5/10 | Duplication code, venvs multiples, divergences |
| **Dette technique** | 4/10 | 4 350 print(), 561 Mo non trié, fichiers géants |
| **Documentation** | 6/10 | Bonne structure mais polluée par les rapports de session |
| **Déploiement** | 6/10 | systemd + svc.sh fonctionnels, mais pas de CI/CD |
| **Performance** | 8/10 | 2,4s/clic, cascade intelligente, GPU bien géré |
| **DX (Developer Experience)** | 5/10 | Makefile cassé, venvs confus, pas de linter |
| **Global** | **5,7/10** | Solide fonctionnellement, sécurité et housekeeping urgents |
### Verdict
RPA Vision V3 est un projet ambitieux et techniquement impressionnant dans sa vision (100% basé sur la vision, pas de sélecteurs). Le pipeline fonctionne, le replay est opérationnel, et l'architecture 5 couches est bien pensée.
Cependant, **la mise en production est bloquée** par les failles de sécurité critiques (tokens hardcodés, eval(), clé par défaut). Les actions P0 doivent être traitées **avant toute exposition supplémentaire sur Internet**.
La dette technique (fichiers monolithiques, 47 Go de venvs, 4 350 print()) ne bloque pas le fonctionnement mais ralentira significativement le développement futur. Un sprint de nettoyage de 1-2 semaines apporterait un ROI important.
---
*Généré le 4 avril 2026 par Claude Sonnet 4.6 — Audit multi-agents (5 agents parallèles : architecture, core, tests, web, sécurité)*

View File

@@ -0,0 +1,95 @@
# Bench QW4 safety_checks — sélection du LLM contextuel
**Date** : 2026-05-06
**Contexte** : QW4 du sprint mai. La fonction `_call_llm_for_contextual_checks`
appelle Ollama avec un screenshot + prompt court pour générer 0-3 checks de
vérification supplémentaires que l'humain doit acquitter avant la reprise
d'un replay en pause supervisée (`safety_level=medical_critical`).
## Méthodologie
- **5 scénarios** : screenshots synthétiques de dossiers patient avec UNE
anomalie volontaire chacun (date de naissance aberrante, IPP incohérent,
diagnostic vide, code CIM inadapté à l'âge, forfait incohérent avec durée).
- **5 candidats** : `gemma4:latest`, `qwen3-vl:8b`, `qwen2.5vl:7b`,
`qwen2.5vl:3b`, `medgemma:4b`.
- **Protocole par modèle** : déchargement VRAM (keep_alive=0 sur tous les
modèles loaded) → 1er appel = cold start chronométré → 4 autres screenshots
× 3 runs = 12 mesures warm.
- **Métriques** : cold start, warm avg, warm p95, % JSON valide, % détection
(anomalie cible présente dans label/evidence d'au moins un check renvoyé).
- **Script** : `tools/bench_safety_checks_models.py`.
## Résultats
| Modèle | Cold (s) | Warm avg (s) | Warm p95 (s) | JSON | Détection |
|---|---:|---:|---:|---:|---:|
| `gemma4:latest` | 10.6 | **2.9** | 3.4 | 92% (12/13) | **46% (6/13)** |
| `qwen3-vl:8b` | 5.6 | — | — | **0%** (0/12) | 0% (0/12) |
| `qwen2.5vl:7b` | 9.4 | 6.6 | 8.1 | 100% (13/13) | 23% (3/13) |
| `qwen2.5vl:3b` | 6.0 | 2.0 | 2.5 | 100% (13/13) | 8% (1/13) |
| `medgemma:4b` | 2.0 | 0.5 | 0.7 | 100% (13/13) | **0%** (0/13) |
## Lecture
- **`medgemma:4b` retourne systématiquement `[]`** sur les 13 mesures.
Trop obéissant à "Si rien d'inhabituel à signaler, retourne []", refuse
de pointer ne serait-ce qu'une date 1900-01-01. **Mauvais choix par défaut**
malgré sa rapidité et sa spécialisation médicale revendiquée.
- **`qwen3-vl:8b` ignore `format=json` Ollama** : 0 réponse parsable. À écarter
pour cette tâche tant que le tooling Ollama / le modèle ne convergent pas.
- **`qwen2.5vl:7b`** détecte mais 2× plus lent (warm 6.6s) que gemma4 et tend
à inventer des anomalies de format de date qui ne sont pas la vraie cible.
- **`qwen2.5vl:3b`** rapide mais détection 8% — il "vérifie pour vérifier"
(renvoie souvent "vérification de la date de naissance" même quand la date
est correcte).
- **`gemma4:latest` gagne** : meilleur taux de détection (46%) ET deuxième
meilleur warm (2.9s). Tend à raisonner cohérence motif/diagnostic plutôt
que valeurs aberrantes brutes.
## Détail détection par scénario
| Scénario | gemma4 | qwen2.5vl:7b | qwen2.5vl:3b | medgemma:4b |
|---|:---:|:---:|:---:|:---:|
| Date naissance aberrante (1900) | ❌ | ✅ | ✅ | ❌ |
| IPP incohérent (`ABC@@##XYZ`) | ❌ | ❌ | ❌ | ❌ |
| Diagnostic principal vide | ✅ | ❌ | ❌ | ❌ |
| Code CIM inadapté à l'âge | ✅ | ❌ | ❌ | ❌ |
| Forfait UHCD vs durée 1h | ❌ | ❌ | ❌ | ❌ |
Aucun modèle ne détecte les 5 scénarios. **L'IPP corrompu et le forfait
incohérent ne sont détectés par personne** — ces anomalies demanderaient
soit un prompt plus dirigé (liste explicite des champs à vérifier), soit
un modèle plus large.
## Décision
- **Défaut serveur** : `RPA_SAFETY_CHECKS_LLM_MODEL=gemma4:latest`
- **Timeout** : `RPA_SAFETY_CHECKS_LLM_TIMEOUT_S=7` (warm 2.9s + marge)
- **Persistance VRAM** : `OLLAMA_KEEP_ALIVE=24h` recommandé pour éviter le
cold start de 10s en démo
Modifications appliquées dans `agent_v0/server_v1/safety_checks_provider.py`.
## Limites & travail futur
1. **46% de détection est faible** : à présenter comme aide au médecin, pas
comme certification. Le médecin reste le décideur.
2. **Prompt actuel trop générique** : un prompt qui liste explicitement les
champs à vérifier (DDN, IPP, diagnostic, forfait, cohérence âge/diagnostic)
donnerait probablement de meilleurs résultats. À mesurer en V2.
3. **Bench sur 5 anomalies seulement** : à étendre dès qu'on a un corpus de
vrais dossiers Easily Assure avec anomalies confirmées par Pauline / Amina.
4. **Pas de test sur des dossiers SANS anomalie** (faux positifs) : à ajouter.
5. **Pas de bench des modèles cloud** (gemma3:27b-cloud, deepseek, gpt-oss)
par contrainte 100% local — mais à explorer si on lève cette contrainte
pour les checks contextuels (qui ne contiennent pas de PII si on
anonymise les screenshots).
## Reproductibilité
```bash
cd /home/dom/ai/rpa_vision_v3
.venv/bin/python tools/bench_safety_checks_models.py
# (BENCH_TIMEOUT=60 par défaut, ~10-15 min sur RTX 5070)
```

233
docs/CARTOGRAPHY.md Normal file
View File

@@ -0,0 +1,233 @@
# Cartographie d'exécution — RPA Vision V3 (Léa)
> **Date** : 26 avril 2026
> **Objectif** : carte complète de ce qui est branché, ce qui ne l'est pas, et comment les données transitent.
> **Règle** : LIRE CE DOCUMENT AVANT TOUTE MODIFICATION DE CODE.
---
## 1. Point d'entrée : deux chemins disjoints
```
POST /api/v3/execute/start (execute.py:1528)
├── execution_mode = "verified" → run_workflow_verified() ← CHEMIN ORA
└── execution_mode = "basic"|"intelligent"|"debug" → execute_workflow_thread() ← CHEMIN LEGACY
```
**Il existe DEUX exécuteurs distincts** qui dupliquent le chargement des ancres, la boucle d'étapes, le grounding, la gestion d'erreurs. Ils ne partagent que `input_handler.py`.
---
## 2. Chemin LEGACY (modes basic/intelligent/debug)
```
[API] POST /execute/start (mode=intelligent)
→ [execute.py:145] execute_workflow_thread()
→ [execute.py:160] Charge steps depuis DB
→ BOUCLE sur chaque step:
├─ RÉFLEXE PRÉ-ÉTAPE (modes intelligent/debug)
│ → [input_handler.py:79] check_screen_for_patterns()
│ → UIPatternLibrary.find_pattern(ocr_text) ← BRANCHÉ
│ → [input_handler.py:129] handle_detected_pattern()
│ → EasyOCR full screen + clic bouton ← BRANCHÉ
├─ CHARGEMENT ANCRE [execute.py:222-256]
│ params['visual_anchor'] = {
│ screenshot: base64 du crop,
│ bounding_box: {x, y, width, height},
│ target_text: anchor.target_text, ← PEUT ÊTRE VIDE ("")
│ description: anchor.ocr_description ← PEUT ÊTRE VIDE ("")
│ }
├─ execute_action(action_type, params) [execute.py:278]
│ │
│ ├─ ACTION = click_anchor [execute.py:862-1096]
│ │ │
│ │ ├─ MODE basic: coordonnées statiques (bbox centre)
│ │ │
│ │ └─ MODE intelligent/debug:
│ │ ├─ target_text = anchor.target_text || step.label
│ │ │ Si target_text == "click_anchor" et screenshot_base64:
│ │ │ → _describe_anchor_image() (VLM qwen2.5vl:3b) ← BRANCHÉ
│ │ │
│ │ ├─ MÉTHODE 1: Template matching (cv2) ← BRANCHÉ
│ │ ├─ MÉTHODE 2: CLIP matching (RF-DETR + CLIP) ← BRANCHÉ
│ │ ├─ MÉTHODE 3: OCR → UI-TARS → VLM ← BRANCHÉ
│ │ └─ ÉCHEC: self-healing interactif ← BRANCHÉ
│ │
│ ├─ ACTION = type_text → safe_type_text() ← BRANCHÉ
│ ├─ ACTION = wait → sleep + pattern check ← BRANCHÉ
│ ├─ ACTION = keyboard_shortcut → pyautogui.hotkey() ← BRANCHÉ
│ ├─ ACTION = ai_analyze_text → Ollama ← BRANCHÉ
│ ├─ ACTION = extract_text → docTR OCR ← BRANCHÉ
│ └─ ACTION = hover/scroll/focus → coords statiques ← PAS DE GROUNDING
```
---
## 3. Chemin ORA (mode "verified")
```
[API] POST /execute/start (mode=verified)
→ [execute.py:1349] run_workflow_verified()
→ [execute.py:1380-1428] Charge steps + ancres (MÊME logique que legacy)
→ [execute.py:1433] ORALoop(verify_level='none', max_retries=2)
│ ^^^^^^^^^^^^^^^^^^^
│ VÉRIFICATION DÉSACTIVÉE EN DUR
→ [ORA:1478] ora.run_workflow(steps=ora_steps)
BOUCLE sur chaque step:
├─ [ORA:1258] OBSERVE: capture écran + pHash + titre fenêtre
├─ [ORA:1263] RÉFLEXE DIALOGUE (si pHash changé > 10)
│ → DialogHandler.handle_if_dialog(screenshot) ← BRANCHÉ
│ → EasyOCR full screen → mots-clés dialogues connus
│ → InfiGUI worker (/tmp/infigui_*)
│ → Fallback OCR clic
├─ [ORA:196] REASON: reason_workflow_step()
│ target_text = anchor.target_text || anchor.description
│ Si vide ou nom d'action → _describe_anchor_image() ← CORRIGÉ 26/04
│ Si encore vide → label (si pas un nom d'action)
├─ [ORA:1306] ACT → _act_click()
│ │
│ ├─ RPA_USE_FAST_PIPELINE=1 (défaut)
│ │ → FastSmartThinkPipeline
│ │ → FastDetector (RF-DETR 120ms + EasyOCR 192ms) ← BRANCHÉ
│ │ → SmartMatcher (texte+type+position+voisins <1ms) ← BRANCHÉ
│ │ → SignatureStore.lookup() (apprentissage) ← BRANCHÉ
│ │ → Score ≥ 0.90 → action directe ← BRANCHÉ
│ │ → Score 0.60-0.90 → ThinkArbiter
│ │ → UITarsGrounder → InfiGUI worker (/tmp) ← BRANCHÉ
│ │ → Score < 0.60 → ThinkArbiter seul ← BRANCHÉ
│ │ → ÉCHEC → _try_fallback()
│ │ → GroundingPipeline ← NON BRANCHÉ (jamais connecté)
│ │
│ ├─ FALLBACK template matching (cv2, >0.75) ← BRANCHÉ
│ ├─ FALLBACK OCR (_grounding_ocr) ← BRANCHÉ
│ └─ DERNIER RECOURS: coords statiques ← BRANCHÉ
├─ [ORA:1337] VÉRIFICATION TITRE (post-action)
│ → TitleVerifier → EasyOCR crop 45px ← BRANCHÉ
│ *** NE LIT RIEN EN VM (titre Windows dans le framebuffer) ← PROBLÈME
├─ [ORA:1358] VERIFY: verify(pre, post, decision)
│ *** DÉSACTIVÉ (verify_level='none') *** ← NON BRANCHÉ
└─ [ORA:1362] RECOVERY (5 stratégies)
*** JAMAIS ATTEINT *** ← NON BRANCHÉ
- _recover_element_not_found (wait+scroll+UI-TARS)
- _recover_overlay_blocking (pattern+Win+D)
- _recover_wrong_screen (Alt+Tab)
- _recover_no_effect (double-clic+décalage)
- _classify_error (4 types)
```
---
## 4. Trace du champ `target_text`
```
CAPTURE (VWB CapturePanel → capture.py:201-263)
→ OCR sur crop élargi (docTR)
→ VLM qwen2.5vl:3b décrit le crop
→ Si les deux échouent → target_text = ""
→ Aucune erreur remontée au frontend
STOCKAGE (DB)
→ VisualAnchor.target_text (nullable) = "" si non renseigné
CHARGEMENT (execute.py:1400-1428)
→ SI anchor.target_text existe et non vide → injecté dans visual_anchor
→ SINON → la clé 'target_text' N'EXISTE PAS dans le dict
LEGACY (execute.py:893-907)
→ target_text = anchor.get('target_text', '')
→ SI vide ET c'est un nom d'action → _describe_anchor_image() ← COMPENSE
→ SINON → fallback sur step_label
ORA (observe_reason_act.py:217) — CORRIGÉ LE 26 AVRIL
→ target_text = anchor.target_text || anchor.description
→ SI vide ou nom d'action → _describe_anchor_image() ← AJOUTÉ
→ SINON → label (si pas un nom d'action)
```
---
## 5. Fonctions existantes NON BRANCHÉES
| Fonction | Fichier | Raison |
|----------|---------|--------|
| `verify()` + `_classify_error()` + 5 `_recover_*()` | observe_reason_act.py | verify_level='none' en dur |
| `GroundingPipeline` (ancien) | pipeline.py | set_fallback_pipeline() jamais appelé |
| `TemplateMatcher` (classe centralisée) | template_matcher.py | Utilisé seulement par GroundingPipeline mort |
| `ShadowLearningHook` | shadow_learning_hook.py | Jamais importé dans aucun flux |
| `CognitiveContext` | working_memory.py | Mode instruction seulement |
| `VLM pre-check` | observe_reason_act.py | `if False:` en dur |
| hover/focus grounding | execute.py | Coords statiques uniquement |
| `grounding/server.py` (FastAPI :8200) | server.py | Crash CUDA, remplacé par worker fichiers |
---
## 6. Les 12 systèmes de grounding
| # | Système | Fichier | Branché ? |
|---|---------|---------|-----------|
| 1 | Template matching inline (legacy) | execute.py:914 | ✅ Legacy |
| 2 | Template matching inline (ORA) | ORA:1475 | ✅ ORA fallback |
| 3 | CLIP matching (IntelligentExecutor) | intelligent_executor.py | ✅ Legacy |
| 4 | OCR docTR (_grounding_ocr) | input_handler.py:430 | ✅ Legacy + ORA |
| 5 | UI-TARS Ollama (_grounding_ui_tars) | input_handler.py:513 | ✅ Legacy |
| 6 | VLM reasoning (_grounding_vlm) | input_handler.py:627 | ✅ Legacy seulement |
| 7 | FastDetector (RF-DETR + EasyOCR) | fast_detector.py | ✅ ORA |
| 8 | SmartMatcher | smart_matcher.py | ✅ ORA |
| 9 | ThinkArbiter → InfiGUI worker | think_arbiter.py + ui_tars_grounder.py | ✅ ORA |
| 10 | DialogHandler → InfiGUI | dialog_handler.py | ✅ ORA réflexe |
| 11 | GroundingPipeline (ancien) | pipeline.py | ❌ Jamais connecté |
| 12 | TemplateMatcher classe | template_matcher.py | ❌ Via GroundingPipeline mort |
---
## 7. Gestion des dialogues (2 systèmes parallèles)
| # | Système | Base de patterns | OCR | Clic | Utilisé par |
|---|---------|-----------------|-----|------|-------------|
| 1 | UIPatternLibrary + handle_detected_pattern | 28 patterns builtin | docTR/EasyOCR | OCR find bouton | Legacy |
| 2 | DialogHandler + KNOWN_DIALOGS | 15 titres connus | EasyOCR full screen | InfiGUI | ORA |
---
## 8. Budget VRAM (configuration actuelle)
| Composant | VRAM | Process |
|-----------|------|---------|
| InfiGUI-G1-3B (NF4) | 2.41 GB | Worker indépendant (/tmp) |
| RF-DETR Medium | 0.8 GB | Process Flask |
| EasyOCR | ~1 GB (GPU) | Process Flask |
| Ollama qwen2.5vl:3b (si appelé) | ~3.2 GB | Process Ollama |
| Chrome + système | ~1.3 GB | — |
| **Total max** | **~8.7 GB / 12 GB** | |
---
## 9. Fichiers critiques par ordre d'importance
1. `core/execution/observe_reason_act.py` — boucle ORA, _act_click, reason, verify
2. `visual_workflow_builder/backend/api_v3/execute.py` — API, chargement ancres, legacy executor
3. `core/grounding/fast_pipeline.py` — pipeline FAST→SMART→THINK
4. `core/grounding/ui_tars_grounder.py` — client InfiGUI worker
5. `core/grounding/infigui_worker.py` — worker InfiGUI (process indépendant)
6. `core/execution/input_handler.py` — OCR, UI-TARS Ollama, safe_type_text, patterns
7. `core/grounding/dialog_handler.py` — gestion dialogues ORA
8. `core/grounding/fast_detector.py` — RF-DETR + EasyOCR
9. `core/grounding/smart_matcher.py` — matching contextuel
10. `core/knowledge/ui_patterns.py` — patterns réflexes
---
> **Dernière mise à jour** : 26 avril 2026
> **Prochaine action** : rebrancher verify + recovery, converger les 2 exécuteurs, nettoyer le code mort.

View File

@@ -0,0 +1,291 @@
# Challenge des plans d'action — Dashboard & VWB
_16 avril 2026 — critique transversale des deux plans du 15 avril, avant exécution._
_Lecture ciblée : 10 minutes. Aucune modification de code. Ton direct._
---
## Section 0 — Verdict global
Les deux plans sont **globalement justes**, bien structurés, honnêtes sur la dette. Mais :
- **Le plan Dashboard** sous-estime le couplage avec l'audit trail backend (risque cascading), et pousse un onglet Audit un peu trop ambitieux pour un POC qui démarre dans 2 semaines.
- **Le plan VWB** a une bonne hiérarchie mais **deux erreurs factuelles** (B5 vise le mauvais frontend, et la "bibliothèque qui s'efface" peut avoir une cause simple non explorée) et rate une priorité réelle : **le backup automatique des workflows n'existe pas**.
- **Aucun des deux plans ne parle à l'autre** — ils pourraient se contredire sur correction_packs et sur l'audit.
Recommandation : exécuter VWB quick wins en priorité (impact immédiat pour Dom), puis Dashboard cleanup, puis audit MVP. PAS l'onglet Audit "DSI-ready complet" avant le POC Anouste.
---
## Section 1 — Dashboard : ce qui tient, ce qui ne tient pas
### 1.1. Ce qu'on valide tel quel
- **Retirer onglet 🧪 Tests (B1)** — correct, la RCE implicite via pytest subprocess est réelle, à éjecter sans regret.
- **Retirer onglet ⚡ Exécution (B4)** — la logique Agent V1 a déprécié l'ancien SocketIO `subscribe_execution`, plus personne ne regarde ça.
- **Retirer pages auxiliaires `/chat`, `/gestures`, `/streaming`, `/extractions`** — doublons morts. Bonne décision.
- **Section E (non-décisions)** — tout est juste : pas de React, pas de SSO, pas de WebSocket Audit. Sagesse YAGNI.
### 1.2. Ce qu'on ajuste
**B2 — Retirer onglet 🧠 Apprentissage**
Le plan dit "10 min". Challenge : l'onglet affiche `statCorpusSize` qui est peut-être câblé ailleurs (conftest, training worker, etc.). Avant de retirer, vérifier qu'aucun autre consommateur (jobs, scripts) ne dépend de ces routes. Budget réaliste : **20-30 min** pour grep + vérifier, pas 10.
**B5 — Retirer onglet 📊 Vue d'ensemble**
"30 min" pour retirer + "fusionner un mini-résumé (4 KPIs) en tête de Services" — la fusion n'est pas gratuite. Si Dom veut garder les 4 KPIs, compter **1 h** (déplacement + CSS + test). Si on retire franc et net sans fusion, alors **15 min**. Trancher maintenant.
**Estimation onglet Audit MVP (0.75 j)**
C'est réaliste **à condition** que le proxy Flask `/api/audit/*` → 5005 soit vraiment du copy-paste du pattern `/api/streaming/*`. Mais le plan omet :
- Côté streaming server, le token `RPA_API_TOKEN` est requis → le dashboard doit le propager (fait par le pattern `/api/streaming` mais pas mentionné dans le plan Audit).
- Le volume de données est surestimé : "1 800 entrées aujourd'hui" **faux** — 18 entrées aujourd'hui dans `audit_2026-04-15.jsonl`, 430 le jour de test du 13 avril. Le volume réel est faible, la pagination serveur n'est pas critique pour le POC.
**Rapport PDF DSI (0.5 j)**
Sous-estimé. ReportLab/WeasyPrint sur une page A4 avec tableau + signature d'intégrité, c'est plutôt **1 j**, à cause du templating, de la gestion des polices, du tableau qui déborde, des caractères accentués (French), et surtout du hash chain (voir 1.3).
**Signature d'intégrité journalière (SHA-256)**
Le plan dit "20 lignes". Réaliste côté code, mais il faut :
- décider quand la clôture journalière a lieu (minuit UTC ? heure locale ?),
- stocker les hashes quelque part (fichier `.sig` ? table `audit_signatures` ?),
- rejouer la vérification facilement (`python -m tools.verify_audit YYYY-MM-DD`).
Compter **0.5 j** honnête, pas 0.25.
### 1.3. Ce qu'on retire du plan
**Alerting seuil d'échecs (0.5 j en Sprint 3)**
Pourquoi on le sort : le dashboard est un outil interne déjà bien chargé. Un "badge rouge si >N échecs/h" sans destinataire email configuré = gadget visuel. Si un jour il y a un vrai besoin RSSI, ça passe par n8n (déjà dans le stack) ou Prometheus alerting. **Ne pas le coder ici.**
**Widgets graphiques (camembert + courbe 7j)**
Pas avant validation MVP par un vrai DSI. Le tableau + filtres + export CSV suffisent pour 90 % des cas d'usage. Les graphiques, c'est du polish, à faire après retour client.
### 1.4. Ce qu'on ajoute
**Backup BDD workflows VWB dans l'onglet Sauvegardes**
Aujourd'hui `/api/backup/*` côté dashboard ne touche probablement pas à `visual_workflow_builder/backend/instance/workflows.db`. Or c'est là que vivent les 3 workflows réels de Dom. À vérifier et intégrer au cron backup. **Critique pour le POC.**
**Lien explicite Dashboard → VWB**
Si on retire les onglets "Workflows" et "Corrections", il faut un bouton "Ouvrir VWB" visible. Le plan le mentionne en passant dans "Services" mais ne le tranche pas. À préciser.
**Health check streaming server**
L'onglet Streaming affiche les sessions, mais pas le statut du serveur 5005. Si le serveur tombe, Dom voit l'iframe vide sans message clair. Ajouter un check explicite côté dashboard.
---
## Section 2 — VWB : ce qui tient, ce qui ne tient pas
### 2.1. Ce qu'on valide tel quel
- **B2 (Unnamed Workflow)** — 20 min, impact immédiat, bon.
- **B3 (supprimer vwb_v3.db fantôme)** — 15 min, risque réel identifié. Oui.
- **B4 (double logging)** — confirmé dans les logs (chaque ligne présente 2×), 15 min, fait.
- **B6 (nettoyer fichiers parasites)** — hygiène, 10 min.
- **B7 (run.sh clarification)** — 20 min, évite que Dom et nous lancions le mauvais frontend.
- **Sections D et E (non-décisions)** — toutes justifiées.
### 2.2. Ce qu'on ajuste
**B1 — Migrer sessionStorage → localStorage**
Challenge fort : le plan dit "30 min → résout le bug principal". Je ne suis pas convaincu que `sessionStorage` soit la **seule** cause du bug "la bibliothèque s'efface tout le temps". Hypothèses alternatives à tester **avant** de coder :
1. L'utilisateur ouvre un nouvel onglet (vrai effacement sessionStorage, OK).
2. Un StrictMode React qui double-mount et écrase le state.
3. Un `setCaptureLibrary([])` appelé par erreur dans un `useEffect` sans dépendance.
4. Une exception silencieuse qui reset l'état (QuotaExceededError de sessionStorage si > 5 Mo de base64 PNG).
**Le plan saute direct à la solution sans diagnostic.** Avant de migrer, **reproduire le bug 2 minutes avec la console ouverte** pour voir *quand* il se déclenche. Si c'est un quota, localStorage ne sauvera rien (même limite). **Ne pas coder avant de comprendre.**
Sous réserve que ce soit bien sessionStorage, la migration localStorage est bonne, mais :
- clé `captureLibrary_v3` : bien de ne PAS migrer les deux anciennes clés automatiquement (laisser Dom perdre l'historique mauvais, repartir propre).
- cap 200 captures : OK mais thumbnails JPEG 200×150 q=0.7 au lieu de PNG base64 **impératif** sinon on fait sauter le quota en 15 captures.
Budget réaliste : **1 h** (diagnostic 15 min + migration 30 min + compression thumbnail 15 min).
**B5 — Supprimer 404 /api/correction-packs/stats**
**Erreur factuelle dans le plan** : B5 dit que l'appel vient de `frontend/src/hooks/useCorrectionPacks.ts` (legacy). Confirmé par grep. Mais le plan n'explique pas **pourquoi on voit ces 404 aujourd'hui alors que seul frontend_v4 tourne**. Deux possibilités :
1. Un onglet legacy resté ouvert dans le navigateur — triviale.
2. Un proxy dashboard appelle la route — à vérifier.
Si c'est (1), fermer l'onglet suffit, pas besoin de stubber. Si c'est (2), stubber. Mais **avant de coder, regarder qui appelle**. Budget : **10 min d'enquête + 10 min de fix éventuel**.
**C1 — Finaliser flux Import Léa → review → replay (1 j)**
Sous-estimé. Le plan liste 4 actions, dont "Bouton 'Valider et exécuter' qui passe `review_status='approved'` puis lance le replay via `/execute`". Mais :
- Le `/execute` VWB utilise l'IRBuilder local, pas le replay server Agent V1 (port 5005). Divergence d'exécution.
- Le flux "replay" réussi du 13 avril passe par Agent V1, pas par VWB. Le bouton "Valider et exécuter" dans VWB va donc **exécuter avec un autre moteur** que celui qui a produit le workflow.
- Question non résolue : si Dom valide un workflow importé et que l'exécution VWB échoue, alors qu'Agent V1 l'avait réussi, c'est quoi la vérité ?
Budget réaliste : **2 j**, avec obligation de clarifier "qui exécute quoi" avant de coder le bouton.
**C5 — Lier step ↔ screenshot source (2 j)**
C'est la vraie valeur. Le plan dit que les workflows Léa "contiennent déjà des `screenshot_hash` dans leurs nodes (à vérifier dans notepad_enriched.json)". Le "à vérifier" est critique. Si ce n'est pas le cas, il faut **d'abord modifier le format d'export Léa** avant de toucher VWB, ce qui triple la durée. **Prérequis à lever avant de s'engager sur cet item.**
### 2.3. Ce qu'on retire du plan
**C4 — Consolider les 3 app*.py (1 j)**
Pas avant le POC. Zero impact utilisateur, risque de régression sur le seul endpoint VLM de `app_lightweight.py`. On garde en backlog "quand bande passante". Le plan le met en semaine 3+, correct, mais le listing en quick win serait une tentation.
**C2 — Persister bibliothèque serveur (1.5 j)**
Si B1 fait vraiment son job avec localStorage + compression thumbnails, le serveur n'est pas nécessaire pour le POC. **Ne démarrer C2 que si B1 échoue en usage réel.** Le plan le dit ("si B1 montre ses limites"), mais ne le chiffre pas comme optionnel dans la roadmap — le sortir explicitement.
### 2.4. Ce qu'on ajoute
**Backup quotidien de `workflows.db`**
Le plan le liste dans "Risques" mais ne le met pas comme action. C'est la seule BDD qui contient le travail manuel de Dom. **1 ligne dans `backup_ssd.sh`** (ou cron local). Critique avant POC. **15 min.**
**Versionnement simple des workflows**
Aucun des 2 plans n'en parle. Scénario : Dom modifie un workflow importé, casse quelque chose, veut revenir en arrière. SQLAlchemy n'a pas de versioning natif. Proposition minimale : à chaque `PUT /api/v3/workflow/<id>`, dumper le JSON avant modification dans `data/vwb/workflow_history/<id>/<timestamp>.json`. **30 min**, zero dépendance, ROI fort.
**Nom clair du projet dans la liste VWB**
Si Dom importe 28 workflows du poste DESKTOP-58D5CAC, il va se noyer. Ajouter un filtre par machine + status (pending_review / approved / rejected) dans `WorkflowList.tsx`. **45 min**, grande valeur UX.
---
## Section 3 — Vision système transverse
### 3.1. Dépendances oubliées entre les deux plans
**Audit trail parle à VWB ?**
Le plan Dashboard mentionne `workflow_id` et `workflow_name` dans les colonnes Audit. Or :
- Côté Agent V1, le `workflow_id` est celui du JSON disque.
- Côté VWB, les workflows ont un `id` SQLAlchemy distinct.
- Quand un workflow est importé dans VWB (source='learned_import'), le mapping entre les deux IDs n'est pas explicite.
Conséquence : un DSI qui filtre "workflow = X" dans l'Audit risque de ne pas retrouver le workflow correspondant dans VWB. **À clarifier** avant le sprint Audit MVP.
**Correction packs : 2 plans, 2 décisions contradictoires**
- Plan Dashboard : "supprimer onglet Corrections, les packs sont gérés dans VWB."
- Plan VWB section E3 : "Ne PAS porter CorrectionPacksDashboard sur le v4 — fermer proprement correction_packs."
Les deux disent "on ne fait plus de correction packs ici", mais personne ne dit **où ils vivent maintenant**. Si la réponse est "plus nulle part", il faut archiver proprement les données historiques (packs déjà produits) et le déclarer explicitement.
**Frontend legacy partagé ?**
Le 404 `/api/correction-packs/stats` vient du frontend legacy VWB. Mais il ne serait pas impossible qu'un iframe du dashboard (onglet Corrections) l'ait aussi chargé. Si on retire l'onglet Dashboard **avant** de retirer le frontend legacy VWB, on ne supprime qu'une moitié du problème.
### 3.2. Ordre d'attaque recommandé
**VWB quick wins AVANT Dashboard cleanup.** Raisons :
1. Dom utilise VWB quotidiennement, le bug captures le bloque tout de suite.
2. VWB a des erreurs factuelles à résoudre en amont (diagnostic B1, source des 404).
3. Un Dashboard cleanup, ça se fait en 1 push, le VWB nécessite diagnostic → étaler.
### 3.3. Points d'intégration critiques
- **Token RPA_API_TOKEN** — doit être propagé Dashboard → streaming 5005 (audit) et VWB → streaming 5005 (replay). Fragile si Dom modifie `.env`. **Ajouter un check au démarrage.**
- **Base `workflows.db`** — partagée entre backend VWB et (potentiellement) Agent V1. Vérifier qu'aucune écriture concurrente n'existe (locks SQLite).
- **Volumes `data/audit/` et `data/training/sessions/`** — doivent être dans le backup quotidien. À vérifier dans `backup_ssd.sh`.
---
## Section 4 — Ce qui n'est pas dans les plans mais devrait y être
Par ordre de priorité pour le POC Anouste :
1. **Backup quotidien de `workflows.db` et `data/audit/`**
Aujourd'hui un seul backup du 23/01 dans `backend/instance/backups/`. Si un disque meurt, Dom perd ses 3 workflows de démo + 10 jours d'audit. **Bloquant POC.** 15 min.
2. **Versionnement basique des workflows VWB**
Snapshot à chaque PUT. 30 min. Zero dépendance. Fort ROI dès le 2e client.
3. **Mode dégradé "streaming server indisponible"**
Aujourd'hui si port 5005 tombe, VWB et Dashboard affichent des erreurs cryptiques. Ajouter un badge "Streaming KO — Léa en pause" partout. 1 h.
4. **Isolation multi-client**
Le jour où Anouste + un second client tournent sur la même instance, il n'y a aucune séparation (BDD, audit, sessions). Avant le POC DGX, décider : 1 instance par client ou tag `client_id` partout ? À trancher avec Dom **avant** de coder l'onglet Audit (sinon on refait le schéma).
5. **Observabilité unifiée**
Prometheus existe côté dashboard (`/metrics`), mais pas côté VWB ni streaming server. Pour un hôpital, "pourquoi c'est lent aujourd'hui" = question fréquente. Ajouter 3 métriques clés (replay_duration_ms, vlm_call_ms, faiss_search_ms) exposées en Prometheus sur les 3 services. 2 h.
6. **Documentation d'installation POC**
Ni DEV_SETUP.md ni README n'expliquent comment déployer l'ensemble chez un client. `run.sh --full` suppose l'environnement Dom. Pour Anouste il faut une procédure, sinon c'est Dom qui installe à la main. 2 h.
7. **Anonymisation des logs pour export**
Si un DSI exporte le CSV Audit, il récupère `user_name = "Marie Dupont"`. Fine pour un audit interne, problématique pour une démo publique. Prévoir un flag `--anonymize` sur l'export. 30 min.
8. **Concurrence dashboard**
Aucune protection : 2 onglets ouverts = 2 actions possibles en parallèle. Pour le POC mono-utilisateur ça passe, à tracer pour multi-TIM.
---
## Section 5 — Roadmap recommandée révisée (4 jours)
**Contexte** : POC Anouste dans ~2 semaines. DGX pas encore arrivé. Fenêtre technique ouverte mais finie.
### Jour 1 (4 h) — Sécuriser le quotidien de Dom
- VWB B3 (vwb_v3.db fantôme) : 15 min
- VWB B4 (double logging) : 15 min
- VWB B6 (fichiers parasites) : 10 min
- VWB B7 (run.sh) : 20 min
- **NOUVEAU** — backup quotidien `workflows.db` + `data/audit/` : 15 min
- VWB B2 (Unnamed Workflow) : 20 min
- **Diagnostic B1** (bibliothèque captures, pas de code) : 30 min
- VWB B1 (localStorage + thumbnails JPEG) : 1 h
- Reste : commit + test manuel + pause café.
**Sortie** : Dom ne perd plus ses captures, ses workflows sont sauvegardés, les logs sont lisibles.
### Jour 2 (4-6 h) — Dashboard cleanup + audit MVP backend
- Dashboard B1→B6 (retirer 5 onglets + pages mortes) : 2 h
- **NOUVEAU** — vérifier proxy `workflow_id` VWB ↔ Audit : 30 min
- Dashboard — onglet Audit MVP (proxy + tableau + filtres + export CSV) : 3 h
- **NON** : pas de PDF, pas de patient_ref_hash, pas d'alerting, pas de graphiques.
**Sortie** : dashboard à 9 onglets propres, onglet Audit fonctionnel pour démo POC.
### Jour 3 (4-6 h) — Flux Léa → VWB → replay (C1)
- Diagnostic source des 404 correction-packs/stats : 15 min, fix si nécessaire
- **NOUVEAU** — versionnage workflows VWB (snapshot avant PUT) : 30 min
- **NOUVEAU** — filtre machine + status dans WorkflowList : 45 min
- C1 étape 1 : vérifier `pendingReviewCount` + banner : 1 h
- C1 étape 2 : warnings visuels sur steps importés : 1 h
- C1 étape 3 : bouton "Valider et exécuter" **avec clarification** qui exécute (Agent V1 ou VWB) : 2 h
**Sortie** : Dom peut importer un workflow Léa, voir les étapes floues, corriger, relancer.
### Jour 4 (4 h) — Hardening POC Anouste
- **NOUVEAU** — mode dégradé streaming KO (3 services) : 1 h
- **NOUVEAU** — 3 métriques Prometheus sur VWB + streaming : 2 h
- **NOUVEAU** — doc installation POC (README_DEPLOY_POC.md) : 1 h
**Sortie** : POC déployable chez Anouste, observable, résilient aux pannes.
### Ce qu'on garde en backlog (pas avant POC)
- VWB C3 (retirer frontend legacy), C4 (consolider app*.py), C5 (screenshot source par step), C2 (captures serveur)
- Dashboard Sprint 3 complet (PDF DSI, patient_ref_hash, signature intégrité, widgets, alerting)
- Dashboard Sprint 4 (améliorations Services, Sessions, Logs, Config)
---
## Section 6 — Risques à surveiller pendant l'exécution
| # | Risque | Probabilité | Impact | Mitigation |
|---|---|---|---|---|
| R1 | B1 localStorage ne résout pas le vrai bug (cause racine différente) | Moyenne | Moyen | Diagnostic avant code. 15 min budgétées. |
| R2 | Suppression d'un onglet Dashboard casse un script externe qui appelait la route | Faible | Moyen | Grep workspace complet avant suppression. `n8n`, `agent_chat`, `core` peuvent consommer. |
| R3 | Proxy dashboard→streaming 5005 échoue sur token RPA_API_TOKEN | Moyenne | Moyen | Reproduire le pattern `/api/streaming/*` à la lettre. Tester avec `curl` direct avant UI. |
| R4 | Workflow importé non rejouable (format Léa incompatible bridge) | Moyenne | Fort | Tester C1 sur le workflow `notepad_enriched.json` en premier. Si KO, pivoter sur C5 avant C1. |
| R5 | `workflow_id` Audit ≠ `id` VWB → filtre DSI casse | Forte | Faible (hors POC) | Documenter dans l'export CSV : "workflow_id = source disque, voir VWB pour UI". Fix propre post-POC. |
| R6 | Backup `workflows.db` oublié, crash disque avant POC | Faible | Critique | Backup manuel aujourd'hui, automatisation demain. |
| R7 | Cleanup Dashboard supprime une route consommée par Agent V1 | Faible | Fort | Routes retirées : `/api/automation/*`, `/api/tests/*`, `/api/gestures`, `/api/chat/*`. Grep avant. |
| R8 | Onglet Audit "demi-fonctionnel" montré à Anouste produit plus de méfiance que rien | Moyenne | Fort | MVP uniquement (tableau + filtres + CSV). Pas de widgets creux. |
| R9 | Régression frontend v4 après suppression legacy (C3 reporté, donc risque faible immédiat) | Faible | Moyen | C3 **pas en roadmap 4 jours**. Post-POC. |
| R10 | Exécution VWB diverge de replay Agent V1 → incohérence démo | Forte | Fort | Clarifier quel moteur exécute en bouton "Valider et exécuter". Préférer Agent V1 via appel 5005. |
| R11 | AI Act / RGPD — absence de patient_ref_hash repérée par DSI Anouste | Faible (POC early) | Moyen | Documenter la limitation dans DOSSIER_COMMISSAIRE_AUX_APPORTS. Planifier Sprint 3 post-POC. |
| R12 | 2 personnes éditent workflows.db simultanément (Dom + un TIM pendant démo) | Faible (POC mono) | Fort | SQLite verrou exclusif = erreur propre. Documenter "VWB mono-utilisateur pour l'instant". |
---
## Résumé exécutif pour Dom
1. **Commence par VWB B3+B4+B6+B7+backup (1 h 30)** — hygiène, zéro risque, gains immédiats.
2. **Puis diagnostic B1 AVANT de coder** — 15 min pour éviter de coder une fausse solution.
3. **Dashboard cleanup + Audit MVP (1 journée)** — retire les onglets morts, ajoute l'onglet Audit minimal. Pas de PDF ni d'alerting avant retour client.
4. **Flux C1 (1 journée)** — la vraie valeur visible de ton idée "importer Léa, corriger".
5. **Hardening POC (demi-journée)** — backups, métriques, doc deploy. Sinon le POC Anouste sera douloureux.
6. **Tout le reste (C2, C3, C4, C5, Dashboard Sprint 3-4) : après POC.**
Les deux plans sont solides. Ils manquent juste de **connexions entre eux** et sous-estiment **le hardening POC**. Ce document les relie et priorise pour la fenêtre de 2 semaines avant Anouste.
---
_Fin du challenge — 16 avril 2026._

View File

@@ -0,0 +1,658 @@
# Dossier de Présentation Technique — Apport en Nature
## Logiciel RPA Vision V3
**Document destiné au Commissaire aux Apports**
---
| | |
|---|---|
| **Projet** | RPA Vision V3 — Plateforme d'automatisation intelligente par vision |
| **Auteur principal** | Dom — Architecte / Expert principal |
| **Profil** | 32 ans d'expérience en informatique de pointe (sécurité, IA, infrastructure, robotique, direction de projet, industrialisation) |
| **Historique du projet** | Premier jet il y a ~5 ans (V1). Version actuelle (V3) développée sur ~12 mois (préparation + développement actif) |
| **Date du présent document** | 25 février 2026 |
| **Nature de l'apport** | Logiciel, code source, propriété intellectuelle associée |
---
## Table des matières
1. [Résumé exécutif](#1-résumé-exécutif)
2. [Description fonctionnelle](#2-description-fonctionnelle)
3. [Architecture technique](#3-architecture-technique)
4. [Stack technologique](#4-stack-technologique)
5. [Métriques de développement](#5-métriques-de-développement)
6. [Fonctionnalités clés et innovations](#6-fonctionnalités-clés-et-innovations)
7. [État d'avancement](#7-état-davancement)
8. [Positionnement concurrentiel](#8-positionnement-concurrentiel)
9. [Marché adressable](#9-marché-adressable)
10. [Inventaire des dépendances open-source et licences](#10-inventaire-des-dépendances-open-source-et-licences)
11. [Éléments de valorisation](#11-éléments-de-valorisation)
---
## 1. Résumé exécutif
**RPA Vision V3** est une plateforme d'automatisation robotisée des processus (RPA) de nouvelle génération. Contrairement aux solutions existantes (UiPath, Automation Anywhere, Blue Prism) qui reposent sur des sélecteurs HTML/UI fragiles, RPA Vision V3 utilise la **vision par ordinateur et l'intelligence artificielle multimodale** pour comprendre sémantiquement les interfaces utilisateur.
Cette approche résout un problème fondamental du marché RPA : **40 % des robots échouent** lorsque les interfaces changent, et **30 % du marché entreprise** (environnements Citrix/VDI, mainframes, systèmes air-gapped) reste inaccessible aux solutions conventionnelles.
Le logiciel est le fruit d'un travail intensif de conception, développement et intégration mené par l'auteur principal, combinant expertise en intelligence artificielle, vision par ordinateur et ingénierie logicielle.
---
## 2. Description fonctionnelle
### Problème résolu
Les solutions RPA traditionnelles présentent trois faiblesses majeures :
- **Fragilité** — Les sélecteurs CSS/XPath cassent dès qu'une interface est mise à jour, entraînant 60 à 70 % des budgets RPA en maintenance
- **Inaccessibilité** — Les environnements Citrix/VDI, mainframes legacy et systèmes air-gapped (défense, santé) restent hors de portée
- **Rigidité** — Aucune capacité d'adaptation autonome aux changements d'interface
### Solution apportée
RPA Vision V3 automatise les processus métier en :
- **Voyant l'écran** comme un humain (aucun sélecteur, aucune coordonnée fixe)
- **Comprenant sémantiquement** les éléments d'interface (bouton, champ de texte, menu, etc.)
- **S'auto-réparant** lorsqu'une interface change (4 stratégies de récupération)
- **Apprenant continuellement** des exécutions passées pour améliorer sa fiabilité
- **Fonctionnant en local** (aucune donnée envoyée dans le cloud — conformité RGPD/défense)
### Composants fonctionnels
| Composant | Rôle |
|-----------|------|
| **Visual Workflow Builder (VWB)** | Interface web de conception visuelle de workflows (drag & drop) |
| **Moteur d'exécution** | Exécute les workflows avec gestion d'erreurs et auto-réparation |
| **Agent de capture** | Capture cross-plateforme des événements et screenshots |
| **Moteur de détection UI** | Détection hybride des éléments d'interface (IA + vision classique) |
| **Système d'embeddings** | Empreintes multimodales des états d'écran (FAISS, CLIP) |
| **Système d'apprentissage** | Apprentissage progressif et détection de dérive |
| **Dashboard de monitoring** | Tableau de bord temps réel des exécutions et analytics |
| **Catalogue d'actions** | 24+ actions prêtes à l'emploi (clic, saisie, navigation, OCR, IA, etc.) |
---
## 3. Architecture technique
### Architecture en 5 couches
```
Couche 0 : RawSession — Capture brute (événements + screenshots)
Couche 1 : ScreenState — Analyse multi-modale (4 niveaux d'abstraction)
Couche 2 : UIElement Detection — Détection sémantique des éléments UI
Couche 3 : State Embedding — Fusion multimodale (empreinte digitale d'écran)
Couche 4 : Workflow Graph — Graphe de nœuds + apprentissage
```
### Structure du projet
```
rpa_vision_v3/
├── core/ # Moteur IA (192 fichiers Python)
│ ├── analytics/ # Collecte et reporting d'analytics
│ ├── capture/ # Capture d'écran et d'événements
│ ├── detection/ # Détection UI hybride (OWL-v2 + OpenCV + VLM)
│ ├── embedding/ # Embeddings CLIP, FAISS, fusion multimodale
│ ├── execution/ # Exécution des actions et robustesse
│ ├── healing/ # Auto-réparation (4 stratégies)
│ ├── learning/ # Apprentissage continu
│ ├── matching/ # Matching hiérarchique
│ ├── monitoring/ # Métriques et ordonnancement
│ ├── security/ # Audit, tokens, validation
│ ├── system/ # Circuit breaker, auto-heal manager
│ └── training/ # Entraînement offline
├── visual_workflow_builder/ # Application web full-stack
│ ├── frontend_v4/ # React 18 + TypeScript + Vite
│ └── backend/ # Flask + SocketIO + SQLAlchemy
│ ├── actions/ # Catalogue de 24+ actions
│ ├── api/ # Endpoints REST et WebSocket
│ ├── contracts/ # Contrats d'interface
│ └── services/ # Services métier (OCR, détection, etc.)
├── agent_v0/ # Agent de capture cross-plateforme
├── server/ # API de traitement (FastAPI)
├── web_dashboard/ # Dashboard de monitoring
├── gui/ # Interface desktop (PyQt5)
├── models/ # Modèles IA pré-entraînés
└── tests/ # Suite de tests
```
---
## 4. Stack technologique
### Intelligence artificielle et Machine Learning
| Technologie | Rôle | Licence |
|-------------|------|---------|
| PyTorch 2.x | Framework de deep learning | BSD-3-Clause |
| OpenCLIP (ViT-B-32) | Embeddings vision-langage (512 dimensions) | MIT |
| FAISS | Recherche vectorielle (1M+ embeddings, <100ms) | MIT / BSD-3-Clause |
| Qwen3-VL 8B (via Ollama) | Modèle de vision-langage local | Apache-2.0 |
| OWL-v2 | Détection d'objets zero-shot | Apache-2.0 |
| HuggingFace Transformers | Pipeline de modèles IA | Apache-2.0 |
| docTR (Mindee) | OCR (reconnaissance de caractères) | Apache-2.0 |
### Vision par ordinateur
| Technologie | Rôle | Licence |
|-------------|------|---------|
| OpenCV 4.x | Traitement d'image | Apache-2.0 |
| Pillow | Manipulation d'images | MIT-CMU |
| MSS | Capture d'écran rapide | MIT |
### Backend
| Technologie | Rôle | Licence |
|-------------|------|---------|
| Python 3.12 | Langage principal | PSF |
| Flask 3.0 | Framework web (VWB) | BSD |
| FastAPI | API de traitement (serveur) | MIT |
| Flask-SocketIO | Communication temps réel | MIT |
| SQLAlchemy 2.0 | ORM base de données | MIT |
| Redis | Cache et files d'attente | MIT |
| Pydantic | Validation de données | MIT |
### Frontend
| Technologie | Rôle | Licence |
|-------------|------|---------|
| React 18 | Framework UI | MIT |
| TypeScript 5.x | Typage statique | Apache-2.0 |
| Vite 5 | Build tool | MIT |
| @xyflow/react 12 | Graphes visuels de workflows | MIT |
### Sécurité et infrastructure
| Technologie | Rôle | Licence |
|-------------|------|---------|
| AES-256-GCM | Chiffrement des sessions | (standard cryptographique) |
| Authentification par tokens | Contrôle d'accès | Développement interne |
| Audit JSONL | Journalisation sécurisée | Développement interne |
---
## 5. Métriques de développement
### Volume de code source (hors dépendances, hors tests)
| Composant | Fichiers | Lignes de code | Langage |
|-----------|----------|----------------|---------|
| Core (moteur IA) | 192 | ~63 800 | Python |
| VWB Backend | 115 | ~42 100 | Python |
| VWB Frontend | 24 | ~6 260 | TypeScript/React |
| Server API | 8 | ~2 900 | Python |
| Agent V0 | 25 | ~7 700 | Python |
| Tests | 177 | ~66 900 | Python |
| **Total** | **~541** | **~189 660** | |
### Historique de développement
Le logiciel RPA Vision V3 est le résultat de **trois itérations majeures** sur une période de 5 ans :
| Version | Période | Rôle |
|---------|---------|------|
| **V1** (premier jet) | ~2021 | Preuve de concept — exploration de l'approche vision pour le RPA |
| **V2** (évolution) | 2022-2024 | Prototypage avancé — validation des choix architecturaux |
| **V3** (version actuelle) | mars 2025 — février 2026 | Développement complet — architecture 5 couches, production-ready |
**Dépôt git V3** (code source livré) :
| Métrique | Valeur |
|----------|--------|
| Nombre de commits | 52 |
| Premier commit V3 | 7 janvier 2026 |
| Dernier commit | 18 février 2026 |
| Contributeur principal | Dom |
| Insertions totales (git) | ~479 000 lignes |
> **Note** : Le dépôt git ne reflète que la phase finale de codage de la V3. Le travail de conception, de R&D et les itérations V1/V2 qui ont fondé l'architecture ne figurent pas dans l'historique de commits mais constituent une part essentielle de la valeur intellectuelle du projet.
### Effort réel de développement
| Phase | Durée | Intensité | Heures estimées |
|-------|-------|-----------|-----------------|
| R&D initiale / V1 et V2 (~5 ans) | ~3 ans cumulés | Variable | Non quantifié — valeur de savoir-faire accumulé |
| Travail préparatoire V3 (conception, veille, architecture) | ~4 mois | ~6 h/jour | ~530 h |
| Développement actif V3 | ~8 mois | ~10-12 h/jour | ~1 760 à 2 100 h |
| **Total effort V3** | **~12 mois** | | **~2 300 à 2 600 h** |
### Profil de l'auteur
- **58 ans**, 32 ans d'expérience en informatique de pointe
- Spécialisations : sécurité, intelligence artificielle (tous niveaux), infrastructure, robotique
- Capacité démontrée à créer des systèmes from scratch, du POC au MVP puis à l'industrialisation
- Direction d'entreprise, direction de projet, développement
- Créateur d'un framework de gestion de projets faisant appel aux nouvelles technologies
- Profil équivalent marché : **Architecte / Expert principal IA** — TJM de référence : 1 200 €/jour
---
## 6. Fonctionnalités clés et innovations
### 6.1 Fusion multimodale d'états d'écran
Chaque état d'écran est résumé en une empreinte vectorielle combinant 4 modalités :
- 50 % Image (screenshot complet via CLIP)
- 30 % Texte (texte détecté)
- 10 % Titre (fenêtre active)
- 10 % UI (éléments détectés)
**Performance** : 0,02 ms par embedding (contrainte : <100 ms) — **500x** plus rapide que le standard.
### 6.2 Auto-réparation en 4 stratégies
Lorsqu'un élément d'interface n'est plus trouvé, le système applique en cascade :
1. **Variantes sémantiques** — Essai de variations visuelles/textuelles
2. **Fallback spatial** — Recherche dans le voisinage
3. **Adaptation temporelle** — Ajustement des temps d'attente
4. **Transformation de format** — Transformation des données d'entrée
Taux de récupération : >95 % des erreurs transitoires, en <30 secondes.
### 6.3 Apprentissage progressif
```
OBSERVATION (5+ exécutions)
COACHING (10+ assistances, >90 % de succès)
AUTO_CANDIDATE (20+ exécutions, >95 % de succès)
AUTO_CONFIRMED (validation utilisateur)
```
Le système détecte automatiquement les dérives d'interface et crée des variantes.
### 6.4 Détection UI hybride
Combine trois approches complémentaires :
- **OWL-v2** : Détection zero-shot (aucun entraînement nécessaire)
- **OpenCV** : Techniques de vision classique
- **VLM (Qwen3-VL)** : Compréhension sémantique via modèle de vision-langage
Détecte 10+ types d'éléments UI avec rôles sémantiques (primary_action, form_input, etc.).
### 6.5 Circuit breaker et résilience
Système de disjoncteur à 5 états (RUNNING, DEGRADED, QUARANTINED, PAUSED, ROLLBACK) inspiré des patterns de production enterprise, avec journalisation d'audit complète.
### 6.6 Exécution 100 % locale
Aucune dépendance cloud. Tous les modèles IA tournent en local (GPU), garantissant la conformité RGPD et l'utilisation en environnements classifiés/air-gapped.
---
## 7. État d'avancement
### Phases complétées (10/13 — 77 %)
| Phase | Description | Statut |
|-------|-------------|--------|
| 1-2 | Fondations + Embeddings FAISS | Terminé |
| 4-6 | Détection UI + Graphes Workflow + Exécution | Terminé |
| 7-8 | Système d'apprentissage + Entraînement | Terminé |
| 10-12 | Gestion GPU + Performance + Monitoring | Terminé |
### Phases restantes (3/13 — 23 %)
| Phase | Description | Statut |
|-------|-------------|--------|
| 3 | Checkpoint final (tests de stockage) | En cours |
| 9 | Visual Workflow Builder (90 % → 100 %) | En cours |
| 13 | Tests end-to-end + Documentation finale | À faire |
### Composants prêts pour la production
- Agent de capture cross-plateforme avec chiffrement AES-256
- Pipeline de traitement serveur + dashboard web
- Système d'analytics et monitoring temps réel
- Auto-réparation et adaptation automatique
---
## 8. Positionnement concurrentiel
### Comparaison avec les solutions existantes
| Critère | UiPath / AA / BluePrism | RPA Vision V3 |
|---------|------------------------|---------------|
| Méthode de détection | Sélecteurs CSS/XPath | Vision par IA |
| Robustesse aux changements UI | Faible (cassure fréquente) | Forte (auto-réparation) |
| Environnements Citrix/VDI | Support limité/payant | Natif |
| Mainframes / Legacy | Non supporté | Supporté |
| Systèmes air-gapped | Non | Oui (100 % local) |
| Apprentissage autonome | Non | Oui (4 niveaux) |
| Coût de maintenance | 60-70 % du budget | Réduit par auto-réparation |
| Cloud requis | Souvent | Jamais |
### Avance technologique estimée
- **2 à 3 ans** d'avance sur l'approche vision-native par rapport aux acteurs traditionnels
- Architecture conçue dès le départ pour la vision (pas un ajout a posteriori)
- Score de moat technique : **85/100** (analyse détaillée disponible)
---
## 9. Marché adressable
### Segments cibles (sous-servis par les solutions existantes)
| Segment | Taille estimée | Problème |
|---------|---------------|----------|
| Citrix / VDI | 3,9 Mds $ | Interfaces sans DOM accessible |
| Legacy / Mainframe | 2,6 Mds $ | Aucun sélecteur disponible |
| Défense / Air-gapped | 1,3 Mds $ | Exigence 100 % local, pas de cloud |
| Santé (RGPD) | 1,8 Mds $ | Données sensibles, conformité stricte |
| **Total adressable** | **~9,6 Mds $** | |
### Marché RPA global
- **2024** : 13 milliards $ — **2030** : 30 milliards $ (CAGR 15 %)
- La transition vers l'IA/vision est un mouvement de fond du secteur
---
## 10. Inventaire des dépendances open-source et licences
Le logiciel RPA Vision V3 est un **développement propriétaire original** qui s'appuie sur des bibliothèques open-source. La propriété intellectuelle réside dans :
- L'architecture 5 couches et sa conception
- Les algorithmes de fusion multimodale
- Le système d'auto-réparation en 4 stratégies
- Le système d'apprentissage progressif
- Le catalogue d'actions et l'intégration complète
- Le Visual Workflow Builder
### 10.1 Dépendances Python directes (requirements.txt)
| Package | Version | Licence | Usage |
|---------|---------|---------|-------|
| numpy | 2.2.x | BSD | Calcul numérique |
| torch | 2.9+ | BSD-3-Clause | Deep learning |
| torchvision | 0.24+ | BSD | Utilitaires vision |
| transformers | 4.57+ | Apache-2.0 | Modèles HuggingFace |
| open_clip_torch | 3.2.x | MIT | Embeddings CLIP |
| faiss-cpu | 1.13.x | MIT / BSD-3-Clause | Recherche vectorielle |
| Pillow | 12.x | MIT-CMU | Manipulation d'images |
| PyQt5 | 5.15.x | **GPL v3** | Interface desktop (GUI) |
| requests | 2.32.x | Apache-2.0 | Requêtes HTTP |
| scikit-learn | 1.7.x | BSD-3-Clause | Machine learning classique |
| opencv-python | 4.12.x | Apache-2.0 | Vision par ordinateur |
| mss | 10.1.x | MIT | Capture d'écran |
| python-doctr | 1.0.x | Apache-2.0 | OCR (reconnaissance de texte) |
| pytest | 9.x | MIT | Tests unitaires |
| hypothesis | 6.x | MPL-2.0 | Tests property-based |
### 10.2 Dépendances VWB Backend
| Package | Version | Licence | Usage |
|---------|---------|---------|-------|
| Flask | 3.0.x | BSD | Framework web |
| Flask-SocketIO | 5.3.x | MIT | WebSocket temps réel |
| Flask-CORS | 4.0.x | MIT | Cross-origin |
| SQLAlchemy | 2.0.x | MIT | ORM base de données |
| Flask-SQLAlchemy | 3.1.x | BSD-3-Clause | Intégration Flask/SQLAlchemy |
| marshmallow | 3.20.x | MIT | Sérialisation |
| redis | 5.0.x | MIT | Cache |
| pydantic | 2.5.x | MIT | Validation de données |
| jsonschema | 4.20.x | MIT | Validation JSON |
| python-dotenv | 1.0.x | BSD-3-Clause | Variables d'environnement |
| black | 23.x | MIT | Formatage de code |
| flake8 | 6.x | MIT | Linting |
| mypy | 1.7.x | MIT | Vérification de types |
### 10.3 Dépendances Server (FastAPI)
| Package | Version | Licence | Usage |
|---------|---------|---------|-------|
| fastapi | 0.115+ | MIT | API REST |
| uvicorn | 0.30+ | BSD-3-Clause | Serveur ASGI |
| python-multipart | 0.0.6+ | Apache-2.0 | Upload de fichiers |
| cryptography | 41+ | Apache-2.0 / BSD-3-Clause | Chiffrement AES-256 |
### 10.4 Dépendances JavaScript/Frontend (package.json)
| Package | Version | Licence | Usage |
|---------|---------|---------|-------|
| react | 18.3.x | MIT | Framework UI |
| react-dom | 18.3.x | MIT | Rendu DOM |
| @xyflow/react | 12.10.x | MIT | Éditeur visuel de graphes |
| typescript | 5.x | Apache-2.0 | Typage statique |
| vite | 5.x | MIT | Build tool |
| @vitejs/plugin-react | 4.x | MIT | Plugin React pour Vite |
| @mui/material | 7.x | MIT | Composants UI Material Design |
| @reduxjs/toolkit | 2.x | MIT | Gestion d'état |
| axios | 1.x | MIT | Client HTTP |
| socket.io-client | 4.x | MIT | WebSocket client |
### 10.5 Dépendances transitives notables
| Package | Licence | Catégorie |
|---------|---------|-----------|
| huggingface-hub | Apache-2.0 | IA / téléchargement de modèles |
| safetensors | Apache-2.0 | Sérialisation de modèles |
| tokenizers | Apache-2.0 | Tokenisation NLP |
| timm | Apache-2.0 | Modèles de vision |
| scipy | BSD | Calcul scientifique |
| networkx | BSD | Manipulation de graphes |
| tqdm | MIT / MPL-2.0 | Barres de progression |
| protobuf | BSD-3-Clause | Sérialisation de données |
| PyYAML | MIT | Parsing YAML |
| certifi | MPL-2.0 | Certificats SSL |
### 10.6 Bibliothèques NVIDIA CUDA (15 packages)
| Package | Licence |
|---------|---------|
| nvidia-cublas-cu12, nvidia-cuda-cupti-cu12, nvidia-cuda-nvrtc-cu12, nvidia-cuda-runtime-cu12, nvidia-cudnn-cu12, nvidia-cufft-cu12, nvidia-cufile-cu12, nvidia-curand-cu12, nvidia-cusolver-cu12, nvidia-cusparse-cu12, nvidia-cusparselt-cu12, nvidia-nccl-cu12, nvidia-nvjitlink-cu12, nvidia-nvshmem-cu12, nvidia-nvtx-cu12 | **NVIDIA Proprietary** (usage gratuit, redistribution encadrée) |
### 10.7 Synthèse des licences
| Type de licence | Nombre de packages | Compatibilité commerciale |
|----------------|-------------------|--------------------------|
| MIT | ~40 | Permissive — usage commercial libre |
| Apache-2.0 | ~18 | Permissive — usage commercial libre |
| BSD / BSD-3-Clause | ~22 | Permissive — usage commercial libre |
| MPL-2.0 | 2 | Permissive (fichier par fichier) |
| **GPL v3** | **1 (PyQt5)** | **Copyleft — voir note ci-dessous** |
| LGPL v3 | 1 (PyQt5-Qt5) | Copyleft faible |
| NVIDIA Proprietary | 15 | Gratuit, redistribution encadrée |
### 10.8 Notes de conformité
1. **PyQt5 (GPL v3)** — Utilisé uniquement pour l'interface desktop optionnelle (`gui/`, 3 fichiers). L'application principale (Visual Workflow Builder) utilise React et n'est pas concernée. Option : migration vers PySide6 (LGPL) ou licence commerciale Qt si distribution du composant GUI.
2. **NVIDIA CUDA** — Les bibliothèques CUDA sont propriétaires mais gratuites. Leur usage est conforme aux conditions de la licence NVIDIA pour le développement et le déploiement.
3. **Majorité permissive** — Plus de 80 % des dépendances utilisent des licences permissives (MIT, Apache-2.0, BSD), pleinement compatibles avec un usage commercial et une distribution propriétaire.
4. **Code propriétaire** — L'intégralité du code source développé spécifiquement pour RPA Vision V3 (architecture, algorithmes, intégrations) est propriétaire et constitue l'essentiel de la valeur de l'apport.
---
## 11. Éléments de valorisation
### 11.1 Coût de développement réel (méthode des coûts historiques)
Investissement effectivement consenti par l'auteur pour la version 3 :
| Poste | Calcul | Montant |
|-------|--------|---------|
| Travail préparatoire (conception, veille, architecture) | ~530 h × 150 €/h (TJM 1 200 € ÷ 8h) | 79 500 € |
| Développement actif V3 | ~2 100 h × 150 €/h | 315 000 € |
| **Sous-total main-d'œuvre V3** | **~2 630 h** | **394 500 €** |
| Matériel — station de travail (AMD Ryzen 9, 128 Go RAM, RTX 5070) | | 3 000 € |
| Matériel — Jetson Nano (tests embarqués) | | 400 € |
| Coûts IA (API, modèles, inférence) | | 200 € |
| **Total coût historique V3** | | **~398 100 €** |
> **Note** : Ce calcul ne valorise pas les ~3 ans de R&D cumulés sur les versions 1 et 2, qui ont directement alimenté la conception de la V3 (choix d'architecture, sélection des modèles IA, retours d'expérience). Ce savoir-faire accumulé est inclus dans la valeur de l'apport mais non chiffré séparément.
### 11.2 Coût de reproduction par un tiers (méthode recommandée)
Le coût de reproduction estime l'investissement qu'une entreprise tierce devrait consentir pour développer un logiciel **fonctionnellement équivalent** en partant de zéro, sans bénéficier des 5 ans d'itérations V1/V2.
#### Scénario A — Profil unique équivalent (improbable)
| Poste | Calcul | Montant |
|-------|--------|---------|
| Architecte IA senior multi-compétences | 2 630 h × 150 €/h | 394 500 € |
> Ce scénario suppose l'existence d'un profil aussi polyvalent (IA + full-stack + sécurité + infra + vision). Ce type de profil est extrêmement rare sur le marché.
#### Scénario B — Équipe spécialisée (réaliste)
Une entreprise devrait constituer une équipe de 3-4 personnes sur 12 à 18 mois :
| Poste | Durée | TJM | Montant |
|-------|-------|-----|---------|
| Lead architect / Chef de projet IA | 12 mois × 22 j | 1 200 €/j | 316 800 € |
| Ingénieur ML / Vision par ordinateur | 10 mois × 22 j | 900 €/j | 198 000 € |
| Développeur full-stack senior (React + Python) | 10 mois × 22 j | 700 €/j | 154 000 € |
| DevOps / Infra GPU (temps partiel) | 4 mois × 22 j | 650 €/j | 57 200 € |
| **Sous-total main-d'œuvre** | | | **726 000 €** |
| Matériel et infrastructure (GPU, serveurs de dev) | | | 5 000 € |
| Coûts IA (API, modèles, calcul) | | | 2 000 € |
| Marge d'incertitude technique (+15 %) | | | 109 950 € |
| **Total coût de reproduction** | | | **~843 000 €** |
> **Justification de la marge** : Un tiers ne bénéficierait pas des retours d'expérience des V1/V2 et devrait absorber des cycles de recherche supplémentaires (choix de modèles, benchmarks, impasses techniques).
#### Synthèse des valorisations
| Méthode | Montant | Commentaire |
|---------|---------|-------------|
| Coût historique (V3 seule) | ~398 000 € | Plancher — ne valorise pas la R&D V1/V2 |
| Reproduction par un tiers (équipe) | ~843 000 € | Estimation réaliste — inclut marge d'incertitude |
| **Fourchette de valorisation recommandée** | **400 000 € — 850 000 €** | Selon la méthode retenue par le commissaire |
### 11.3 Actifs incorporels composant l'apport
| Actif | Description | Quantification |
|-------|-------------|---------------|
| **Code source propriétaire** | Moteur IA, VWB, Agent, Server, Dashboard | ~190 000 lignes (Python, TypeScript) |
| **Architecture logicielle** | Conception originale 5 couches, documentation | 14 modules architecturaux |
| **Algorithmes propriétaires** | Fusion multimodale, auto-réparation 4 stratégies, apprentissage progressif 4 niveaux | Développements originaux |
| **Catalogue d'actions** | Actions prêtes à l'emploi pour l'automatisation | 24+ actions |
| **Suite de tests** | Tests unitaires, intégration, property-based | ~67 000 lignes |
| **Savoir-faire accumulé** | 5 ans d'itérations (V1 → V3), intégration de modèles IA en pipeline local | Non quantifiable — valeur intrinsèque |
| **Documentation technique** | Architecture, API, guides, spécifications | Corpus documentaire complet |
### 11.3 Comparables marché
| Solution | Valorisation | CA / ARR | Source |
|----------|-------------|----------|--------|
| **UiPath** (NYSE: PATH) | ~8,8 Mds $ (capitalisation déc. 2025) | CA : 1,43 Md $ / ARR : 1,67 Md $ (FY2025) | [UiPath IR — FY2025 Results](https://ir.uipath.com/news/detail/381/uipath-reports-fourth-quarter-and-full-year-fiscal-2025-financial-results) |
| **Automation Anywhere** | 6,8 Mds $ (Series D, oct. 2025) | Non divulgué (privé) | [Tracxn — AA Funding](https://tracxn.com/d/companies/automation-anywhere/__tre2zh_F5voAIrD5MmsvheJ0drmtTXyaT3m8-w_KaZ0/funding-and-investors) |
| **SS&C Blue Prism** | 1,6 Md $ (acquisition par SS&C, 2022) | ~211 M$ (post-acquisition) | [SS&C Blue Prism Acquisition](https://info.ssctech.com/blue-prism-acquisition) |
| **Sema4.ai** (ex-Robocorp) | 30,5 M$ levés (2024) | Early stage | [Sema4.ai — PR Newswire](https://www.prnewswire.com/news-releases/sema4-ai-raises-30-5-million-to-bring-open-source-powered-ai-to-mission-critical-enterprise-work-302047158.html) |
**Contexte** : UiPath, Automation Anywhere et SS&C Blue Prism sont identifiés comme « Leaders » dans le [Gartner Magic Quadrant for RPA 2025](https://www.gartner.com/en/documents/6632834) (publié juin 2025, 7e année consécutive pour les trois). RPA Vision V3 se positionne dans le segment des solutions IA-natives pour RPA, avec une approche différenciante (vision pure, 100 % local) ciblant les segments inaccessibles aux leaders actuels.
---
## 12. Références et sources
### 12.1 Marché RPA — Taille et prévisions
| Source | Donnée | Lien |
|--------|--------|------|
| **Grand View Research** | Marché RPA mondial : 4,68 Mds $ (2025) → 35,84 Mds $ (2033), CAGR 29,0 % | [Grand View Research — RPA Market](https://www.grandviewresearch.com/industry-analysis/robotic-process-automation-rpa-market) |
| **Precedence Research** | Marché RPA : 28,31 Mds $ (2025) → 247,34 Mds $ (2035), CAGR 24,2 % | [Precedence Research — RPA Market](https://www.precedenceresearch.com/robotic-process-automation-market) |
| **Gartner** | Marché RPA : 3,79 Mds $ (2024) → 30,85 Mds $ (2030), CAGR 43,9 % | [Gartner — Market Share Analysis RPA 2024](https://www.gartner.com/en/documents/6842834) |
| **Statista** | Prévision marché RPA mondial jusqu'en 2030 | [Statista — RPA Market Size](https://www.statista.com/statistics/1259903/robotic-process-automation-market-size-worldwide/) |
> **Note** : Les écarts entre sources reflètent des périmètres de définition différents (RPA strict vs. hyperautomation). Le consensus est un CAGR de 24 à 44 % selon le périmètre.
### 12.2 Produits concurrents — Données financières
| Acteur | Donnée | Source |
|--------|--------|--------|
| **UiPath** — CA FY2025 : 1,43 Md $, croissance +9 %, ARR 1,67 Md $, 2 292 clients >100k$ ARR | [UiPath — Q4 & FY2025 Results](https://ir.uipath.com/news/detail/381/uipath-reports-fourth-quarter-and-full-year-fiscal-2025-financial-results) |
| **UiPath** — Capitalisation boursière ~8,8 Mds $ (déc. 2025) | [MacroTrends — UiPath Market Cap](https://www.macrotrends.net/stocks/charts/PATH/uipath/market-cap) |
| **Automation Anywhere** — Série D : 290 M$ levés, valorisation 6,8 Mds $ (oct. 2025), total levé : 840 M$ | [Tracxn — AA Funding](https://tracxn.com/d/companies/automation-anywhere/__tre2zh_F5voAIrD5MmsvheJ0drmtTXyaT3m8-w_KaZ0/funding-and-investors) |
| **SS&C Blue Prism** — Acquis par SS&C Technologies pour 1,6 Md $ (mars 2022) | [SS&C — Blue Prism Acquisition](https://info.ssctech.com/blue-prism-acquisition) |
| **Sema4.ai** (acquéreur de Robocorp) — 30,5 M$ levés, Robocorp acquis janv. 2024 | [PR Newswire — Sema4.ai](https://www.prnewswire.com/news-releases/sema4-ai-raises-30-5-million-to-bring-open-source-powered-ai-to-mission-critical-enterprise-work-302047158.html) |
### 12.3 Analystes et classements sectoriels
| Source | Donnée | Lien |
|--------|--------|------|
| **Gartner Magic Quadrant for RPA 2025** | Leaders : UiPath, Automation Anywhere, SS&C Blue Prism (7e année consécutive). 13 éditeurs évalués. | [Gartner — MQ RPA 2025](https://www.gartner.com/en/documents/6632834) |
| **UiPath** — Communiqué leader MQ 2025 | Reconnu leader pour la 7e année, meilleur score « Ability to Execute » | [UiPath — MQ 2025 Press Release](https://ir.uipath.com/news/detail/400/uipath-recognized-as-a-leader-in-the-2025-gartner-magic-quadrant-for-robotic-process-automation) |
### 12.4 Problématique du marché — Fragilité et échecs RPA
| Source | Donnée | Lien |
|--------|--------|------|
| **Ernst & Young** | 30 à 50 % des projets RPA échouent initialement | [Flobotics — RPA Statistics](https://flobotics.io/blog/rpa-statistics/) |
| **Blueprint Software** | Le coût de licence ne représente que 25-30 % du coût total RPA ; la maintenance et le support représentent 15-20 % de l'investissement initial par an | [Blueprint — RPA Cost](https://www.blueprintsys.com/blog/rpa/how-much-does-robotic-process-automation-really-cost) |
| **Blueprint Software** | Les bots cassent régulièrement lors de changements d'interface (break-fix cycles) ; la maintenance est le premier poste de coût récurrent | [Blueprint — Reduce RPA Maintenance](https://www.blueprintsys.com/blog/rpa/reduce-rising-costs-rpa-maintenance-and-support) |
| **Worksoft** | La fragilité des bots face aux changements UI est le principal défi technique du RPA (« bot fragility ») | [Worksoft — Solving Bot Fragility](https://www.worksoft.com/corporate-blog/solving-bot-fragility-with-change-resilient-rpa) |
| **Deloitte** | Enquête mondiale sur l'adoption RPA : 62 % citent l'intégration comme barrière principale, 55 % le manque de compétences | [Deloitte — Global RPA Survey](https://www2.deloitte.com/us/en/pages/operations/articles/global-robotic-process-automation-report.html) |
### 12.5 Problématique Citrix/VDI — Marché sous-servi
| Source | Donnée | Lien |
|--------|--------|------|
| **PwC India** | Livre blanc : « Robotic Process Automation in a Virtual Environment » — les environnements VDI ne fournissent aucun objet DOM exploitable, l'automatisation repose uniquement sur la reconnaissance d'image | [PwC — RPA in Virtual Environment (PDF)](https://www.pwc.in/assets/pdfs/publications/2018/robotic-process-automation-in-a-virtual-environment.pdf) |
| **Accelirate** | « Challenges of RPA in Citrix Environment » — absence totale d'Object IDs, le bot ne voit qu'une image pixel | [Accelirate — RPA & Citrix](https://www.accelirate.com/challenges-of-rpa-in-citrix-environment/) |
| **Ultima (IA Connect)** | Solution spécialisée RPA pour Citrix/VDI — confirme le besoin non couvert par les plateformes standard | [Ultima — IA Connect for Citrix](https://ultima.com/ia-connect/) |
| **Leapwork** | « Overcoming Common Citrix Automation Challenges » — les outils RPA classiques échouent en environnement Citrix | [Leapwork — Citrix Challenges](https://www.leapwork.com/blog/overcoming-common-citrix-automation-challenges-with-the-right-tool) |
### 12.6 Technologies IA utilisées — Publications et documentation
| Technologie | Référence |
|-------------|-----------|
| **CLIP** (OpenAI, 2021) | Radford et al., « Learning Transferable Visual Models From Natural Language Supervision » — [arXiv:2103.00020](https://arxiv.org/abs/2103.00020) |
| **FAISS** (Meta AI) | Johnson et al., « Billion-scale similarity search with GPUs » — [arXiv:1702.08734](https://arxiv.org/abs/1702.08734) |
| **OWL-v2** (Google, 2023) | Minderer et al., « Scaling Open-Vocabulary Object Detection » — [arXiv:2306.09683](https://arxiv.org/abs/2306.09683) |
| **docTR** (Mindee) | OCR open-source — [GitHub: mindee/doctr](https://github.com/mindee/doctr) |
| **Qwen2.5-VL** (Alibaba) | Modèle vision-langage — [HuggingFace: Qwen](https://huggingface.co/Qwen) |
| **PyTorch** (Meta AI) | Framework de deep learning — [pytorch.org](https://pytorch.org/) |
| **OpenCV** | Bibliothèque de vision par ordinateur — [opencv.org](https://opencv.org/) |
---
## Annexes
### A. Liste des modules du moteur Core (192 fichiers)
Les modules couvrent : analytics, capture, detection, embedding, execution, graph, healing, learning, matching, models, monitoring, security, system, training.
### B. Catalogue des 24 actions VWB
Vision UI (14) : click_anchor, type_text, screenshot_evidence, extract_text, hover, drag_drop, select_option, scroll, wait_element, verify_element, double_click, right_click, keyboard_shortcut, focus_element
Navigation (2) : navigate_to_url, browser_back
Data (2) : download_to_folder, extraire_tableau
Database (3) : save_data, load_data, db_manager
Validation (2) : verify_element_exists, verify_text_content
Intelligence (1) : analyze_with_ai
### C. Références documentaires internes
- `ARCHITECTURE_VISION_COMPLETE.md` — Architecture complète 5 couches
- `PITCH_INVESTISSEURS_RPA_VISION_V3.md` — Pitch investisseurs
- `ANALYSE_MOAT_RPA_VISION_V3.md` — Analyse concurrentielle détaillée
- `QUICK_START.md` — Guide de démarrage rapide
---
*Document généré le 25 février 2026 — RPA Vision V3*

View File

@@ -0,0 +1,192 @@
# Flags d'exécution vision-aware (C1) — ExecutionLoop
> Introduit dans la série de correctifs **C1** (avril 2026).
> Référence code : [`core/execution/execution_loop.py`](../core/execution/execution_loop.py) (classe `ExecutionLoop`, constructeur lignes ~177-237).
Cette page décrit les quatre flags ajoutés à `ExecutionLoop` pour piloter
finement la construction de `ScreenState` pendant le replay. Ils permettent de
dégrader volontairement le pipeline de perception quand un composant est en
panne ou quand on veut gagner de la latence.
## Contexte
Depuis C1, chaque itération de la boucle d'exécution construit un
`ScreenState` enrichi via `ScreenAnalyzer` (OCR + détection UI + embedding),
avec un cache perceptuel pour éviter de recalculer deux fois sur le même
screenshot.
Cela coûte cher (~200 ms 2 s selon la machine). Les flags ci-dessous
permettent de désactiver ou contraindre ces étapes.
Le `StepResult` expose désormais :
| Champ | Type | Sens |
|---|---|---|
| `ocr_ms` | float | Temps OCR pour ce step |
| `ui_ms` | float | Temps détection UI pour ce step |
| `analyze_ms` | float | Temps total analyse ScreenState |
| `total_ms` | float | Temps total du step (alias `duration_ms`) |
| `cache_hit` | bool | True si le ScreenState vient du cache perceptuel |
| `degraded` | bool | True si on est retombé en mode dégradé |
Ces champs remontent automatiquement dans le module analytics
(table SQLite `step_metrics`, voir
[`core/analytics/storage/timeseries_store.py`](../core/analytics/storage/timeseries_store.py)).
## Flags
### `enable_ui_detection: bool = True`
Active/désactive la détection UI (YOLO + SomEngine + VLM de grounding).
**Pourquoi le désactiver** :
- Le serveur VLM (Ollama) est down ou surchargé
- On cible un workflow très simple où seul l'OCR suffit
- On debugge un problème de détection et on veut isoler la cause
**Impact performance** : gain ~100-1500 ms par step selon modèle VLM.
**Exemple** :
```python
from core.execution.execution_loop import ExecutionLoop, ExecutionMode
loop = ExecutionLoop(
pipeline=pipeline,
enable_ui_detection=False, # VLM down → on coupe la détection UI
)
loop.start(workflow_id="wf_notepad", mode=ExecutionMode.AUTOMATIC)
```
### `enable_ocr: bool = True`
Active/désactive l'OCR (Tesseract/docTR).
**Pourquoi le désactiver** :
- Gains de performance sur un workflow piloté uniquement par templates/embeddings
- Environnement CPU-only où l'OCR est trop lent
- Les textes ne sont pas utilisés par la stratégie de matching
**Impact performance** : gain ~80-500 ms par step.
**Exemple** :
```python
loop = ExecutionLoop(
pipeline=pipeline,
enable_ocr=False,
)
```
> Note : si `enable_ui_detection=False` **et** `enable_ocr=False`, la boucle
> renvoie un `ScreenState` stub (sans texte ni éléments) et force
> `degraded=True`. Le matching retombera sur les embeddings CLIP seuls.
### `analyze_timeout_ms: int = 8000`
Seuil soft en millisecondes au-delà duquel on considère que l'analyse a été
trop lente et on bascule **tous les steps suivants** en mode dégradé
(pas de recalcul OCR/UI, réutilisation du cache ou stub direct).
**Pourquoi le modifier** :
- Machines lentes (CPU, VM, Citrix) → augmenter à `15000` ou `20000`
- Serveurs dédiés GPU → réduire à `3000` pour détecter plus tôt
- Tests / profiling → utiliser `999999` pour désactiver le basculement
**Exemple** :
```python
loop = ExecutionLoop(
pipeline=pipeline,
analyze_timeout_ms=15000, # environnement lent (RDP/Citrix)
)
```
Le mode dégradé est porté par `ExecutionLoop._degraded_mode` et affiché dans
`StepResult.degraded`. Voir
[`_build_screen_state`](../core/execution/execution_loop.py) (~ligne 920).
### `window_info_provider: Optional[Callable[[], Optional[Dict]]] = None`
Callable renvoyant un `dict` décrivant la fenêtre active. Par défaut, la
boucle appelle `screen_capturer.get_active_window()`.
**Pourquoi fournir un provider custom** :
- **Citrix / RDP** : le client Windows local voit un seul process (le client
Citrix). L'info de fenêtre utile vient de l'agent distant, on doit donc la
passer explicitement.
- **Environnements headless** : pas de gestionnaire de fenêtres natif.
- **Tests** : injecter une fenêtre mockée sans toucher au capturer.
**Format attendu du dict** (au minimum) :
```python
{
"title": str, # Titre de la fenêtre
"app_name": str, # Nom de l'application
# champs optionnels utilisés par ScreenAnalyzer
"x": int, "y": int, "width": int, "height": int,
}
```
**Exemple Citrix** :
```python
def citrix_window_info():
# L'agent dans la session Citrix distante publie ces infos
# (par ex. via un fichier partagé ou une websocket)
return remote_agent.get_current_window_info()
loop = ExecutionLoop(
pipeline=pipeline,
window_info_provider=citrix_window_info,
)
```
## Combinaisons recommandées
| Cas d'usage | Flags |
|---|---|
| Production standard (GPU local) | `enable_ui_detection=True, enable_ocr=True, analyze_timeout_ms=8000` (défaut) |
| VLM down — mode fallback | `enable_ui_detection=False, enable_ocr=True` |
| Machine lente / VM | `analyze_timeout_ms=15000` |
| Citrix / RDP | `window_info_provider=<custom>` + valeurs par défaut |
| Benchmark CLIP-only | `enable_ui_detection=False, enable_ocr=False` |
## Remontée analytics
Les timings et flags dégradés persistent dans la table SQLite
`step_metrics` (colonnes `ocr_ms`, `ui_ms`, `analyze_ms`, `total_ms`,
`cache_hit`, `degraded`) via
[`AnalyticsExecutionIntegration.on_step_result`](../core/analytics/integration/execution_integration.py).
Exemple de requête d'analyse :
```sql
-- Steps avec OCR lent (>300 ms)
SELECT node_id, action_type, ocr_ms, analyze_ms
FROM step_metrics
WHERE ocr_ms > 300
ORDER BY ocr_ms DESC;
-- Taux de cache hit par workflow
SELECT workflow_id,
SUM(cache_hit) * 1.0 / COUNT(*) AS cache_hit_ratio
FROM step_metrics
GROUP BY workflow_id;
-- Steps ayant basculé en mode dégradé
SELECT execution_id, node_id, analyze_ms
FROM step_metrics
WHERE degraded = 1
ORDER BY started_at DESC;
```
## Voir aussi
- [`core/execution/execution_loop.py`](../core/execution/execution_loop.py) — implémentation
- [`core/pipeline/screen_analyzer.py`](../core/pipeline/screen_analyzer.py) — pipeline d'analyse
- [`core/pipeline/screen_state_cache.py`](../core/pipeline/screen_state_cache.py) — cache perceptuel
- [`tests/unit/test_execution_loop_vision_aware.py`](../tests/unit/test_execution_loop_vision_aware.py) — tests C1
- [`tests/unit/test_analytics_vision_metrics.py`](../tests/unit/test_analytics_vision_metrics.py) — tests analytics C1
- [docs/STATUS.md](STATUS.md) — état général du projet

View File

@@ -0,0 +1,267 @@
# Plan d'action — Dashboard Web RPA Vision V3
_Date : 2026-04-15 — périmètre : `web_dashboard/` (port 5001). Auteur : audit technique (lecture seule, aucune modif de code)._
Objectifs :
1. faire le ménage dans un dashboard qui a grossi par sédimentation,
2. préparer l'onglet **Audit & Traçabilité** attendu par les clients (AI Act / RGPD),
3. donner à Dom une roadmap actionnable et priorisée.
---
## Section A — Inventaire
Le dashboard a aujourd'hui **14 onglets** déclarés dans `web_dashboard/templates/index.html` (3 455 lignes) plus des pages auxiliaires (`chat.html`, `gestures.html`, `extractions.html`, `streaming.html`). Le backend Flask (`web_dashboard/app.py`, 2 665 lignes) expose **~65 routes HTTP** + 4 events SocketIO.
| # | Onglet (label UI) | Routes backend principales | État | MAJ estimée | Utile ? |
|---|---|---|---|---|---|
| 1 | 🎛️ Services | `/api/services*`, `/api/version*` | **OK** — fonctionne, utilisé quotidiennement | avril 2026 | **oui, cœur** |
| 2 | 📊 Vue d'ensemble | `/api/system/status`, SocketIO | **OK partiel**`statTests` et `statExecution` remplis par legacy | mars 2026 | doublonne avec Services |
| 3 | ⚡ Exécution | `/api/automation/*`, SocketIO `subscribe_execution` | **Douteux** — branché sur le moteur v1 core, pas sur Agent V1 streaming (5005). Le replay réel passe par VWB ou `/api/v1/traces/stream/replay*` | janv. 2026 | **non en l'état** — obsolète face au flux replay actuel |
| 4 | 🔄 Workflows | `/api/workflows*`, `/api/chains*`, `/api/triggers*` | **OK liste**, exécution via `/api/workflows/<id>/execute` redondante avec VWB et Agent V1 | janv.-mars 2026 | partiel — la liste oui, l'exécution non |
| 5 | 📦 Sessions | `/api/agent/sessions*` | **OK** — affiche bien les sessions dans `data/training/sessions/` | avril 2026 | **oui** mais se recoupe avec le cleaner (5006) |
| 6 | 📈 Performance | `/api/system/performance`, `/api/system/faiss/test`, `/metrics` | **OK** — FAISS + Prometheus + cache hit | mars 2026 | **oui, utile debug/benchmark** |
| 7 | 📡 Streaming | proxy vers `http://localhost:5005/api/v1/traces/stream/*` | **OK** — lecture seule, bonne visibilité live sessions | mars 2026 | **oui, cœur Agent V1** |
| 8 | 📄 Logs | `/api/logs`, `/api/logs/download` | **OK minimaliste** — lit `logs/*.log`, pas de filtre/tail | nov. 2025 | utile ponctuel, pourrait disparaître si Audit bien fait |
| 9 | 🧪 Tests | `/api/tests*` (subprocess `pytest`) | **Cassé en prod** — pytest non dispo sur cible packagée, timeout 120s bloque l'UI, **aucun droit d'exécuter pytest depuis une UI exposée sur Internet** | mars 2026 | **NON — à retirer** |
| 10 | 💾 Sauvegardes | `/api/backup/*` | **OK** — exports fonctionnels | janv. 2026 | **oui**, valeur clairement réglementaire |
| 11 | 🔧 Corrections (packs) | routes ailleurs (VWB / API core) — onglet affiche des stats | **Cassé**`refreshCorrectionPacks` appelle une route qui n'existe plus côté dashboard, données via VWB | févr. 2026 | à supprimer côté dashboard, garder côté VWB |
| 12 | 🧠 Apprentissage | idem : placeholders `statCorpusSize`, charts Chart.js non alimentés | **Cassé/placeholder** — aucune route backend ne nourrit les 4 stat-cards ni les 2 canvas | janv. 2026 | **NON — à retirer ou refaire sérieusement** |
| 13 | 🔧 Configuration | `/api/config*`, `/api/config/ollama-models`, `/api/config/test-connection` | **OK** — édition ports/modèles/DB/sécurité/logs | avril 2026 | **oui** (utile admin) |
| 14 | 🧹 Nettoyage | iframe → `http://localhost:5006` | **OK** — ajouté hier, dépend du service session_cleaner | avril 2026 | **oui** |
Pages auxiliaires (templates séparés, non liées aux onglets) :
| Page | Route | État | Verdict |
|---|---|---|---|
| `/chat` | `chat.html` + `/api/chat/*` | **OK expérimental** — utilisé en dev | doublonne Agent Chat (5004), à supprimer ici |
| `/gestures` | `gestures.html` + `/api/gestures` | **Mort** — aucune donnée, concept abandonné | **à supprimer** |
| `/extractions` | `extractions.html` + `/api/extractions*` | **Douteux** — fonctionnalité concurrente au plan Data Extraction prévu via VWB | à geler/archiver |
| `/streaming` | `streaming.html` | **Doublon** — remplacé par l'onglet Streaming intégré dans `index.html` | **à supprimer** |
SocketIO : 4 events (`connect`, `disconnect`, `subscribe_execution`, `get_performance`). Seul `connect/disconnect` sert aujourd'hui — le reste alimente l'onglet Exécution qui va disparaître.
---
## Section B — À virer (cleanup)
Dans cet ordre, à faire en un seul coup (effort total estimé : **2 à 3 heures**).
1. **Onglet 🧪 Tests** (lignes 67, 499-517 de `index.html` ; routes 1006-1094 de `app.py`)
- Pourquoi : exécuter `pytest` depuis une UI auth Basic exposée sur Internet est une RCE déguisée. Et ça plante en prod. Les tests se lancent en CLI.
- Effort : 20 min.
2. **Onglet 🧠 Apprentissage** (lignes 70, 665-720)
- Pourquoi : placeholders, aucune route backend, induit le client en erreur.
- Alternative : soit on le refait proprement dans l'onglet **Audit & Traçabilité** (stats globales sur trace d'exécution), soit on l'enlève.
- Effort : 10 min (retrait).
3. **Onglet 🔧 Corrections** (lignes 69, 603-664)
- Pourquoi : les correction packs sont gérés dans VWB, pas ici. L'onglet affiche des cartes qui ne se remplissent plus. Ajouter un simple bouton "Ouvrir VWB" dans l'onglet Services suffit.
- Effort : 10 min.
4. **Onglet ⚡ Exécution** (lignes 61, 197-233)
- Pourquoi : branché sur l'ancien moteur core via SocketIO `subscribe_execution`. Aujourd'hui le replay passe par l'Agent V1 et VWB. L'onglet Streaming couvre déjà le live.
- Effort : 15 min (retrait + supprimer les 4 routes `/api/automation/*`).
5. **Onglet 📊 Vue d'ensemble** (lignes 60, 162-194)
- Pourquoi : 4 stat-cards dupliquées depuis Services + Sessions + Workflows. Un `perfChart` qui duplique ce qui est dans Performance.
- Alternative : fusionner un mini-résumé (4 KPIs) en tête de l'onglet Services, ce qui fait gagner un clic. Puis supprimer l'onglet.
- Effort : 30 min.
6. **Pages auxiliaires `/chat`, `/gestures`, `/streaming`, `/extractions`**
- Pourquoi : concurrence avec services dédiés (Agent Chat 5004, VWB, plan Data Extraction) ou fonctionnalités mortes (gestures).
- Effort : 15 min (supprimer templates + routes Flask).
7. **Code mort résiduel**
- `execution_state`, `performance_metrics` (globales module) : utiles uniquement si Exécution / Overview subsistent.
- `chain_manager`, `trigger_manager`, `automation_scheduler` : à évaluer, probablement à garder côté moteur mais retirer l'UI.
- Effort : 30 min.
**Résultat visé** : de 14 onglets à **8 onglets** (Services, Sessions, Performance, Streaming, Logs, Sauvegardes, Config, Nettoyage) **+ 1 nouveau** (Audit). Soit **9 onglets utiles et alimentés**.
---
## Section C — À garder mais améliorer
Par priorité.
1. **🎛️ Services** — ajouter un bandeau "santé globale" (somme du statut des 6 services critiques + streaming server + session cleaner) et rapatrier les 4 stat-cards de "Vue d'ensemble" en tête. Effort : **1 h**.
2. **📦 Sessions** — ajouter un filtre par statut (pending/processing/completed/failed — la donnée existe déjà dans `PROCESSING_STATUS_FILE`). Ajouter un bouton "Voir dans le cleaner" qui ouvre l'onglet Nettoyage pré-filtré sur la session. Effort : **1 h**.
3. **📄 Logs** — passer en lecture "tail -f" WebSocket plutôt que polling d'un fichier entier. Filtrer par niveau (INFO/WARN/ERROR) côté serveur. Effort : **2 h**. Faible priorité : une fois l'onglet Audit en place, 90 % de l'usage métier disparaît.
4. **📈 Performance** — la section FAISS est bonne. Retirer les deux graphiques Chart.js `cacheChart` et `faissChart` : ils se redessinent toutes les 5s avec un tableau vide au reload, c'est du bruit visuel. Effort : **20 min**.
5. **🔧 Configuration** — documenter dans un tooltip chaque champ (DASHBOARD_PASSWORD, RPA_API_TOKEN, etc.) et signaler clairement les secrets. Ajouter un bouton "Recharger les modèles Ollama" déjà présent mais discret. Effort : **1 h**.
Aucune amélioration pour Streaming, Sauvegardes, Nettoyage : ils sont récents et suffisants.
---
## Section D — Nouvel onglet "⚖️ Audit & Traçabilité"
### Ce que l'utilisateur voit
Une table paginée des actions Léa, précédée de filtres, avec export CSV.
**Bandeau haut** — 4 KPIs aujourd'hui :
- Actions totales (24 h)
- Taux de succès global
- Nombre de TIMs actifs
- Applications cibles touchées
**Zone de filtres** (ligne horizontale) :
- Période (preset : Aujourd'hui / 7j / 30j / personnalisée via deux date pickers)
- Collaborateur (dropdown peuplé via `/api/v1/audit/summary``by_user`)
- Application métier (dropdown, peuplé via le champ `target_app` des entrées)
- Type d'action (click, type, key_combo, wait)
- Résultat (success / failed / recovered / skipped)
- Mode d'exécution (autonomous / assisted / shadow)
- Workflow (dropdown si < 50, sinon champ texte)
- Recherche libre (matche `action_detail`)
**Boutons d'action** (à droite du bandeau) :
- 📥 Exporter CSV (respecte les filtres courants)
- 📄 Rapport PDF pour DSI (génération simple, voir ci-dessous)
- 🔄 Actualiser
**Tableau principal** (colonnes dans cet ordre) :
| Colonne | Source `AuditEntry` | Remarque |
|---|---|---|
| Horodatage | `timestamp` | affichage local `HH:mm:ss · JJ/MM` |
| Collaborateur | `user_name` ou fallback `user_id` | |
| Poste | `machine_id` | utile multi-sites |
| Application | `target_app` | DPI, Orbis, DxCare, navigateur… |
| Action | `action_type` + badge | icône par type |
| Détail | `action_detail` | tronqué à 80 car., tooltip complet |
| Mode | `execution_mode` | badge coloré |
| Résultat | `result` | vert/rouge/orange |
| Récup. | `recovery_action` | uniquement si `result != success` |
| Durée | `duration_ms` | ms → s si > 1 s |
| Workflow | `workflow_name` ou `workflow_id` tronqué | lien vers détail workflow |
Ligne cliquable → panneau latéral avec le JSON complet (+ `critic_result`, `resolution_method`, session_id, action_id). Ce panneau sert aux audits techniques.
**Pagination** serveur (limit 100, offset) — le backend `/api/v1/audit/history` gère déjà.
**Widgets complémentaires** (en dessous du tableau) :
- Camembert "répartition par application" (utile DSI pour visualiser le périmètre Léa)
- Courbe "taux d'échec sur 7 j" (seuil d'alerte ajustable)
- Liste "Top 10 échecs récents" — pour qu'un TIM/RSSI identifie vite les workflows à retoucher
### Sources de données — ce qui existe déjà
Tout le backend est **déjà là** côté streaming server (port 5005) :
- Module `agent_v0/server_v1/audit_trail.py``AuditTrail` avec `record()`, `query()`, `get_summary()`, `export_csv()`.
- Endpoints FastAPI déjà en place :
- `GET /api/v1/audit/history` — historique filtrable paginé
- `GET /api/v1/audit/summary?date=YYYY-MM-DD` — résumé du jour
- `GET /api/v1/audit/export` — export CSV
- Données réelles présentes dans `data/audit/audit_YYYY-MM-DD.jsonl` (7 fichiers, ~500 ko sur les 10 derniers jours, ~1 800 entrées aujourd'hui).
### Ce qu'il manque
1. **Proxy Flask côté dashboard (5001 → 5005)** — sur le modèle de `/api/streaming/<path:endpoint>` qui existe déjà (`app.py:2505`). Coût : ~30 lignes.
2. **Template HTML + JS** — nouvel onglet dans `index.html`, ~300 lignes. Pas de framework : réutiliser le style et Chart.js existant.
3. **Champ patient pseudonymisé** : actuellement **pas présent** dans `AuditEntry`. Décision nécessaire :
- Option A (recommandée) : ajouter un champ optionnel `patient_ref_hash` (SHA-256 tronqué du n° patient), alimenté quand Léa extrait ou saisit un identifiant patient. Coût : ajout d'un champ dataclass + propagation dans 2-3 endroits au niveau exécuteur.
- Option B : n'en pas mettre pour le POC Anouste, préciser dans la doc DSI.
4. **Rapport PDF DSI** — simple template ReportLab ou WeasyPrint, une page A4 avec en-tête (client, période, nombre d'actions, signature hash des logs source pour intégrité), puis le tableau filtré. Coût : ~150 lignes + 1 dép.
### Effort estimé
| Lot | Jours·homme |
|---|---|
| Proxy Flask + nouvel onglet (MVP : tableau + filtres + export CSV) | **0.5** |
| Widgets graphiques (camembert + courbe 7j) | 0.25 |
| Rapport PDF DSI | 0.5 |
| Champ `patient_ref_hash` (option A) | 0.5 |
| Alerting (si > N échecs consécutifs sur 1 h → badge rouge + email optionnel) | 0.5 |
| **Total MVP (sans PDF ni patient)** | **0.75 j** |
| **Total version "complète DSI-ready"** | **~2.25 j** |
### Réglementaire — ce qui est adressé
- **AI Act, article 12 — "Record-keeping"** : les systèmes d'IA à haut risque doivent **automatiquement enregistrer** les événements pertinents. L'onglet rend visibles et exportables les logs qui existent déjà côté serveur. Respect du critère _traçabilité permanente_.
- **AI Act, article 14 — "Human oversight"** : le champ `execution_mode` (`shadow/assisted/autonomous`) documente le niveau de supervision humaine pour chaque action.
- **RGPD, article 30 — registre des traitements** : champ `target_app` + `domain` permettent de produire un inventaire des traitements par application métier.
- **RGPD, article 32 — intégrité** : ajouter un hash SHA-256 du fichier JSONL signé à la clôture journalière (coût marginal, 20 lignes) garantit la non-falsification.
- **RGPD, article 33 — notification de violation** : l'alerting (> N échecs sur fenêtre glissante) est la brique technique qui permet à un RSSI d'être notifié.
### Exemple concret — "un DSI hospitalier demande un audit"
**Scénario** : le DSI du CH d'Auch veut montrer à son directeur que Léa n'a jamais validé seule une codification CIM-10 sur le patient X pendant la semaine 15.
1. Il ouvre le dashboard → onglet Audit.
2. Filtre : période = 2026-04-06 → 2026-04-12, application = `DxCare`, action = `click`.
3. Il saisit dans la recherche libre le hash pseudo du patient (fourni par le TIM responsable).
4. Il voit 3 lignes, toutes en mode `assisted`, toutes avec `user_name = "Marie Dupont"`.
5. Il clique sur "📄 Rapport PDF DSI" → reçoit un PDF d'une page avec les 3 lignes, la signature d'intégrité, le tampon Léa, la période.
6. Il remonte ça à sa direction. Dossier clos en 4 minutes.
C'est exactement le cas d'usage qui manque aujourd'hui et qui fera la différence en appel d'offres CHU.
---
## Section E — Non-décisions (ce qu'on ne fait pas)
- **Dashboard mobile / responsive** : le dashboard est un outil admin, pas un produit grand public. Les TIMs n'en ont pas besoin sur téléphone. **Non.**
- **Multi-thème (clair/sombre)** : sombre tout le temps. **Non.**
- **Internationalisation du dashboard** : tout est en français, cible FR. i18n prévu côté produit Léa, pas côté dashboard admin. **Non pour 2026.**
- **Refonte complète en framework (React/Vue)** : le HTML vanilla + Chart.js tient la route. Une refonte coûterait 2 semaines pour zéro gain utilisateur. **Non.**
- **Authentification OAuth / SSO** : HTTP Basic suffit sur usage interne. SSO hôpital = chantier en soi, ne pas le mélanger avec ce cleanup. **Pas maintenant.**
- **Temps réel sur l'onglet Audit** : le polling toutes les 30 s est largement suffisant. WebSocket = complexité inutile ici. **Non.**
---
## Section F — Roadmap recommandée
Ordre **non négociable** : on fait le ménage **avant** d'ajouter du neuf. Un onglet Audit brillant au milieu d'un dashboard bruité envoie un signal d'amateurisme.
### Sprint 1 — Cleanup (0.5 jour)
- Section B points 1 à 7 (retirer Tests, Apprentissage, Corrections, Exécution, Vue d'ensemble, pages mortes, code orphelin).
- **Livrable** : dashboard à 8 onglets fonctionnels, zéro placeholder, zéro 404 silencieuse.
- **Critère de sortie** : un client invité voit l'interface sans jamais tomber sur une page vide ou une erreur JS.
### Sprint 2 — Onglet Audit MVP (0.75 jour)
- Proxy Flask `/api/audit/*``http://localhost:5005/api/v1/audit/*` (réutilise le pattern `/api/streaming/<path>`).
- Nouvel onglet "⚖️ Audit & Traçabilité" : bandeau 4 KPIs + filtres + tableau paginé + export CSV.
- **Livrable** : un DSI peut filtrer, visualiser, exporter.
- **Critère de sortie** : scénario CH Auch (voir Section D) exécutable en < 5 min par un utilisateur non technique.
### Sprint 3 — Audit complet DSI-ready (1.5 jour, à chaud si appel d'offres)
- Rapport PDF DSI.
- Champ `patient_ref_hash` dans `AuditEntry` + propagation executor.
- Signature d'intégrité journalière (hash du JSONL en clôture).
- Widgets graphiques (camembert + courbe 7 j).
- Alerting seuil d'échecs.
- **Livrable** : conformité démontrable AI Act art. 12 + 14 + RGPD art. 30 + 32.
### Sprint 4 — Améliorations ciblées (1 à 2 jours, au fil de l'eau)
- Section C (santé globale Services, filtres Sessions, tail logs WebSocket, retrait graphiques vides Performance, tooltips Config).
- À faire **seulement** quand un TIM ou un DSI remonte un manque précis, pas en préventif.
---
## Annexe — Justification du cleanup
Pourquoi on supprime plutôt qu'on "met en veille" ?
1. Chaque onglet coûte en maintenance (CSS, JS, tests manuels, support client).
2. Chaque route Flask morte est une surface d'attaque (surtout `/api/tests/run` qui exécute `pytest` en subprocess).
3. Chaque placeholder visuel dégrade la perception client : "pourquoi il y a un onglet 🧠 Apprentissage qui n'affiche rien ?"
4. Git garde tout. Aucune donnée n'est perdue. Revert possible.
Principe général : **moins de surface, plus de valeur**. Un dashboard de 9 onglets pleins bat un dashboard de 14 onglets dont 5 creux, tous les jours.

View File

@@ -0,0 +1,194 @@
# Plan d'action RPA Vision V3 — Mars 2026
**Date** : 10 mars 2026
**Auteur** : Dom + Claude
**Statut** : En cours de validation
---
## Diagnostic du projet
### Objectif du projet
Système RPA **100% basé sur la vision** (pas de sélecteurs DOM/accessibility) capable d'**apprendre par observation** des workflows utilisateur et de les rejouer de manière autonome.
**Philosophie** : "Observer → Comprendre → Apprendre → Agir"
**Cas d'usage cible** : milieu médical (facturation T2A, logiciels hospitaliers type HIS) — applications legacy sans API.
**Progression d'apprentissage** : OBSERVATION → COACHING → AUTO_CANDIDATE → AUTO_CONFIRMED
### Architecture 5 couches
```
Couche 0: RawSession — Capturer clics/clavier/screenshots
Couche 1: ScreenState — Analyser l'écran (image + texte + UI + contexte métier)
Couche 2: UIElement — Détecter boutons/champs/tableaux sémantiquement
Couche 3: StateEmbedding — Créer un "fingerprint" fusionné de l'état d'écran
Couche 4: WorkflowGraph — Modéliser en graphe (nodes = écrans, edges = actions)
```
### Etat réel du code vs la vision documentée
| Composant | Statut | Notes |
|---|---|---|
| Modèles de données (5 couches) | **Complet** | Dataclasses/Pydantic bien structurées |
| Pipeline de détection UI (OWL-v2 + OpenCV + VLM) | **Fonctionnel** | |
| Embedding multi-modal (CLIP + FAISS) | **Fonctionnel** | BUG: composant texte toujours None |
| Learning States (4 niveaux + transitions) | **Implémenté** | |
| ActionExecutor + TargetResolver | **Très complet** | ~2800 lignes, multi-stratégie |
| ExecutionLoop multi-modes | **Implémenté** | |
| Self-healing (4 stratégies) | **Implémenté** | |
| GraphBuilder (RawSession → Workflow) | **Partiel** | Clustering OK, templates incomplets |
| Capture d'événements (clavier/souris) | **Absent du core** | Délégué à agent_v0 (séparé) |
| Construction auto des 4 niveaux ScreenState | **Absent** | OCR jamais peuplé |
| Visual Workflow Builder (éditeur web) | **Fonctionnel** | React + Flask, 20+ composants |
### Enrichissements documentés (8 concepts)
| # | Concept | Statut code |
|---|---|---|
| 1 | Grammaire du temps (épisodes) | Partiel — boost temporel, pas de patterns |
| 2 | Marquage du bruit | Manquant — implicite dans DBSCAN, non persisté |
| 3 | Layout signature | Implémenté — `screen_signature.py` |
| 4 | Identité stable | Partiel — target_memory, pas de StableIdentity formelle |
| 5 | Actionnabilité (scores) | Partiel — `is_interactable` bool, pas de score numérique |
| 6 | Versioning des espaces | Implémenté — PrototypeVersion |
| 7 | Variables métier | Partiel — champ présent, intégration non automatisée |
| 8 | Noeuds d'erreur | Manquant — pas de ErrorNode dans le graphe |
### Problèmes identifiés
#### Bug critique silencieux
`core/embedding/state_embedding_builder.py` accède à `detected_texts` (avec 's') alors que le champ réel s'appelle `detected_text`. Le composant texte de l'embedding (30% du poids) est **toujours None**. La qualité du matching est silencieusement dégradée.
#### Pipeline end-to-end non bouclé
La chaîne complète "observer un utilisateur → construire un workflow → le rejouer" n'est pas opérationnelle. La capture d'événements est dans agent_v0 (séparé), le GraphBuilder laisse des TODOs dans les templates, l'OCR ne peuple jamais le ScreenState.
#### Dette technique massive
- ~660K lignes Python, ~25 000 fichiers
- Centaines de scripts one-shot (classés dans `_a_trier/`)
- 3 frontends VWB abandonnés (classés dans `_a_trier/`)
- agent_v0 de 7.9 Go
- Ratio signal/bruit faible
#### Dispersion de l'effort
Beaucoup de modules sophistiqués (coaching, analytics, monitoring, sécurité, i18n, training, précision) développés en parallèle, mais la boucle fondamentale n'est pas fiable.
---
## Programme d'action
### Phase 0 — Stabiliser les fondations (1-2 semaines)
**Objectif : un pipeline minimal qui tourne de A à Z**
- [x] **P0-1** Corriger le bug `detected_texts``detected_text` dans `state_embedding_builder.py`
- [x] **P0-2** Intégrer la capture d'événements dans le core (`core/capture/event_listener.py` — pynput)
- [x] **P0-3** Implémenter le Screen Analyzer (`core/pipeline/screen_analyzer.py` — ScreenState complet 4 niveaux)
- [x] **P0-4** Compléter le GraphBuilder — `_create_screen_template()` + alignement modèles + fix import circulaire
- [x] **P0-5** Test E2E (`tests/test_pipeline_e2e.py` — 20 tests, pipeline validé avec embeddings mock)
**Critère de succès** : une démo qui enregistre un workflow simple et le rejoue correctement.
### Phase 1 — Valider sur un cas réel (2-3 semaines)
**Objectif : prouver que ça fonctionne sur un vrai logiciel**
**Prérequis (complétés) :**
- [x] SessionRecorder (`core/capture/session_recorder.py`) — orchestre EventListener + ScreenCapturer
- [x] Script CLI (`scripts/record_and_build.py`) — record / build / full / list
- [x] Fix `_extract_node_vector` dans WorkflowPipeline (support metadata prototypes)
- [x] Nettoyage code mort dans `execute_workflow_step`
- [x] Validation pipeline sur session réelle (66 screenshots → 1 node "Calculatrice")
**Tâches :**
- [ ] **P1-1** Choisir un workflow simple sur une application réelle (ex : 3 boutons + 1 saisie)
- [ ] **P1-2** Enregistrer 5 sessions de ce workflow (`python scripts/record_and_build.py record`)
- [ ] **P1-3** Vérifier que le GraphBuilder crée un workflow cohérent (`python scripts/record_and_build.py build`)
- [ ] **P1-4** Passer en COACHING → valider les suggestions
- [ ] **P1-5** Passer en AUTO_CANDIDATE → vérifier l'exécution supervisée
- [ ] **P1-6** Mesurer : précision matching, taux succès, temps exécution
**Critère de succès** : taux de succès > 80% en mode AUTO_CANDIDATE sur le workflow choisi.
### Phase 2 — Consolider le core (3-4 semaines)
**Objectif : fiabilité et robustesse**
- [ ] **P2-1** Implémenter les enrichissements manquants prioritaires : noeuds d'erreur, identité stable formelle, score d'actionnabilité
- [ ] **P2-2** Tests d'intégration sur le pipeline complet
- [ ] **P2-3** Nettoyage `_a_trier/` — décider quoi garder/supprimer/archiver
- [ ] **P2-4** Documentation code core (modules clés uniquement)
**Critère de succès** : couverture de tests > 60% sur le pipeline core, zéro bug silencieux connu.
### Phase 3 — Produit utilisable (4-6 semaines)
**Objectif : le VWB comme outil complet**
- [ ] **P3-1** Intégrer le pipeline core dans le VWB (aligner les services VWB sur core/)
- [ ] **P3-2** Supprimer l'ancien frontend VWB (garder uniquement frontend_v4)
- [ ] **P3-3** Workflow complet dans le VWB : enregistrer → éditer → tester → déployer
- [ ] **P3-4** Mode démo pour présentation (prospects/investisseurs)
**Critère de succès** : un utilisateur non technique peut enregistrer et rejouer un workflow via le VWB.
### Ce qu'on ne fait PAS maintenant
- Ajouter de nouvelles fonctionnalités (analytics avancé, coaching amélioré, multi-écran)
- Refactorer la structure des 28 sous-modules
- Migrer vers un autre framework web
- Développer agent_v1 tant que le pipeline core n'est pas bouclé
---
## Métriques de suivi
| Métrique | Cible Phase 0 | Cible Phase 1 | Cible Phase 3 |
|---|---|---|---|
| Pipeline end-to-end fonctionnel | Oui (cas simple) | Oui (cas réel) | Oui (multi-cas) |
| Taux de succès AUTO_CANDIDATE | N/A | > 80% | > 90% |
| Temps enregistrement → workflow | < 5 min | < 5 min | < 2 min |
| Bugs silencieux connus | 0 | 0 | 0 |
| Couverture tests pipeline | Smoke test | > 40% | > 60% |
---
## Fichiers clés à connaître
### Core — Modèles
- `core/models/raw_session.py` — RawSession, Event, Screenshot
- `core/models/screen_state.py` — ScreenState (4 niveaux)
- `core/models/ui_element.py` — UIElement, BBox, VisualFeatures
- `core/models/state_embedding.py` — StateEmbedding, EmbeddingComponent
- `core/models/workflow_graph.py` — Workflow, WorkflowNode, WorkflowEdge, LearningState
### Core — Pipeline
- `core/capture/screen_capturer.py` — Capture screenshots (mss)
- `core/detection/ui_detector.py` — Pipeline OWL-v2 + OpenCV + VLM
- `core/embedding/state_embedding_builder.py` — Construction StateEmbedding (**BUG ligne ~216**)
- `core/embedding/clip_embedder.py` — OpenCLIP ViT-B-32
- `core/embedding/faiss_manager.py` — Index FAISS
- `core/graph/graph_builder.py` — RawSession → Workflow (DBSCAN, **TODOs templates**)
### Core — Exécution
- `core/execution/action_executor.py` — Exécution des actions
- `core/execution/target_resolver.py` — Résolution multi-stratégie (~2800 lignes)
- `core/execution/execution_loop.py` — Orchestration des modes
- `core/healing/healing_engine.py` — Self-healing (4 stratégies)
- `core/learning/learning_manager.py` — Machine à états d'apprentissage
### Points d'entrée
- `run.sh` — Chef d'orchestre (--full, --gui, --server, etc.)
- `Makefile` — Tests (make test, make test-fast, make check)
- `visual_workflow_builder/run_v4.sh` — VWB frontend_v4
### Documentation de référence
- `docs/reference/ARCHITECTURE_VISION_COMPLETE.md` — Architecture 5 couches
- `docs/reference/ARCHITECTURE_ENRICHISSEMENTS.md` — 8 enrichissements
- `docs/PLAN_ACTION_MARS_2026.md` — Ce fichier
---
**Priorité absolue : Phase 0 — Boucler le pipeline end-to-end.**

273
docs/PLAN_ACTION_VWB.md Normal file
View File

@@ -0,0 +1,273 @@
# Plan d'action VWB — 13 avril 2026
Audit ciblé du **Visual Workflow Builder** (`visual_workflow_builder/`) — backend Flask port 5002, frontend React+Vite port 3002 — suite aux retours flous de Dom : « la bibliothèque s'efface tout le temps » + idée d'importer les workflows Léa pour les corriger.
---
## Section A — État des lieux
### Stack réelle en production (PIDs live)
- **Backend 5002** → `backend/app.py` (Flask complet, blueprints v3, SQLAlchemy) — **c'est celui qui sert le VWB**
- **Backend 5003** → `backend/app_lightweight.py` (serveur HTTP natif fallback, 1451 lignes, mode quasi-inutile aujourd'hui)
- **Frontend 3002** → `frontend_v4/` (Vite + React 18, `@xyflow/react`, TypeScript) — actif
- **BDD utilisée** → `backend/instance/workflows.db` (via `.env DATABASE_URL=sqlite:///workflows.db`) — **3 workflows** dedans (« Classement de dossier », « bloc notes », « Onlyoffice »), tous `source='manual'`, aucun `review_status`
- **BDD fantôme** → `backend/instance/vwb_v3.db` (schema identique, **0 workflows**) — zombie de `app.py` ligne 47 (`'sqlite:///vwb_v3.db'` en défaut)
### Ce qui marche bien
- **API v3** est complète et propre : 44 routes réparties (`session`, `workflow`, `capture`, `execute`, `match`, `review`, `learned_workflows`, `dag_execute`). Modèles SQLAlchemy avec champs review (`source`, `review_status`, `review_feedback`, `reviewed_at`).
- **Pont Léa ↔ VWB déjà implémenté et câblé** :
- Backend : `api_v3/learned_workflows.py` (459 l.) + `services/learned_workflow_bridge.py` (604 l.)
- Frontend : `services/api.ts` expose `getLearnedWorkflows`, `importLearnedWorkflow`, `exportForLea` ; `components/WorkflowSelector.tsx` charge et propose d'importer les workflows non-importés
- Endpoints fonctionnels : logs du 15 avril montrent `GET /api/v3/learned-workflows?os=linux → 200`
- **Système de review** : composants `ReviewModal.tsx`, `WorkflowValidation.tsx`, `WorkflowManagerModal.tsx` + backend `api_v3/review.py` prêt. Un workflow importé arrive en `review_status='pending_review'`.
- Tests backend localisés : `backend/tests/` (test_models.py, test_coaching_api.py). Workflow CRUD complet.
- Logs rotatifs propres (`backend/logs/vwb.log`, 5 MB × 3).
### Ce qui est cassé / douteux
1. **BUG CRITIQUE — Bibliothèque de captures qui s'efface**
Fichier : `frontend_v4/src/components/CaptureLibrary.tsx` lignes 25-62
- Stockage dans **`sessionStorage`** (clé `captureLibrary_v2`) → purgé à chaque fermeture d'onglet ou redémarrage du navigateur
- Cap arbitraire à **50 captures** max (`slice(0, 49)`)
- Les captures sont des base64 PNG → sessionStorage ne tient pas plus de quelques dizaines de Mo au total
- C'est très probablement **le bug** que Dom décrit
2. **BUG — Deux composants concurrents pour la même bibliothèque**
- `CaptureLibrary.tsx` écrit dans `sessionStorage['captureLibrary_v2']`
- `CapturePanel.tsx` lignes 51-62 écrit dans `sessionStorage['captureLibrary']` (ancienne clé, jamais migrée à l'envers)
- Résultat : deux listes de captures tenues en parallèle, désynchronisées, invisibles l'une pour l'autre
3. **Base fantôme `vwb_v3.db`**
- `app.py` l.47 : `'sqlite:///vwb_v3.db'` en défaut
- Si un jour `.env` n'est pas chargé (ex : systemd mal configuré), le VWB passe silencieusement sur l'autre BDD **vide** et Dom voit ses workflows disparaître
- Dette : deux fichiers `instance/` (`backend/instance/` et `visual_workflow_builder/instance/`) créent de la confusion
4. **404 en prod — `/api/correction-packs/stats`**
- Logs récents : deux 404 à chaque chargement
- La route n'existe pas côté backend, elle est appelée par le **frontend legacy** (`frontend/src/hooks/useCorrectionPacks.ts`)
- Dom voit sans doute des erreurs CORS/404 dans la console réseau selon quel frontend il ouvre
5. **Double logging (tous les logs en double)**
- `app.py` attache un handler au **root logger** + Flask attache le sien → chaque ligne loguée deux fois
- Bruit dans les logs, rend le debug plus dur
6. **Confusion run.sh vs run_v4.sh**
- `run.sh` lance frontend legacy port 3000 (via webpack react-scripts) + app.py
- `run_v4.sh` lance frontend_v4 port 3002 + app.py
- Les deux coexistent, Dom peut cliquer sur l'un ou l'autre sans savoir
- `launch.sh` mentionné dans le README **n'existe pas** (README obsolète)
7. **Workflow « Unnamed Workflow »**
- Les JSON Léa ont souvent `"name": "Unnamed Workflow"` (cf. `notepad_enriched.json`)
- L'import les reprend tel quel — la liste du VWB devient illisible vite
8. **Tests d'intégration VWB datent de janvier**
- `tests/integration/test_vwb_*.py` : 6 fichiers
- `tests/property/test_vwb_frontend_v2_*` : 14 fichiers — ciblent `frontend/` (v2), pas `frontend_v4/`
- Aucun test cible le pont learned_workflows → pas de garde-fou pour C2
### Dette technique identifiable
- **3 backends Flask** (`app.py`, `app_lightweight.py`, `app_catalogue_simple.py`) pour 403 + 1451 + 1370 lignes = 3224 lignes. Un seul tourne.
- **2 frontends** (`frontend/` = react-scripts v2, `frontend_v4/` = Vite v4) avec duplication partielle des composants — seul le v4 est vivant
- **2 bases SQLite** nommées différemment, schéma identique
- **`catalog_routes.py.backup_20260122_163105`** (127 Ko) traîne dans le repo
- Screenshots `.screenshot2026-*.png` (8 fichiers × ~220 Ko) traînent à la racine backend
- Migrations Alembic présentes mais **un seul fichier** (`001_initial_schema.py`) alors que le schéma a évolué (`review_status` ajouté après)
---
## Section B — Quick wins (< 1 jour chacun)
Classés par ratio impact/effort décroissant :
### B1. 🔥 Migrer la bibliothèque de captures de `sessionStorage` vers `localStorage`
**Effort** : 30 min
**Impact** : résout le bug principal rapporté par Dom
- Remplacer `sessionStorage.*` par `localStorage.*` dans `CaptureLibrary.tsx` et `CapturePanel.tsx`
- Unifier sur une seule clé `captureLibrary_v3` (migration ascendante depuis les deux anciennes)
- Augmenter le cap à 200 captures + ajouter un bouton « vider la bibliothèque »
- Attention : `localStorage` plafonne ~5 Mo, les PNG base64 saturent vite — **meilleure option** : persister côté backend via `/api/v3/capture/library` (ajout d'une petite table) et ne garder que des IDs+thumbnails en localStorage
### B2. 🔥 Renommer le fichier « Unnamed Workflow » à l'import
**Effort** : 20 min
**Impact** : moyen (lisibilité immédiate dans la liste)
- `api_v3/learned_workflows.py` l.210 : si `wf_meta["name"] == "Unnamed Workflow"``f"Appris {datetime:%d/%m %H:%M}"`
- Idem exposer ce nom dans `WorkflowSelector.tsx` ligne 120 quand on affiche la liste learned
### B3. Supprimer la BDD fantôme
**Effort** : 15 min
**Impact** : élimine un foot-gun discret
- Modifier `app.py` l.47 : défaut `'sqlite:///workflows.db'` (aligné sur `.env`)
- Supprimer `backend/instance/vwb_v3.db` + `visual_workflow_builder/instance/workflows.db` (vestige)
- Documenter dans le `.env.example` le chemin absolu recommandé
### B4. Corriger le double logging
**Effort** : 15 min
**Impact** : faible (quality-of-life debug)
- `app.py` l.40 : remplacer `logging.getLogger().addHandler(...)` par `app.logger.addHandler(...)` et `logging.getLogger('werkzeug').addHandler(...)`, puis `propagate = False`
### B5. Supprimer le 404 `/api/correction-packs/stats`
**Effort** : 20 min
**Impact** : faible (erreurs dans la console navigateur)
- Option A (propre) : stubber la route dans `api/correction_packs.py` qui retourne `{"success": true, "stats": {"total": 0}}`
- Option B : retirer l'appel côté `frontend/src/hooks/useCorrectionPacks.ts` ligne 229
- Option B préférable si le frontend legacy est condamné (voir E1)
### B6. Nettoyer les fichiers parasites
**Effort** : 10 min
**Impact** : cosmétique + réduire la confusion
- Supprimer `backend/catalog_routes.py.backup_20260122_163105` (127 Ko)
- Supprimer les 8 screenshots `.screenshot2026-*.png` à la racine backend
- `.gitignore` : ajouter `*.screenshot*.png`, `*.backup_*`
### B7. Clarifier run.sh
**Effort** : 20 min
**Impact** : moyen (Dom et moi perdons du temps sur quel frontend lancer)
- Renommer `run.sh``run_legacy.sh` avec bandeau warning
- Mettre à jour `README.md` du VWB pour refléter `run_v4.sh` comme canonique
- Supprimer la mention `launch.sh` qui n'existe pas
**Total Quick Wins : ~2h30 de dev** pour résoudre la douleur principale + 4 dettes visibles.
---
## Section C — Chantiers moyens (1-3 jours)
### C1. Finaliser le flux « Import Léa → review VWB → replay »
**Effort** : 1 jour
**Contexte** : toute la plomberie existe (backend+frontend), mais la boucle n'est pas testée end-to-end depuis le premier replay réussi du 13 avril. Actuellement `source='learned_import'` déclenche bien `review_status='pending_review'`, mais :
- Le frontend n'a **pas de banner** « ⚠ 2 workflows importés en attente de review » visible au démarrage (il y a bien un `pendingReviewCount` dans `App.tsx` mais je n'ai pas vérifié son affichage)
- Quand Dom ouvre un workflow en `pending_review`, aucun indicateur visuel sur les étapes automatiquement générées par le bridge (ex: warning sur les `compound` décomposés, warning sur les `by_position` convertis en `x_pct/y_pct`)
- Pas de bouton « rejouer le workflow tel quel sans review » pour tester vite
**Actions** :
1. Vérifier l'affichage `pendingReviewCount` dans le header + ajouter un badge coloré
2. Dans `StepNode.tsx`, afficher un ⚠️ quand `step.parameters.compound_steps` existe ou quand `metadata.core_edge_id` manque d'info
3. Exposer les `warnings` retournés par l'import dans un panneau dépliant sur le workflow importé
4. Bouton « Valider et exécuter » qui passe `review_status='approved'` puis lance le replay via `/execute`
### C2. Persister la bibliothèque de captures côté serveur
**Effort** : 1,5 jour
**Contexte** : extension de B1 si on veut une bibliothèque réellement persistante et partagée entre sessions/machines
- Nouvelle table `captures_library(id, screenshot_b64, timestamp, label, favorite, workflow_id NULL)`
- Endpoints `GET/POST/DELETE /api/v3/captures/library`
- `CaptureLibrary.tsx` : fetch initial + mutations, plus de localStorage
- Bonus : associer une capture à un step (fav pour référence future)
### C3. Unifier les deux frontends — retirer `frontend/` (legacy)
**Effort** : 2 jours (avec vérif de non-régression)
**Contexte** : `frontend/` (v2 React 19 + MUI + Redux) n'est plus maintenu, `frontend_v4/` (Vite + xyflow) est la cible. 14 tests `tests/property/test_vwb_frontend_v2_*` pointent vers le v2.
- Auditer ce que le v4 ne fait pas encore (CorrectionPacksDashboard, CoachingPanel, etc.) → décider si on porte ou si on abandonne
- Archiver `frontend/` dans `_archives/` ou le supprimer
- Désactiver `run.sh` ou le refaire pointer sur v4
- Porter ou supprimer les tests `test_vwb_frontend_v2_*`
### C4. Consolider les 3 `app*.py`
**Effort** : 1 jour
**Contexte** : `app.py` est le seul utilisé pour le VWB. `app_lightweight.py` sert uniquement à l'endpoint catalogue VLM sur 5003 (un seul endpoint utile). `app_catalogue_simple.py` n'est plus référencé.
- Supprimer `app_catalogue_simple.py`
- Déplacer le seul endpoint utile de `app_lightweight.py` dans `app.py` + retirer le port 5003 de `services.conf`
- Supprimer `app_lightweight.py`
- Gain : -2800 lignes, un seul point d'entrée
### C5. Lier étape VWB ↔ screenshot source du workflow Léa
**Effort** : 2 jours
**Contexte** : actuellement quand on importe un workflow Léa, les steps sont **des actions sans contexte visuel**. Pour pouvoir « corriger visuellement » (idée Dom), il faut que chaque step affiche :
- Le screenshot `from_node` (état avant l'action)
- Le screenshot `to_node` (état après)
- Les bbox cliquées (target)
- Les workflows Léa contiennent déjà des `screenshot_hash` dans leurs nodes (à vérifier dans `notepad_enriched.json`)
- Modifier `convert_learned_to_vwb_steps` pour persister les screenshots en tant que `VisualAnchor` + `anchor_id` sur le step
- Enrichir `StepNode.tsx` pour afficher la vignette
**Note** : c'est ce qui donne du sens à l'idée de Dom. Sans ça, l'import Léa→VWB donne des étapes abstraites « click(x=42%, y=68%) » que personne ne peut corriger visuellement.
---
## Section D — Chantiers lourds (>1 semaine)
### D1. Refonte du stockage workflow : un seul format canonique
**Effort** : 1-2 semaines
**Justification** : aujourd'hui on a 2 formats (core JSON de Léa vs SQLite VWB) avec un bridge. Le bridge perd de l'information (ex: `compound` décomposé, metadata core/screenshots abandonnés en route). À chaque nouvelle feature du core (C2 aujourd'hui, grounding, extraction, etc.) il faut mettre à jour le bridge.
**Proposition** : VWB stocke directement le format core JSON (ou un surset strict). Les « steps » VWB deviennent une **vue dérivée** du graphe core, pas une copie. Les corrections humaines modifient le JSON core.
**À faire seulement si** l'import Léa→VWB devient un flux majeur et que le bridge montre ses limites. Pour l'instant, le bridge fait le job.
### D2. Mode collaboratif / multi-utilisateurs
**Effort** : 2-3 semaines
**Justification** : aucune pour aujourd'hui. Dom est seul à utiliser le VWB. À garder dans le coin de la tête pour quand les premiers clients testeront.
---
## Section E — Non-décisions
Choses qu'on **ne fera pas**, pour se prémunir des tentations.
### E1. Ne PAS réécrire le frontend en v5
Pourquoi : le v4 Vite+xyflow est récent (mars 2026), propre, bien structuré. Pas de raison architecturale. La migration v3→v4 a déjà coûté cher (cf. nombreux `CORRECTION_TYPESCRIPT_*.md` en janvier).
### E2. Ne PAS unifier `instance/*.db` en PostgreSQL
Pourquoi : SQLite convient pour un outil desktop mono-utilisateur. PostgreSQL ajoute une dépendance runtime sans valeur tangible tant qu'on est seul. Le `.env` mentionne déjà l'option, gardée pour plus tard.
### E3. Ne PAS porter CorrectionPacksDashboard sur le v4
Pourquoi : la fonctionnalité « correction packs » est en doublon conceptuel avec le nouveau flux `review_status` + `learned_workflow_bridge`. Autant fermer proprement correction_packs (C4 bis) plutôt que le migrer.
### E4. Ne PAS rajouter de tests property pour le v4 tout de suite
Pourquoi : 14 tests `test_vwb_frontend_v2_*` existent déjà et ne tournent plus (pointent vers le v2). Avant de recréer des tests property, il faut décider si on garde le v2 ou pas (C3). Refaire 14 tests pour les jeter dans 2 semaines = gâchis.
### E5. Ne PAS implémenter le mode WebSocket realtime pour le VWB
Pourquoi : le polling actuel (500 ms dans `App.tsx`) suffit pour l'usage. WebSocket existe déjà (`socketio`) mais n'est câblé nulle part dans le v4. On peut l'ajouter quand une vraie feature le demande (ex: collaborative editing = E1/D2 → pas maintenant).
---
## Section F — Recommandation d'ordre
**Semaine 1 — quick wins utilisateur (jour J)**
1. B1 — localStorage pour CaptureLibrary (30 min) → résout le bug principal
2. B2 — nom lisible à l'import (20 min) → la liste devient utilisable
3. B3 — supprimer BDD fantôme (15 min) → évite un bug futur
4. B6 — nettoyer les fichiers parasites (10 min) → hygiène
5. B7 — clarifier run.sh (20 min) → moins de confusion
Total : ~2h de dev pour un retour utilisateur net dès demain.
**Semaine 1 — chantier moyen utile (jours J+1 à J+3)**
6. C1 — finaliser le flux « Import Léa → review → replay ». C'est la VALEUR DIRECTE de l'idée de Dom : « importer les workflows Léa pour les corriger visuellement ».
**Semaine 2 — étendre la valeur (si C1 tient la route)**
7. C5 — lier step ↔ screenshot source. C'est ce qui transforme le VWB en **vrai outil de correction visuelle**. Sans ça, l'import Léa est abstrait.
**Semaine 3+ — consolidation (quand bande passante)**
8. C3 — retirer frontend legacy (gain de clarté)
9. C4 — consolider les 3 `app*.py` (gain dette)
10. C2 — bibliothèque captures serveur (si B1 montre ses limites)
**Justification globale** :
- **Les 2h de quick wins** donnent à Dom une expérience visible et immédiate (bibliothèque qui ne s'efface plus, liste lisible, moins de bruit).
- **C1** capitalise sur l'investissement existant (le pont Léa/VWB est déjà codé à 80%, il faut juste finir le dernier kilomètre — les warnings visuels et le bouton « valider et exécuter »).
- **C5** est le vrai game-changer pour l'idée de Dom, mais ne vaut le coup que si C1 a confirmé que le flux globalement fonctionne. Si C1 révèle que le bridge perd trop d'info, on saute direct à D1 (refonte).
- **C3/C4** sont de la dette : on s'en occupe quand on a quelqu'un sous la main pour pas ralentir les features.
**À éviter** : commencer par C3 ou C4 (dette) parce qu'aucun impact utilisateur visible → pas de ROI court terme.
---
## Risques identifiés
- **Backup BDD** : `backend/instance/backups/` contient un seul backup du 23/01. Aucune rotation automatique. Risque de perte : 3 workflows seulement mais ce sont ceux de Dom → ajouter un backup quotidien via `backup_ssd.sh` (déjà existant à la racine `~/ai/`).
- **`localStorage` quota** : B1 seul ne suffira pas à long terme. Prévoir C2 si Dom fait plus de 50-100 captures en PNG base64.
- **Modèle `Workflow` sans cascade sur source='learned_import'** : si Dom supprime un workflow importé, rien ne met à jour le JSON core sur disque → divergence. Acceptable tant que l'import est monodirectionnel (disque → VWB) mais à surveiller.
---
## Métriques de succès
- Bibliothèque persiste après reload navigateur : testable manuellement en 30 s
- Liste de workflows ne contient plus « Unnamed Workflow » : testable via SQL
- Un workflow Léa importé a un badge « à réviser » visible, et le bouton « Valider et exécuter » le fait tourner sans quitter le VWB
- 0 warning 404 dans `backend/logs/vwb.log` après B5
- `run_v4.sh` unique script documenté dans README

View File

@@ -0,0 +1,566 @@
# Plan de test humain — 16 avril 2026 matin
**Cible** : valider le nouveau ZIP Léa (Lea_v1.0.0.zip reconstruit avec C2 + enrichissement UI + fail-safe UAC + enrollment fleet) sur la VM Windows 11.
**Durée estimée** : 45 min si tout passe, 1h30 si un test bloque.
**Principe général** : tests du plus simple au plus complexe. Ne pas sauter d'étape, chaque test dépend du précédent.
---
## Section 1 — Pré-requis (checklist 5 min)
### 1.1 — Côté serveur Linux (poste Dom)
Ouvrir un terminal et vérifier dans l'ordre :
```bash
cd /home/dom/ai/rpa_vision_v3
# 1) Services up (systemd user)
./svc.sh status
# On doit voir actifs au minimum :
# streaming (5005) — OBLIGATOIRE
# dashboard (5001) — OBLIGATOIRE
# api (8000) — utile mais pas bloquant
# vwb-backend (5002), vwb-frontend (3002) — pour regarder les workflows enrichis
# 2) Ollama tourne avec le modèle VLM attendu
curl -s http://localhost:11434/api/tags | grep -E "gemma4|ui-tars|qwen" | head -5
# 3) Token API effectif (doit correspondre à celui de config.txt sur la VM)
grep -E "^RPA_API_TOKEN" .env .env.local 2>/dev/null | head -2
# Attendu : 86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
# 4) Endpoint streaming répond
curl -s http://localhost:5005/health | head
# Attendu : {"status":"ok",...}
# 5) Dashboard répond (401 attendu sans auth — c'est la preuve que l'auth est active)
curl -sI http://localhost:5001/ | head -3
# Attendu : HTTP/1.1 401 Unauthorized + WWW-Authenticate: Basic
# 6) Endpoint fleet accessible (sans auth — c'est un endpoint API_TOKEN)
curl -s http://localhost:5005/api/v1/agents/fleet | head -200
# Attendu : JSON avec active/uninstalled/total_active/total_uninstalled
```
**Si un service est down** : `./svc.sh start <nom>` puis `./svc.sh logs <nom>` pour vérifier.
### 1.2 — Accès exposé Internet (à tester depuis la VM)
- `https://lea.labs.laurinebazin.design/health` → doit renvoyer 200 OK (proxy NPM → streaming 5005)
- `https://vwb.labs.laurinebazin.design/` → challenge auth Basic `lea` / `Medecin2026!`
### 1.3 — Côté VM Windows
- VM démarrée, session Windows 11 ouverte (compte local, pas domaine)
- Accès RDP ou console (Spice) fonctionnel
- Internet OK sur la VM : ouvrir Edge, faire `https://lea.labs.laurinebazin.design/health`, doit renvoyer `ok`
- **Si aucune instance Léa précédente installée** : rien à faire.
- **Si une instance Léa précédente est déjà installée** : noter le chemin (`C:\rpa_vision\` ou `C:\Lea\`) et **conserver `config.txt`** à portée de main pour restaurer le token.
### 1.4 — Fichiers à avoir sous la main
Sur le poste Dom, dans `~/ai/rpa_vision_v3/deploy/` :
- `Lea_v1.0.0.zip` (ZIP fraîchement reconstruit — vérifier la date : `ls -la deploy/Lea_v1.0.0.zip`)
- `lea_package/config.txt` (référence si on veut vérifier le contenu attendu)
- `lea_package/LISEZMOI.txt` (pour référence utilisateur)
**Commande utile** pour vérifier la fraîcheur du ZIP :
```bash
ls -la /home/dom/ai/rpa_vision_v3/deploy/Lea_v1.0.0.zip
unzip -l /home/dom/ai/rpa_vision_v3/deploy/Lea_v1.0.0.zip | head -20
```
---
## Section 2 — Déploiement du nouveau ZIP sur la VM (10 min)
### 2.1 — Transférer le ZIP sur la VM
**Option A — Drag & drop VirtualBox/VMware** : déposer `Lea_v1.0.0.zip` dans `C:\Users\<user>\Downloads\`.
**Option B — scp (si SSH actif sur la VM)** :
```bash
scp /home/dom/ai/rpa_vision_v3/deploy/Lea_v1.0.0.zip user@192.168.x.x:/C:/Users/<user>/Downloads/
```
**Option C — navigateur** : depuis la VM, aller sur `https://vwb.labs.laurinebazin.design/downloads/Lea_v1.0.0.zip` (si exposé — sinon utiliser A ou B).
### 2.2 — Arrêter l'instance Léa existante (si présente)
Sur la VM, ouvrir un terminal PowerShell ou cmd :
```cmd
:: Chercher le lock de l'ancienne instance
type C:\rpa_vision\lea_agent.lock
:: Si un PID apparaît, le tuer proprement :
taskkill /F /PID <PID_affiché>
:: Vérifier qu'aucun pythonw.exe Lea ne tourne plus
tasklist | findstr pythonw
```
Ne JAMAIS faire `taskkill /F /IM pythonw.exe` (tuerait Jupyter, Anaconda, etc.).
### 2.3 — Sauvegarder l'ancien config.txt
```cmd
copy C:\rpa_vision\config.txt C:\Users\<user>\Desktop\config.txt.backup
```
### 2.4 — Extraire le nouveau ZIP
```cmd
:: Renommer l'ancien dossier (rollback si besoin)
ren C:\rpa_vision C:\rpa_vision_old_16avril
:: Extraire le ZIP via l'explorateur (clic droit → Extraire tout → C:\rpa_vision)
:: OU via PowerShell :
powershell -command "Expand-Archive -Path C:\Users\<user>\Downloads\Lea_v1.0.0.zip -DestinationPath C:\rpa_vision -Force"
```
### 2.5 — Restaurer le token dans config.txt
Ouvrir `C:\rpa_vision\config.txt` dans Notepad et vérifier :
```
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
RPA_SERVER_HOST=lea.labs.laurinebazin.design
RPA_BLUR_SENSITIVE=false
```
**Important** : le token doit correspondre à celui du serveur (cf. Section 1.1 étape 3).
### 2.6 — Premier lancement
Double-cliquer sur `C:\rpa_vision\install.bat` (si premier déploiement — crée `.venv`).
Puis lancer `C:\rpa_vision\Lea.bat`. Une console s'ouvre 3 secondes, puis une icône apparaît dans la zone de notification Windows (près de l'horloge).
**Preuve que ça tourne** :
- Fichier `C:\rpa_vision\lea_agent.lock` contient un PID
- `tasklist | findstr pythonw` retourne au moins une ligne
Si rien n'apparaît, voir Section 4 (diagnostic).
---
## Section 3 — Tests fonctionnels (ordre important)
### Test 1 — Baseline + streaming + enrollment (objectifs 1, 2, 8)
**Pré-conditions** : Léa vient de démarrer sur la VM, icône visible dans systray.
**Actions** :
1. Côté Dom, lancer un watch sur les logs streaming :
```bash
./svc.sh logs streaming -f
```
2. Côté VM, clic droit sur l'icône Léa → "Apprenez-moi une tâche" (ou équivalent dans le menu).
3. Faire une action triviale : ouvrir le menu Démarrer, taper `notepad`, valider.
4. Clic droit sur Léa → "C'est terminé".
**Observations attendues** côté serveur :
- `[STREAM] register session_id=...` dès le démarrage
- `[STREAM] event ...` pour chaque click/touche
- `[STREAM] image ...` pour chaque screenshot (un par action)
- `[FLEET] Agent enrolé (created) : machine_id=...` au premier lancement uniquement
**Critères de succès** :
- La session apparaît dans :
```bash
curl -s http://localhost:5005/api/v1/traces/stream/sessions | python3 -m json.tool | head -40
```
- L'agent apparaît dans `/fleet` :
```bash
curl -s http://localhost:5005/api/v1/agents/fleet | python3 -m json.tool
```
→ `total_active >= 1`, avec `machine_id`, `hostname`, `version`, `enrolled_at` récent.
**Critères d'échec** :
- Aucun log streaming → voir Section 4 lignes "Token invalide" / "Agent ne démarre pas"
- Événements reçus mais pas d'image → voir Section 4 "images non streamées"
---
### Test 2 — Auth dashboard (objectif 7)
**Pré-conditions** : dashboard service up (cf. 1.1).
**Actions depuis la VM** :
1. Ouvrir `https://vwb.labs.laurinebazin.design/` → challenge Basic Auth → saisir `lea` / `Medecin2026!`
2. Ouvrir le dashboard local (si exposé) ou tester en direct depuis Dom :
```bash
curl -s -u lea:changeme-dashboard-Medecin2026! http://localhost:5001/ | head -20
```
**Critère de succès** : page HTML renvoyée (pas de 401).
**Critère d'échec** : 401 persistant → vérifier `DASHBOARD_PASSWORD` dans l'env systemd :
```bash
systemctl --user show rpa-vision-v3-dashboard | grep DASHBOARD_PASSWORD
```
---
### Test 3 — Enrichissement UI côté serveur (objectif 3, C2)
**Pré-conditions** : Test 1 exécuté, session créée et finalisée.
**Actions** :
```bash
# Récupérer l'ID de la dernière session
SESS=$(curl -s http://localhost:5005/api/v1/traces/stream/sessions | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d[-1]['session_id'])")
echo "Session : $SESS"
# Récupérer la session détaillée (doit contenir les ScreenStates enrichis)
curl -s "http://localhost:5005/api/v1/traces/stream/session/$SESS" \
| python3 -m json.tool > /tmp/sess_16avril.json
wc -l /tmp/sess_16avril.json
```
**Vérifier le contenu des ScreenStates persistés** :
```bash
# Les ScreenStates JSON sont sauvegardés sous data/screen_states/<YYYY-MM-DD>/
find /home/dom/ai/rpa_vision_v3/data/screen_states -name "state_*.json" -newer /tmp/sess_16avril.json -type f 2>/dev/null | head -5
# Pour un fichier donné, vérifier ui_elements ET detected_text non vides
LAST_STATE=$(find /home/dom/ai/rpa_vision_v3/data/screen_states -name "state_*.json" -type f -printf '%T@ %p\n' | sort -n | tail -1 | awk '{print $2}')
python3 -c "
import json
d = json.load(open('$LAST_STATE'))
print('ui_elements count:', len(d.get('ui_elements', [])))
print('detected_text len:', len(d.get('detected_text', '')))
print('sample ui_element:', d.get('ui_elements', [{}])[0] if d.get('ui_elements') else 'NONE')
"
```
**Critères de succès** :
- `ui_elements count >= 3` sur un écran normal (Notepad ouvert ~5-15 éléments)
- `detected_text len > 20` (le texte OCR doit contenir des mots lisibles)
- Au moins un `ui_element` avec un `role` rempli (`button`, `textbox`, `menu`, etc.)
**Critère d'échec** : `ui_elements count == 0` partout → C2 ne tourne pas côté serveur. Vérifier logs `./svc.sh logs streaming | grep -iE "detect|ocr|screen_analyzer"`.
---
### Test 4 — Target resolution (objectifs 4, C1 + Lot E)
**Pré-conditions** : une session Test 1 réussie, avec au moins 2-3 actions.
**Actions** — demander un replay via l'endpoint :
```bash
curl -s -X POST http://localhost:5005/api/v1/traces/stream/replay \
-H "Authorization: Bearer 86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab" \
-H "Content-Type: application/json" \
-d "{\"session_id\": \"$SESS\", \"mode\": \"dry_run\"}" \
| python3 -m json.tool > /tmp/replay_plan.json
```
**Vérifications** :
```bash
# Le plan doit contenir des TargetSpec avec by_role / by_text, pas juste des coordonnées
grep -E "\"by_role\"|\"by_text\"" /tmp/replay_plan.json | head -10
grep -E "\"unknown_element\"" /tmp/replay_plan.json | wc -l
```
**Critères de succès** :
- Au moins 50% des `TargetSpec` ont un `by_role` OU un `by_text` non null
- Moins de 20% des cibles sont marquées `unknown_element`
**Critère d'échec** : 100% `unknown_element` → target_resolver ne lit pas les `ui_elements`. Relire logs streaming pour erreurs d'import.
---
### Test 5 — Cache context-aware (objectif 5, Lot D)
**But** : s'assurer que deux workflows différents sur le même écran ne partagent pas le cache de target resolution.
**Actions** (simplifié — pas de test unitaire ici, juste observation) :
1. Côté VM, enregistrer une session 1 : ouvrir Bloc-notes, cliquer sur menu "Fichier" → "Nouveau".
2. Arrêter l'enregistrement ("C'est terminé").
3. Redémarrer un enregistrement (session 2) : même écran Bloc-notes, mais cliquer "Fichier" → "Enregistrer".
4. Arrêter.
**Vérifications côté serveur** :
```bash
./svc.sh logs streaming | grep -iE "cache_key|cache hit|context_hints" | tail -20
```
**Critère de succès** : les `cache_key` des deux sessions diffèrent même si le ScreenState est similaire (le `context_hints` dans la clé fait la différence).
**Critère d'échec** : même `cache_key`, même résultat → bug Lot D. Pas bloquant pour la démo mais à noter.
---
### Test 6 — Fail-safe UAC (objectif 6, P0-D)
**But** : un popup UAC bloque le replay proprement.
**Actions** :
1. Côté VM, lancer un replay d'une session existante (Test 4 ci-dessus).
2. **Pendant l'exécution**, faire clic droit sur un `.exe` nécessitant élévation → "Exécuter en tant qu'administrateur" → le popup UAC apparaît.
3. **Ne pas cliquer** sur le UAC.
**Observations attendues** :
- Côté VM : Léa arrête toute action (pas de clic sur UAC)
- Côté serveur, logs :
```bash
./svc.sh logs streaming | grep -iE "UAC|CredUI|SmartScreen|paused_need_help" | tail -5
```
→ messages du type `paused_need_help` ou `élévation de privilèges (UAC) détectée`
**Critère de succès** :
- Aucun clic envoyé sur la fenêtre UAC
- Le replay passe en `status: paused_need_help`
- Un appel `curl .../replay/<replay_id>` montre bien ce statut
**Critère d'échec critique** : Léa clique sur "Oui" / "Non" de l'UAC → **arrêter immédiatement les tests**, c'est une régression de sécurité.
**Cleanup** : fermer manuellement le popup UAC (clic sur Non).
---
### Test 7 — Blur PII côté serveur (objectif 9)
**But** : vérifier que les screenshots contenant des noms/mails sont bien floutés côté serveur.
**Actions côté VM** :
1. Ouvrir Bloc-notes, écrire :
```
Patient : Jean Dupont
Né le 12/03/1980
Tél : 06 12 34 56 78
Email : jean.dupont@test.fr
```
2. Lancer un enregistrement court via le menu Léa.
3. Faire 2-3 clics dans Bloc-notes (scroll, sélection).
4. Arrêter.
**Vérifications côté serveur** :
```bash
# Chercher les paires _raw / _blurred dans la session
SESS_DIR=$(find /home/dom/ai/rpa_vision_v3/data -type d -name "$SESS*" 2>/dev/null | head -1)
ls -la "$SESS_DIR"/*.png 2>/dev/null | grep -E "_blurred|_raw" | head -10
# Comparer les tailles — le _blurred doit exister
find /home/dom/ai/rpa_vision_v3/data -name "*_blurred.png" -newer /tmp/sess_16avril.json 2>/dev/null | head -5
```
**Critères de succès** :
- Au moins un fichier `*_blurred.png` présent pour cette session
- Ouvert dans un viewer, les zones "Jean Dupont", "06 12 34...", "jean.dupont@..." sont floutées/boxées
- Le fichier `*_raw.png` reste net (destiné à l'entraînement)
**Critère d'échec** : aucun `_blurred.png` généré → module `core/anonymisation/pii_blur.py` pas appelé. Vérifier les logs avec `grep -i "pii_blur\|anonymisation"`.
---
### Test 8 — Logs d'audit (objectif 10)
**Actions** :
```bash
# Vérifier que le fichier JSONL du jour existe et se remplit
ls -la /home/dom/ai/rpa_vision_v3/data/audit/audit_2026-04-16.jsonl
tail -5 /home/dom/ai/rpa_vision_v3/data/audit/audit_2026-04-16.jsonl | python3 -m json.tool
# Endpoint d'audit (API_TOKEN requis si sécurisé — ici en dev)
curl -s "http://localhost:5005/api/v1/audit/history?limit=5" \
| python3 -m json.tool | head -60
# Summary du jour
curl -s "http://localhost:5005/api/v1/audit/summary?date=2026-04-16" \
| python3 -m json.tool
```
**Critères de succès** :
- Fichier `audit_2026-04-16.jsonl` existe
- Chaque ligne contient `timestamp`, `session_id`, `action_id`, `machine_id`, `action_type`, `result`
- Le `machine_id` correspond à la VM (pas "Unknown")
**Critère d'échec** : fichier inexistant → `AuditTrail` pas initialisé. Vérifier logs streaming au démarrage.
---
### Test 9 (bonus si temps) — Replay E2E complet sur Bloc-notes
**But** : valider la chaîne complète apprentissage → replay réussi.
**Actions** :
1. Sur la VM, enregistrer : ouvrir Bloc-notes depuis le menu Démarrer → taper "Hello Lea 16 avril" → Fichier → Enregistrer sous → Bureau → Nom "test_lea.txt" → Enregistrer.
2. Arrêter l'enregistrement.
3. Fermer Bloc-notes et supprimer le fichier créé.
4. Lancer le replay depuis le menu Léa (ou via API curl Test 4 sans `dry_run`).
**Critères de succès** :
- Bloc-notes s'ouvre tout seul
- Le texte est tapé (attention AZERTY : si c'est Qwerty côté VM, possible que "Lea" sorte en "Léq" — noter mais pas bloquant)
- Le fichier `test_lea.txt` est créé sur le Bureau
- Statut replay final : `completed`
---
## Section 4 — Diagnostic rapide
| # | Symptôme | Cause probable | Comment vérifier | Fix rapide |
|---|----------|----------------|------------------|------------|
| 1 | Léa ne démarre pas (pas d'icône systray) | `.venv` absent ou install interrompue | `dir C:\rpa_vision\.venv\Scripts\python.exe` | Relancer `install.bat` |
| 2 | Léa démarre puis crash (console ferme) | Erreur Python au boot | Lancer à la main : `C:\rpa_vision\.venv\Scripts\python.exe C:\rpa_vision\run_agent_v1.py` → lire la stack trace | Selon l'erreur : check deps, check `config.txt`, check `RPA_SERVER_URL` |
| 3 | Agent tourne mais rien n'arrive côté serveur | Mauvais `RPA_SERVER_URL` / firewall / DNS | Sur la VM : `curl https://lea.labs.laurinebazin.design/health` | Corriger URL dans `config.txt`, relancer `Lea.bat` |
| 4 | 401 Unauthorized côté serveur | Token différent entre VM et serveur | `grep RPA_API_TOKEN C:\rpa_vision\config.txt` vs `grep RPA_API_TOKEN /home/dom/ai/rpa_vision_v3/.env.local` | Aligner les deux, relancer `Lea.bat` et `./svc.sh restart streaming` |
| 5 | Popup UAC → Léa clique dessus | **RÉGRESSION CRITIQUE** | Voir logs `grep -i UAC` côté serveur | **Arrêter le test**, remonter à Dom |
| 6 | `total_active == 0` dans /fleet | Enrollment jamais déclenché ou échoué | Logs streaming : `grep FLEET` | Vérifier que le build contient `agent_registry.py` côté client + relancer |
| 7 | `ui_elements = []` dans les ScreenStates | C2 pas appelé ou Ollama down | `curl http://localhost:11434/api/tags` + logs streaming `grep -i detector` | Relancer Ollama : `systemctl --user restart ollama` |
| 8 | `by_role`/`by_text` tous null | TargetSpecBuilder ne lit pas les UIElements | Logs streaming `grep -i target_spec` | Bug code — noter et passer au test suivant |
| 9 | Aucun `_blurred.png` | `pii_blur.py` pas appelé | Logs : `grep -i "blur\|anonymisation"` | Vérifier que les imports côté serveur passent |
| 10 | Replay planté sans erreur claire | Session incomplète, pas de workflow compilé | `curl .../workflow/compile` en direct | Re-enregistrer une nouvelle session plus courte |
### Commandes debug utiles (à garder sous la main)
```bash
# État global
./svc.sh status
./status.sh
# Logs live streaming
./svc.sh logs streaming -f
# Dernière session streamée
curl -s http://localhost:5005/api/v1/traces/stream/sessions | python3 -m json.tool | tail -30
# Dernier screenshot reçu (ordre décroissant)
ls -lt /home/dom/ai/rpa_vision_v3/data/streaming_sessions/*.json | head -3
# Audit du jour
tail -f /home/dom/ai/rpa_vision_v3/data/audit/audit_2026-04-16.jsonl
# Base fleet
sqlite3 /home/dom/ai/rpa_vision_v3/data/databases/rpa_data.db \
"SELECT machine_id, user_name, hostname, version, status, enrolled_at FROM enrolled_agents;"
```
---
## Section 5 — Grille d'observation C2 (enrichissement UI)
### Comment vérifier concrètement que C2 fonctionne
**Niveau 1 — ScreenState persisté** :
```bash
LAST_STATE=$(find /home/dom/ai/rpa_vision_v3/data/screen_states -name "state_*.json" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | awk '{print $2}')
python3 <<EOF
import json
d = json.load(open("$LAST_STATE"))
print("screen_state_id:", d.get("screen_state_id"))
print("ui_elements count:", len(d.get("ui_elements", [])))
print("detected_text length:", len(d.get("detected_text", "")))
if d.get("ui_elements"):
el = d["ui_elements"][0]
print("first element keys:", list(el.keys()))
print("first element role:", el.get("role"))
print("first element text:", el.get("text"))
print("first element bbox:", el.get("bbox"))
EOF
```
**Niveau 2 — TargetSpec dans le workflow compilé** :
```bash
# Récupérer un workflow compilé
WF=$(curl -s http://localhost:5005/api/v1/traces/stream/workflows | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d[-1]['workflow_id'])")
curl -s "http://localhost:5005/api/v1/traces/stream/workflow/$WF" \
| python3 -m json.tool > /tmp/wf.json
# Compter les TargetSpec qualitatifs
python3 <<'EOF'
import json
d = json.load(open("/tmp/wf.json"))
nodes = d.get("nodes", [])
total = len(nodes)
with_role = sum(1 for n in nodes if (n.get("target_spec") or {}).get("by_role"))
with_text = sum(1 for n in nodes if (n.get("target_spec") or {}).get("by_text"))
unknown = sum(1 for n in nodes if (n.get("target_spec") or {}).get("by_role") in (None, "unknown_element"))
print(f"Total nodes : {total}")
print(f"With by_role : {with_role} ({100*with_role/max(total,1):.0f}%)")
print(f"With by_text : {with_text} ({100*with_text/max(total,1):.0f}%)")
print(f"Unknown : {unknown} ({100*unknown/max(total,1):.0f}%)")
EOF
```
**Exemple de TargetSpec attendu (succès C2)** :
```json
{
"by_role": "button",
"by_text": "Enregistrer",
"by_position": {"x": 0.52, "y": 0.89},
"context_hints": {"near_text": "Nom du fichier"}
}
```
**Exemple de TargetSpec dégradé (C2 ne tourne pas)** :
```json
{
"by_role": "unknown_element",
"by_text": null,
"by_position": {"x": 1024, "y": 768}
}
```
---
## Section 6 — Ce qu'on veut éviter
1. **Ne pas lancer deux instances Léa en même temps sur la VM** — le lock PID suffit normalement, mais vérifier manuellement : une seule ligne `pythonw` dans `tasklist`.
2. **Ne pas exposer le dashboard sur Internet pendant les tests** — rester sur `http://localhost:5001/` côté Dom. Le VWB exposé est OK (auth Basic), le dashboard non.
3. **Ne jamais tester avec des données patient réelles** — c'est un POC. Utiliser uniquement des noms/codes de test ("Jean Dupont", "TestPatient01", etc.).
4. **Ne pas interrompre un replay en cours avec Ctrl+C dans la console** — utiliser le menu Léa "Stop" pour arrêt propre (sinon le lock reste et il faut le tuer à la main).
5. **Ne pas modifier `config.txt` pendant que Léa tourne** — la config est lue au démarrage uniquement. Redémarrer après modif.
6. **Ne pas supprimer `C:\rpa_vision_old_16avril\`** avant d'avoir validé que la nouvelle version marche (rollback possible).
7. **Ne pas cliquer sur le popup UAC pendant le Test 6** — c'est le but du test, le laisser apparaître et attendre que Léa s'arrête.
8. **Ne pas toucher aux services systemd côté Dom pendant qu'un test tourne** — attendre la fin du test avant `./svc.sh restart`.
9. **Ne pas déployer le ZIP sur la VM "prod"** — uniquement VM de test. Ce build contient des fonctionnalités non validées.
10. **Ne pas committer le contenu de `data/audit/` ou `data/screen_states/`** — ce sont des données de test locales (screenshots + PII éventuelles).
---
## Annexe — URLs et chemins de référence
| Ressource | URL / chemin |
|-----------|--------------|
| Streaming API (local) | http://localhost:5005 |
| Streaming API (Internet) | https://lea.labs.laurinebazin.design |
| Dashboard (local) | http://localhost:5001 (Basic auth `lea` / `changeme-dashboard-Medecin2026!` ou env `DASHBOARD_PASSWORD`) |
| VWB (Internet) | https://vwb.labs.laurinebazin.design (Basic auth `lea` / `Medecin2026!`) |
| Token API | `86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab` |
| ZIP client | `/home/dom/ai/rpa_vision_v3/deploy/Lea_v1.0.0.zip` |
| Sessions streaming | `/home/dom/ai/rpa_vision_v3/data/streaming_sessions/` |
| ScreenStates persistés | `/home/dom/ai/rpa_vision_v3/data/screen_states/YYYY-MM-DD/` |
| Audit logs | `/home/dom/ai/rpa_vision_v3/data/audit/audit_YYYY-MM-DD.jsonl` |
| Base fleet SQLite | `/home/dom/ai/rpa_vision_v3/data/databases/rpa_data.db` (table `enrolled_agents`) |
| Logs services | `./svc.sh logs <service>` ou `/home/dom/ai/rpa_vision_v3/logs/` |
---
## Checklist finale (à cocher au fur et à mesure)
- [ ] 1.1 — Services Linux up
- [ ] 1.2 — Endpoints Internet OK
- [ ] 1.3 — VM prête, internet OK
- [ ] 2.x — ZIP déployé, Léa démarre
- [ ] Test 1 — Streaming + enrollment OK
- [ ] Test 2 — Auth dashboard OK
- [ ] Test 3 — ScreenStates enrichis (C2)
- [ ] Test 4 — TargetSpec qualitatifs (C1 + Lot E)
- [ ] Test 5 — Cache context-aware (Lot D)
- [ ] Test 6 — Fail-safe UAC (P0-D)
- [ ] Test 7 — Blur PII serveur
- [ ] Test 8 — Logs audit
- [ ] Test 9 (bonus) — Replay E2E Bloc-notes
Bonne session ! En cas de blocage, revenir à la Section 4 avant de plonger dans le code.

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