Compare commits
115 Commits
7f2bc6fe97
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5543e25f9d | ||
|
|
2a07d8084b | ||
|
|
35b27ae492 | ||
|
|
b584bbabc3 | ||
|
|
8817f527e7 | ||
|
|
964856ab30 | ||
|
|
a67d896104 | ||
|
|
90c1d8036f | ||
|
|
6261002039 | ||
|
|
0e6e61f2b1 | ||
|
|
41c1250c99 | ||
|
|
2af3bc3b93 | ||
|
|
6154423a91 | ||
|
|
41eba898c0 | ||
|
|
9452e86fd1 | ||
|
|
5e31cdf666 | ||
|
|
487bcb8618 | ||
|
|
3d6868f029 | ||
|
|
f73a2a59a9 | ||
|
|
77faa03ec9 | ||
|
|
343d6fbe95 | ||
|
|
cc64439738 | ||
|
|
90007cc7c1 | ||
|
|
73cea2385e | ||
|
|
e2046837cf | ||
|
|
b30d4b6656 | ||
|
|
e4a48e78bf | ||
|
|
ea36bba5cc | ||
|
|
9da589c8c2 | ||
|
|
16ff396dbf | ||
|
|
e44fd7b328 | ||
|
|
66815b7a1a | ||
|
|
c6b695eca8 | ||
|
|
99d2083dea | ||
|
|
a718086140 | ||
|
|
c82979e72b | ||
|
|
2185c41cc1 | ||
|
|
26804eb123 | ||
|
|
d71d5df4a8 | ||
|
|
6829ad8e79 | ||
|
|
8903f35433 | ||
|
|
4ab2c15e5c | ||
|
|
eba6fea779 | ||
|
|
f04398d5a7 | ||
|
|
4ce9c47f45 | ||
|
|
9dfcdb5fb0 | ||
|
|
3efe15d2c7 | ||
|
|
9d87ed64c5 | ||
|
|
00134963e5 | ||
|
|
0ec5e2a25b | ||
|
|
0c5fffe951 | ||
|
|
5027ed9a23 | ||
|
|
6caab2c600 | ||
|
|
552e66dbf6 | ||
|
|
de1026ee2e | ||
|
|
7b50725bf8 | ||
|
|
7feef3b6a9 | ||
|
|
0b06db222d | ||
|
|
74ee0dadee | ||
|
|
0b452f975a | ||
|
|
6ab385d671 | ||
|
|
b3eab83a0f | ||
|
|
27490849a8 | ||
|
|
cebbf0809a | ||
|
|
3e227d28ad | ||
|
|
8ce63fcba2 | ||
|
|
4202431421 | ||
|
|
4923623dd4 | ||
|
|
84181cc982 | ||
|
|
7355d315a3 | ||
|
|
c50adab3a1 | ||
|
|
2fbb305f65 | ||
|
|
ff581be397 | ||
|
|
203e5cc6c1 | ||
|
|
d1b556b6cd | ||
|
|
729cd67743 | ||
|
|
73ddcdb29d | ||
|
|
14a9442343 | ||
|
|
5da4581e76 | ||
|
|
cbe8dc95d2 | ||
|
|
04a14a56b2 | ||
|
|
2290f1846b | ||
|
|
c57b40ae1d | ||
|
|
bc21b27da7 | ||
|
|
6a2248ddcd | ||
|
|
82d7b38cff | ||
|
|
6c7f88c05d | ||
|
|
447fbb2c6e | ||
|
|
623be15bfe | ||
|
|
55d5aebbd2 | ||
|
|
73b731fef8 | ||
|
|
ffd97ae9a5 | ||
|
|
d168833609 | ||
|
|
23a06a744c | ||
|
|
af4eae28b9 | ||
|
|
c198c930a1 | ||
|
|
e3efef2fe7 | ||
|
|
95fddeebb3 | ||
|
|
71523cebd3 | ||
|
|
3aa806a630 | ||
|
|
588c8f22c1 | ||
|
|
3d243d731d | ||
|
|
2431a6c9e9 | ||
|
|
969236da03 | ||
|
|
f30461b88c | ||
|
|
f34eca20f9 | ||
|
|
309dfd5287 | ||
|
|
f5a672d7b9 | ||
|
|
1acea85fa6 | ||
|
|
4f61741420 | ||
|
|
2fa864b5c7 | ||
|
|
10739c33fa | ||
|
|
39bea1b042 | ||
|
|
26b4e6d8ce | ||
|
|
4fb84b1090 |
@@ -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
1
.gitignore
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
149
agent_v0/agent_v1/network/feedback_bus.py
Normal file
149
agent_v0/agent_v1/network/feedback_bus.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ mss>=9.0.1 # Capture d'écran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
|
||||
Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
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
|
||||
pystray>=0.19.5 # Icône Tray UI
|
||||
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
|
||||
|
||||
@@ -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
|
||||
# ======================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, Optional
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
|
||||
@@ -15,6 +23,9 @@ from .blur_sensitive import blur_sensitive_regions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OS courant (détecté une seule fois)
|
||||
_SYSTEM = platform.system()
|
||||
|
||||
class VisionCapturer:
|
||||
def __init__(self, session_dir: str):
|
||||
self.session_dir = session_dir
|
||||
@@ -27,13 +38,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 +66,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 +97,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 +112,130 @@ 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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@ mss>=9.0.1 # Capture d'écran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
|
||||
Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
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
|
||||
pystray>=0.19.5 # Icône Tray UI
|
||||
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -219,6 +219,10 @@ 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_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 +296,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."""
|
||||
@@ -2744,8 +2762,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 +2849,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 +2866,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 +2877,80 @@ 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 : no-op en mode autonome — on saute et on continue
|
||||
if type_ == "pause_for_human":
|
||||
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_ == "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,
|
||||
@@ -3865,7 +3978,9 @@ async def resume_replay(replay_id: str):
|
||||
state["pause_message"] = None
|
||||
|
||||
# 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 +4021,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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -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,10 @@ 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": {},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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, "
|
||||
|
||||
643
core/analytics/process_mining_bridge.py
Normal file
643
core/analytics/process_mining_bridge.py
Normal 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
|
||||
60
core/analytics/screen_change_detector.py
Normal file
60
core/analytics/screen_change_detector.py
Normal 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
|
||||
0
core/cognition/__init__.py
Normal file
0
core/cognition/__init__.py
Normal file
191
core/cognition/vram_orchestrator.py
Normal file
191
core/cognition/vram_orchestrator.py
Normal 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
|
||||
260
core/cognition/working_memory.py
Normal file
260
core/cognition/working_memory.py
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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]}...)")
|
||||
|
||||
708
core/execution/input_handler.py
Normal file
708
core/execution/input_handler.py
Normal file
@@ -0,0 +1,708 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
words = []
|
||||
try:
|
||||
import easyocr
|
||||
_reader = easyocr.Reader(['fr', 'en'], gpu=False, 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,
|
||||
) -> 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)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# ─── Niveau 3 — VLM reasoning (~10s) ───
|
||||
result = _grounding_vlm(target_text, target_description)
|
||||
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():
|
||||
"""Capture l'écran principal et retourne (PIL.Image, width, height)."""
|
||||
try:
|
||||
import mss
|
||||
from PIL import Image as PILImage
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = PILImage.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
return screen, monitor['width'], monitor['height']
|
||||
except Exception as e:
|
||||
logger.debug(f"Capture écran échouée: {e}")
|
||||
return None, 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:
|
||||
screen, screen_w, screen_h = _capture_screen()
|
||||
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'], 'y': best['y'], '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 = "") -> 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 = _capture_screen()
|
||||
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, 'y': y, '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 = "") -> 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 = _capture_screen()
|
||||
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, 'y': y, '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
|
||||
@@ -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
|
||||
|
||||
|
||||
2006
core/execution/observe_reason_act.py
Normal file
2006
core/execution/observe_reason_act.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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"
|
||||
):
|
||||
"""
|
||||
|
||||
20
core/grounding/__init__.py
Normal file
20
core/grounding/__init__.py
Normal 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',
|
||||
]
|
||||
256
core/grounding/dialog_handler.py
Normal file
256
core/grounding/dialog_handler.py
Normal 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
|
||||
239
core/grounding/element_signature.py
Normal file
239
core/grounding/element_signature.py
Normal 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,
|
||||
}
|
||||
326
core/grounding/fast_detector.py
Normal file
326
core/grounding/fast_detector.py
Normal 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
|
||||
216
core/grounding/fast_pipeline.py
Normal file
216
core/grounding/fast_pipeline.py
Normal 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
|
||||
81
core/grounding/fast_types.py
Normal file
81
core/grounding/fast_types.py
Normal 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,
|
||||
)
|
||||
210
core/grounding/infigui_worker.py
Normal file
210
core/grounding/infigui_worker.py
Normal 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
190
core/grounding/pipeline.py
Normal 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
113
core/grounding/server.py
Normal 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)
|
||||
156
core/grounding/shadow_learning_hook.py
Normal file
156
core/grounding/shadow_learning_hook.py
Normal 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
|
||||
263
core/grounding/smart_matcher.py
Normal file
263
core/grounding/smart_matcher.py
Normal 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
48
core/grounding/target.py
Normal 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
|
||||
350
core/grounding/template_matcher.py
Normal file
350
core/grounding/template_matcher.py
Normal 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
|
||||
103
core/grounding/think_arbiter.py
Normal file
103
core/grounding/think_arbiter.py
Normal 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
|
||||
174
core/grounding/title_verifier.py
Normal file
174
core/grounding/title_verifier.py
Normal 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
|
||||
161
core/grounding/ui_tars_grounder.py
Normal file
161
core/grounding/ui_tars_grounder.py
Normal 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
|
||||
0
core/knowledge/__init__.py
Normal file
0
core/knowledge/__init__.py
Normal file
523
core/knowledge/ui_patterns.py
Normal file
523
core/knowledge/ui_patterns.py
Normal 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
15
core/llm/__init__.py
Normal 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
71
core/llm/ocr_extractor.py
Normal 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
168
core/llm/t2a_decision.py
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
19
deploy/configs/config_dev_windows.txt
Normal file
19
deploy/configs/config_dev_windows.txt
Normal 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
|
||||
18
deploy/configs/config_pc_fixe_lan.txt
Normal file
18
deploy/configs/config_pc_fixe_lan.txt
Normal 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
|
||||
19
deploy/configs/config_tim_pauline.txt
Normal file
19
deploy/configs/config_tim_pauline.txt
Normal 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
|
||||
18
deploy/configs/config_vm_lan.txt
Normal file
18
deploy/configs/config_vm_lan.txt
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
28
deploy/systemd/rpa-mockup-easily.service
Normal file
28
deploy/systemd/rpa-mockup-easily.service
Normal 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
|
||||
39
deploy/systemd/rpa-streaming.service
Normal file
39
deploy/systemd/rpa-streaming.service
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
897
docs/AUDIT_20260404.md
Normal 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é)*
|
||||
233
docs/CARTOGRAPHY.md
Normal file
233
docs/CARTOGRAPHY.md
Normal 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.
|
||||
291
docs/CHALLENGE_PLANS_16AVRIL.md
Normal file
291
docs/CHALLENGE_PLANS_16AVRIL.md
Normal 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._
|
||||
658
docs/DOSSIER_COMMISSAIRE_AUX_APPORTS.md
Normal file
658
docs/DOSSIER_COMMISSAIRE_AUX_APPORTS.md
Normal 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*
|
||||
192
docs/EXECUTION_LOOP_FLAGS.md
Normal file
192
docs/EXECUTION_LOOP_FLAGS.md
Normal 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
|
||||
267
docs/PLAN_ACTION_DASHBOARD.md
Normal file
267
docs/PLAN_ACTION_DASHBOARD.md
Normal 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.
|
||||
194
docs/PLAN_ACTION_MARS_2026.md
Normal file
194
docs/PLAN_ACTION_MARS_2026.md
Normal 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
273
docs/PLAN_ACTION_VWB.md
Normal 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
|
||||
566
docs/PLAN_TEST_HUMAIN_16AVRIL.md
Normal file
566
docs/PLAN_TEST_HUMAIN_16AVRIL.md
Normal 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.
|
||||
293
docs/POC_ANOUST_PAS.md
Normal file
293
docs/POC_ANOUST_PAS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Plan d'Assurance Sécurité (PAS)
|
||||
|
||||
**Logiciel** : Léa — RPA Vision V3
|
||||
**Version** : 3.x
|
||||
**Date** : 14 avril 2026
|
||||
**Client** : Clinique Anoust (psychiatrie)
|
||||
**Objet** : Extraction automatisée de dossiers patients 2024-2025 + constitution d'un Entrepôt de Données de Santé (EDS)
|
||||
|
||||
---
|
||||
|
||||
## I. Introduction et cadre réglementaire
|
||||
|
||||
### 1.1 Finalité du système
|
||||
|
||||
Léa est un système RPA (Robotic Process Automation) basé sur la vision, déployé **intégralement en local** sur l'infrastructure de l'établissement. Sa finalité est **exclusivement administrative** :
|
||||
- Extraction automatisée de données depuis le DPI (OSIRIS)
|
||||
- Structuration et anonymisation des données extraites
|
||||
- Alimentation d'un Entrepôt de Données de Santé (EDS)
|
||||
|
||||
Le système ne prend **aucune décision clinique**. Il reproduit les gestes d'un opérateur humain naviguant dans les écrans du DPI.
|
||||
|
||||
**Lettre de Non-Qualification** : Léa n'est pas un dispositif médical au sens du Règlement (UE) 2017/745. Une lettre de non-qualification est fournie.
|
||||
|
||||
### 1.2 Réglementation applicable
|
||||
|
||||
| Texte | Applicabilité | Obligations clés |
|
||||
|-------|--------------|------------------|
|
||||
| **RGPD** (art. 9) | Données de santé mentale = catégorie spéciale | AIPD obligatoire, DPO, base légale art. 6.1.e ou 6.1.f |
|
||||
| **Référentiel CNIL EDS** (2021-118) | Entrepôt de données de santé | Déclaration de conformité ou autorisation individuelle |
|
||||
| **AI Act** (Règlement UE 2024/1689) | Système IA en contexte de santé | Évaluation du niveau de risque (art. 6(3)), documentation écrite |
|
||||
| **NIS2** (transposition FR juillet 2026) | Établissement de santé = entité importante | Notification incidents 24h, plan de gestion risques cyber |
|
||||
| **HDS** (L.1111-8 CSP) | **Dispensé si on-premise** | Le déploiement sur l'infrastructure de la clinique ne nécessite pas de certification HDS |
|
||||
| **PGSSI-S** | Données de santé à caractère personnel | Conformité au palier minimal des référentiels ANS |
|
||||
| **Programme CaRE** | Établissements de santé | BIA pour services critiques (échéance juin 2026) |
|
||||
|
||||
### 1.3 Spécificités psychiatrie
|
||||
|
||||
- Les **notes personnelles du psychiatre** (hypothèses, réflexions non formalisées) sont **exclues du périmètre d'extraction** (art. R4127-45 CSP)
|
||||
- Anonymisation renforcée : risque de re-identification élevé en psychiatrie (pathologies rares, situations uniques)
|
||||
- Secret médical renforcé (art. 226-13 Code pénal)
|
||||
|
||||
### 1.4 AI Act — Évaluation du niveau de risque
|
||||
|
||||
Léa invoque l'exception de l'art. 6(3) du AI Act :
|
||||
- **(a)** Tâche procédurale étroite : extraction de champs depuis des écrans
|
||||
- **(d)** Tâche préparatoire : alimentation d'un EDS pour analyse humaine ultérieure
|
||||
- Aucune décision clinique automatisée
|
||||
- Supervision humaine permanente (mode supervisé)
|
||||
|
||||
Cette évaluation est documentée conformément à l'art. 6(4).
|
||||
|
||||
---
|
||||
|
||||
## II. Architecture et déploiement
|
||||
|
||||
### 2.1 Principe : déploiement 100% local
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ RÉSEAU CLINIQUE ANOUST │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Poste │ │ SERVEUR LÉA (VM/bare) │ │
|
||||
│ │ DPI │◄───────►│ │ │
|
||||
│ │ (OSIRIS) │ écran │ - Moteur RPA (extraction) │ │
|
||||
│ └──────────┘ │ - Pipeline anonymisation │ │
|
||||
│ │ - EDS (PostgreSQL + OMOP) │ │
|
||||
│ │ - Dashboard (monitoring) │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⛔ AUCUNE CONNEXION INTERNET SORTANTE │
|
||||
│ ⛔ AUCUNE DONNÉE NE QUITTE L'ÉTABLISSEMENT │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Prérequis infrastructure — à fournir par la clinique
|
||||
|
||||
| Élément | Spécification | Notes |
|
||||
|---------|--------------|-------|
|
||||
| **VM ou serveur dédié** | 8 vCPU, 32 Go RAM, 500 Go SSD minimum | GPU optionnel (accélère l'OCR mais pas requis) |
|
||||
| **OS** | Ubuntu Server 22.04 LTS ou 24.04 LTS | L'établissement gère les MAJ de sécurité OS |
|
||||
| **Adresse IP fixe** | 1 IP sur le VLAN santé | Pour le serveur Léa |
|
||||
| **VLAN** | VLAN dédié ou VLAN santé existant | Isolation réseau des données patients |
|
||||
| **Accès réseau au DPI** | Connectivité vers le poste/serveur OSIRIS | Léa navigue dans l'interface comme un utilisateur |
|
||||
| **Accès SSH** | Port 22, limité à 1 IP source (notre maintenance) | Ou VPN site-to-site |
|
||||
| **DNS interne** | Résolution des noms internes | Si OSIRIS est accessible par nom |
|
||||
| **Compte utilisateur DPI** | Compte OSIRIS dédié "Léa" (lecture seule) | Avec les droits d'accès aux dossiers du périmètre |
|
||||
| **Sauvegarde** | Intégration au plan de sauvegarde de l'établissement | VM snapshots ou backup du volume données |
|
||||
| **Certificat TLS** | Certificat interne pour HTTPS (ou auto-signé) | Pour le dashboard de monitoring |
|
||||
|
||||
### 2.3 Ports et services
|
||||
|
||||
| Port | Protocole | Direction | Usage | Sécurité |
|
||||
|------|-----------|-----------|-------|----------|
|
||||
| **443** | HTTPS/TLS | Interne uniquement | Dashboard monitoring + consultation logs | Authentification forte (AD/LDAP clinique) |
|
||||
| **22** | SSH | Entrant, 1 IP source | Maintenance par nos équipes uniquement | Clé SSH, pas de mot de passe. IP source unique |
|
||||
| **5432** | TCP | Localhost uniquement | PostgreSQL (EDS) | Écoute 127.0.0.1 uniquement, pas d'accès réseau |
|
||||
|
||||
**Ports bloqués** : tout le reste. Aucun port ouvert vers Internet.
|
||||
|
||||
### 2.4 Règles de firewall (ufw)
|
||||
|
||||
```bash
|
||||
# Politique par défaut : tout bloquer
|
||||
ufw default deny incoming
|
||||
ufw default deny outgoing
|
||||
|
||||
# SSH maintenance (IP source unique fournie par la clinique)
|
||||
ufw allow from <IP_MAINTENANCE> to any port 22 proto tcp
|
||||
|
||||
# HTTPS dashboard (VLAN interne uniquement)
|
||||
ufw allow from <VLAN_SANTE> to any port 443 proto tcp
|
||||
|
||||
# DNS interne (si nécessaire)
|
||||
ufw allow out to <DNS_INTERNE> port 53
|
||||
|
||||
# Bloquer explicitement Internet
|
||||
ufw deny out to 0.0.0.0/0
|
||||
```
|
||||
|
||||
### 2.5 Hardening serveur
|
||||
|
||||
- Service RPA sous **compte non-root** à privilèges limités (`lea-svc`)
|
||||
- **Chiffrement intégral** du disque (LUKS) — protection PI et données
|
||||
- AppArmor activé avec profil dédié
|
||||
- Fail2ban sur SSH
|
||||
- Logs système centralisés (journald)
|
||||
- Pas de serveur X / interface graphique sur le serveur
|
||||
|
||||
---
|
||||
|
||||
## III. Sécurité des données et anonymisation
|
||||
|
||||
### 3.1 Cycle de vie des données
|
||||
|
||||
```
|
||||
Écran DPI (OSIRIS)
|
||||
│
|
||||
▼ capture en RAM uniquement
|
||||
Extraction RPA
|
||||
│
|
||||
▼ destruction immédiate des captures écran
|
||||
Données structurées brutes (RAM)
|
||||
│
|
||||
▼ pipeline anonymisation
|
||||
Données pseudonymisées
|
||||
│
|
||||
▼ chargement
|
||||
EDS (PostgreSQL, disque chiffré LUKS)
|
||||
│
|
||||
▼ requêtage
|
||||
Dashboard (lecture seule, HTTPS)
|
||||
```
|
||||
|
||||
### 3.2 Traitement des captures d'écran
|
||||
|
||||
- Les images sont traitées **en RAM uniquement**
|
||||
- **Destruction immédiate** après analyse (pas de stockage sur disque)
|
||||
- Aucune capture d'écran dans les logs
|
||||
- Les traces d'audit ne contiennent que des **métadonnées** (horodatage, action, résultat), jamais de contenu patient
|
||||
|
||||
### 3.3 Pipeline d'anonymisation
|
||||
|
||||
| Étape | Technologie | Fonction |
|
||||
|-------|-------------|----------|
|
||||
| NER (détection entités) | **EDS-NLP** (AP-HP, open source) | Détection noms, dates, lieux, numéros dans le texte clinique français |
|
||||
| Dé-identification | Remplacement systématique | Noms → pseudonymes, dates → décalage cohérent, lieux → généralisés |
|
||||
| Validation | Échantillonnage + relecture clinicien | Contrôle qualité sur un échantillon de dossiers |
|
||||
| Exécution | **ONNX Runtime (CPU)** | Pas de GPU requis, pas de dépendance cloud |
|
||||
|
||||
**Exclusions automatiques** :
|
||||
- Notes personnelles du psychiatre (détection par métadonnées OSIRIS)
|
||||
- Données d'identification directe (NIR, IPP) jamais stockées dans l'EDS
|
||||
|
||||
### 3.4 Entrepôt de Données de Santé (EDS)
|
||||
|
||||
| Aspect | Détail |
|
||||
|--------|--------|
|
||||
| **SGBD** | PostgreSQL 15+ |
|
||||
| **Modèle de données** | OMOP CDM v5.4 (standard Health Data Hub) |
|
||||
| **Chiffrement au repos** | LUKS (disque) + TDE PostgreSQL optionnel |
|
||||
| **Chiffrement en transit** | TLS 1.3 pour toute connexion |
|
||||
| **Accès** | Localhost uniquement (pas d'accès réseau direct) |
|
||||
| **Sauvegarde** | Intégrée au plan de backup de l'établissement |
|
||||
| **Rétention** | Selon politique de l'établissement (max 20 ans, R.1112-7 CSP) |
|
||||
|
||||
---
|
||||
|
||||
## IV. Traçabilité et audit
|
||||
|
||||
### 4.1 Journal d'audit (RPA traçable)
|
||||
|
||||
Toutes les actions du robot sont enregistrées :
|
||||
|
||||
| Donnée enregistrée | Exemple |
|
||||
|-------------------|---------|
|
||||
| Horodatage | `2026-04-14T09:32:15+02:00` |
|
||||
| Action | `extraction_dossier`, `navigation_ecran`, `saisie_champ` |
|
||||
| Résultat | `succès`, `échec`, `pause_supervisée` |
|
||||
| Utilisateur superviseur | `dr.martin` (via AD) |
|
||||
| Session ID | `sess_20260414_a3f2b1` |
|
||||
| Durée | `2.3s` |
|
||||
|
||||
**Ce qui n'est JAMAIS enregistré** : contenu patient, captures d'écran, données de santé.
|
||||
|
||||
### 4.2 Consultation des logs
|
||||
|
||||
- Accès via **dashboard web sécurisé** (HTTPS/443)
|
||||
- Authentification via l'annuaire de l'établissement (AD/LDAP)
|
||||
- **Lecture seule** — aucun accès direct aux fichiers du serveur
|
||||
- Rétention des logs : 1 an (configurable)
|
||||
|
||||
### 4.3 Supervision humaine (AI Act)
|
||||
|
||||
- Léa fonctionne en **mode supervisé** : un opérateur peut interrompre, corriger ou stopper le système à tout moment
|
||||
- En cas d'échec d'une action, Léa se met en **pause** et attend l'intervention humaine (pas de retry aveugle)
|
||||
- Toutes les interventions humaines sont tracées
|
||||
|
||||
---
|
||||
|
||||
## V. Sécurité du code et des modèles IA
|
||||
|
||||
### 5.1 Gouvernance du modèle IA
|
||||
|
||||
- **Entraînement en vase clos** : le modèle apprend exclusivement sur les données d'activité interne, sans export
|
||||
- **Aucune donnée ni modèle transmis à l'extérieur**
|
||||
- **Intégrité du modèle** : chaque version est signée numériquement — l'application vérifie la signature au démarrage
|
||||
- **Modèles VLM** : exécutés localement via Ollama (pas d'API cloud)
|
||||
|
||||
### 5.2 Supply chain logicielle
|
||||
|
||||
- **SBOM** (Software Bill of Materials) : liste complète des composants open source fournie
|
||||
- Engagement à patcher les vulnérabilités connues (CVE) sous 30 jours (critique) / 90 jours (autres)
|
||||
- Dépendances auditées : PyTorch, ONNX Runtime, PostgreSQL, Flask, FastAPI
|
||||
|
||||
### 5.3 Protection de la propriété intellectuelle
|
||||
|
||||
Le serveur est traité comme une **"Boîte Noire"** :
|
||||
- L'établissement n'a pas accès au code source ni aux modèles
|
||||
- Consultation des logs uniquement via l'interface web (lecture seule)
|
||||
- Pas d'accès direct au système de fichiers
|
||||
|
||||
---
|
||||
|
||||
## VI. Maintenance et gestion des incidents
|
||||
|
||||
### 6.1 Maintenance
|
||||
|
||||
| Aspect | Détail |
|
||||
|--------|--------|
|
||||
| **Accès** | SSH, limité à 1 IP source, clé SSH uniquement |
|
||||
| **Intervenant** | Exclusivement nos équipes certifiées |
|
||||
| **Fenêtre** | En dehors des heures d'extraction (nuit/week-end) |
|
||||
| **Mises à jour** | Planifiées, testées en pré-production, validées par le DSI |
|
||||
| **SLA** | Intervention sous 4h (critique) / 24h (normal) |
|
||||
|
||||
### 6.2 Gestion des incidents de sécurité
|
||||
|
||||
Conformément à **NIS2** (transposition FR juillet 2026) :
|
||||
|
||||
| Délai | Action |
|
||||
|-------|--------|
|
||||
| **< 1h** | Détection et containment (isolement du serveur si nécessaire) |
|
||||
| **< 24h** | Notification à l'établissement + ANSSI si incident significatif |
|
||||
| **< 72h** | Notification CNIL si violation de données personnelles |
|
||||
| **< 30j** | Rapport d'incident complet |
|
||||
|
||||
### 6.3 Plan de continuité
|
||||
|
||||
- En cas de panne Léa : **aucun impact sur le DPI** — le robot n'écrit rien dans OSIRIS
|
||||
- L'extraction peut être reprise là où elle s'est arrêtée (sessions persistantes)
|
||||
- Les données déjà chargées dans l'EDS restent accessibles
|
||||
|
||||
---
|
||||
|
||||
## VII. Checklist de conformité
|
||||
|
||||
### À réaliser avant le démarrage du POC
|
||||
|
||||
| # | Action | Responsable | Statut |
|
||||
|---|--------|------------|--------|
|
||||
| 1 | Désigner le DPO (si pas déjà fait) | Clinique | ☐ |
|
||||
| 2 | Réaliser l'AIPD (Analyse d'Impact) | Conjoint (nous + DPO) | ☐ |
|
||||
| 3 | Valider la base légale (mission intérêt public ou intérêt légitime) | DPO + direction | ☐ |
|
||||
| 4 | Déclaration CNIL (conformité référentiel EDS ou autorisation) | DPO | ☐ |
|
||||
| 5 | Créer le compte OSIRIS dédié "Léa" (lecture seule) | DSI | ☐ |
|
||||
| 6 | Provisionner la VM (specs §II.2) | DSI | ☐ |
|
||||
| 7 | Configurer le réseau (VLAN, IP, firewall §II.4) | DSI | ☐ |
|
||||
| 8 | Configurer l'accès SSH maintenance (1 IP source) | DSI | ☐ |
|
||||
| 9 | Intégrer la VM au plan de sauvegarde | DSI | ☐ |
|
||||
| 10 | Signer l'accord de confidentialité | Les deux parties | ☐ |
|
||||
| 11 | Valider le périmètre des dossiers avec le médecin DIM | Clinique | ☐ |
|
||||
| 12 | Documenter l'évaluation AI Act art. 6(3) | Nous | ☐ |
|
||||
130
docs/POC_ANOUST_QUESTIONS_DSI.md
Normal file
130
docs/POC_ANOUST_QUESTIONS_DSI.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Questions pour le DSI — Clinique Anoust
|
||||
## Rendez-vous du 14 avril 2026
|
||||
|
||||
---
|
||||
|
||||
## A. DPI et système d'information
|
||||
|
||||
| # | Question | Pourquoi c'est important | Notes |
|
||||
|---|----------|-------------------------|-------|
|
||||
| 1 | **Quel logiciel DPI utilisez-vous ?** (Cariatides, Cortexte, Osiris, Hopital Manager, Mediboard, autre ?) | Détermine toute l'approche d'extraction et les connecteurs possibles | |
|
||||
| 2 | **Quelle version ?** Depuis quand déployé ? | Les anciennes versions ont moins d'API | |
|
||||
| 3 | **Quelle base de données sous-jacente ?** (Oracle, SQL Server, PostgreSQL ?) | Si accès direct possible, c'est un plan B en parallèle de Léa | |
|
||||
| 4 | **L'éditeur propose-t-il des API ou exports programmés ?** | Alternative ou complément au RPA | |
|
||||
| 5 | **Y a-t-il un contrat de support interdisant l'accès direct à la BDD ?** | Risque contractuel à évaluer | |
|
||||
| 6 | **Quels modules DPI sont déployés ?** (prescription, observations, agenda, urgences, RIM-P ?) | Périmètre de données disponibles | |
|
||||
| 7 | **Le RIM-P est-il géré par le DPI ou un outil tiers ?** | Le RIM-P est notre point d'entrée le plus rapide | |
|
||||
| 8 | **Des mises à jour DPI sont-elles prévues ?** | Risque de casser les parcours Léa pendant le POC | |
|
||||
|
||||
---
|
||||
|
||||
## B. Infrastructure technique
|
||||
|
||||
| # | Question | Pourquoi c'est important | Notes |
|
||||
|---|----------|-------------------------|-------|
|
||||
| 9 | **Infrastructure serveurs ?** (on-premise, cloud privé, hybride, infogéré ?) | Si on-premise → dispense HDS. Si tiers → certification HDS obligatoire | |
|
||||
| 10 | **Quel hyperviseur ?** (VMware, Proxmox, Hyper-V ?) | Pour provisionner la VM du POC | |
|
||||
| 11 | **Pouvez-vous provisionner une VM dédiée POC ?** (idéal : 8 vCPU, 32 Go RAM, 500 Go SSD) | Hébergement EDS + Léa serveur | |
|
||||
| 12 | **Quel OS serveur ?** (Ubuntu, RHEL, Windows Server ?) | Compatibilité stack technique | |
|
||||
| 13 | **Architecture réseau ?** VLAN santé isolé ? DMZ ? | Isolation nécessaire pour l'EDS | |
|
||||
| 14 | **Accès VPN ou bastion pour maintenance à distance ?** | Interventions sans déplacement | |
|
||||
| 15 | **Politique de backup ?** (fréquence, rétention, stockage) | Protection des données EDS | |
|
||||
| 16 | **Bande passante réseau interne ?** (Gigabit ? 10G ?) | Performance extraction | |
|
||||
|
||||
---
|
||||
|
||||
## C. Volumétrie et données
|
||||
|
||||
| # | Question | Pourquoi c'est important | Notes |
|
||||
|---|----------|-------------------------|-------|
|
||||
| 17 | **Combien de patients en file active annuelle ?** | Dimensionnement EDS | |
|
||||
| 18 | **Volume total de dossiers 2024-2025 à traiter ?** | Estimation charge de travail Léa | |
|
||||
| 19 | **Types de documents dans les dossiers ?** (CR hospitalisation, observations infirmières, courriers, prescriptions, résultats labo ?) | Périmètre d'extraction | |
|
||||
| 20 | **Volume estimé de texte libre par patient ?** (observations, entretiens) | La psy a beaucoup de texte → impact anonymisation | |
|
||||
| 21 | **Les données RIM-P / PMSI sont-elles exportables ?** Format ? Qui gère ? | Point d'entrée le plus rapide pour le POC | |
|
||||
| 22 | **Y a-t-il des données structurées codées ?** (CIM-10, EDGAR, CSARR ?) | Qualité de base pour l'EDS | |
|
||||
|
||||
---
|
||||
|
||||
## D. Sécurité et conformité
|
||||
|
||||
| # | Question | Pourquoi c'est important | Notes |
|
||||
|---|----------|-------------------------|-------|
|
||||
| 23 | **Avez-vous un DPO ?** Qui ? Interne ou externe ? | Obligatoire. Interlocuteur clé pour l'AIPD | |
|
||||
| 24 | **Existe-t-il une PSSI ?** (Politique de Sécurité des SI) | Cadre à respecter | |
|
||||
| 25 | **Avez-vous un RSSI ?** | Interlocuteur sécurité | |
|
||||
| 26 | **Quel niveau de certification ?** (HOP'EN, ISO 27001 ?) | Maturité sécurité | |
|
||||
| 27 | **Chiffrement en place ?** (au repos, en transit) | Prérequis EDS | |
|
||||
| 28 | **Gestion des accès ?** (AD, LDAP, SSO ?) | Intégration authentification | |
|
||||
| 29 | **Programme CaRE : où en êtes-vous ?** (BIA fait ? Plans de continuité ?) | Échéance juin 2026 | |
|
||||
| 30 | **Conformité Ségur du Numérique : quels référentiels implémentés ?** | Maturité interopérabilité | |
|
||||
|
||||
---
|
||||
|
||||
## E. Gouvernance et organisation
|
||||
|
||||
| # | Question | Pourquoi c'est important | Notes |
|
||||
|---|----------|-------------------------|-------|
|
||||
| 31 | **Y a-t-il un médecin DIM motivé et disponible ?** | **Facteur n°1 de succès** — sans DIM, le POC échoue | |
|
||||
| 32 | **La CME est-elle informée / favorable ?** | Gouvernance médicale | |
|
||||
| 33 | **Y a-t-il déjà des projets de recherche sur les données patients ?** | Si oui, formalités CNIL déjà en place | |
|
||||
| 34 | **Base légale envisagée pour l'EDS ?** (mission d'intérêt public, intérêt légitime ?) | Détermine la procédure CNIL | |
|
||||
| 35 | **La clinique participe-t-elle au service public hospitalier ?** (convention ARS ?) | Si oui → mission d'intérêt public → référentiel CNIL EDS applicable | |
|
||||
| 36 | **Quel est le positionnement de la direction sur l'innovation ?** | Soutien stratégique | |
|
||||
|
||||
---
|
||||
|
||||
## F. Budget, timeline et ambition
|
||||
|
||||
| # | Question | Pourquoi c'est important | Notes |
|
||||
|---|----------|-------------------------|-------|
|
||||
| 37 | **Budget disponible pour le POC ?** (matériel, prestation, jours/homme) | Cadrage financier | |
|
||||
| 38 | **Timeline souhaitée ?** (notre estimation : 3 mois) | Aligner les attentes | |
|
||||
| 39 | **Ressources mobilisables ?** (DIM dédié, admin sys, médecin référent) | Charge côté clinique | |
|
||||
| 40 | **Financements identifiés ?** (AAP ARS, DGOS, ANR, programme CaRE ?) | Possibilité de co-financement | |
|
||||
| 41 | **Ambition post-POC ?** (production, extension, publication, multi-sites ?) | Dimensionner la suite | |
|
||||
| 42 | **D'autres établissements du groupe sont-ils intéressés ?** | Potentiel de déploiement | |
|
||||
|
||||
---
|
||||
|
||||
## G. Points d'attention à aborder (nous)
|
||||
|
||||
### Ce que nous devons expliquer au DSI
|
||||
|
||||
1. **Léa est 100% locale** — aucune donnée ne quitte le réseau. Pas de cloud, pas d'API externe. Argument massue en psychiatrie.
|
||||
|
||||
2. **Supervision humaine permanente** — Léa n'est pas autonome, elle apprend sous supervision d'un humain. Conforme AI Act.
|
||||
|
||||
3. **Anonymisation intégrée** — le pipeline dé-identifie AVANT tout stockage dans l'EDS. On ne stocke jamais de données nominatives dans l'EDS de recherche.
|
||||
|
||||
4. **Notes personnelles du psychiatre** — nous sommes conscients de cette particularité légale et l'avons intégrée dans la conception (exclusion automatique).
|
||||
|
||||
5. **OMOP CDM** — standard international du Health Data Hub. L'EDS sera nativement interopérable si la clinique souhaite participer à des projets de recherche nationaux.
|
||||
|
||||
6. **Open source** — pas de coût de licence. Le coût est 100% en temps humain et infrastructure.
|
||||
|
||||
### Points à valider absolument avant de partir
|
||||
|
||||
- [ ] Le DPI utilisé (nom + version)
|
||||
- [ ] La base légale pour l'EDS (mission d'intérêt public ou pas)
|
||||
- [ ] La disponibilité d'un médecin DIM
|
||||
- [ ] La capacité à provisionner une VM
|
||||
- [ ] L'accord de principe pour un accès au DPI (même en lecture écran)
|
||||
- [ ] Le calendrier de la prochaine étape
|
||||
|
||||
---
|
||||
|
||||
## H. Checklist de conformité EDS (référentiel CNIL 2021-118)
|
||||
|
||||
Pour référence — à parcourir avec le DPO :
|
||||
|
||||
- [ ] Finalité déterminée de l'EDS
|
||||
- [ ] Base légale identifiée (art. 6 + art. 9 RGPD)
|
||||
- [ ] AIPD réalisée
|
||||
- [ ] Information des patients
|
||||
- [ ] Exercice des droits (accès, rectification, opposition)
|
||||
- [ ] Mesures de sécurité (chiffrement, accès, audit trail)
|
||||
- [ ] Pseudonymisation des données
|
||||
- [ ] Gouvernance (comité, responsable, charte d'accès)
|
||||
- [ ] Durée de conservation définie
|
||||
- [ ] Procédure pour les réutilisations (recherche)
|
||||
@@ -32,6 +32,7 @@ Les fonctionnalités ci-dessous sont documentées sans minimiser les limites.
|
||||
| Embedding & FAISS (`core/embedding/`) | alpha | CLIP ViT-B/32 + index Flat, pas testé à grande échelle |
|
||||
| Workflow Graph (`core/graph/`) | alpha | Construction depuis sessions, matching heuristique |
|
||||
| Replay E2E (`agent_v0/server_v1/api_stream.py`) | alpha | Premier succès le 13 avril 2026 sur Notepad, asymétries strict/legacy connues |
|
||||
| ExecutionLoop vision-aware (C1) | alpha | ScreenState enrichi + cache perceptuel + flags `enable_ui_detection`/`enable_ocr`/`analyze_timeout_ms`/`window_info_provider` — voir [EXECUTION_LOOP_FLAGS.md](EXECUTION_LOOP_FLAGS.md) |
|
||||
| Mode apprentissage supervisé | alpha | Pause sur échec répété, demande d'intervention humaine |
|
||||
| TargetMemoryStore (Phase 1 apprentissage) | alpha | Schéma SQLite en place, DB vide jusqu'au premier replay complet |
|
||||
| Grounding visuel (UI-TARS, gemma4, qwen3-vl) | alpha | Switch de modèle via `.env` (`RPA_VLM_MODEL`) |
|
||||
@@ -43,7 +44,7 @@ Les fonctionnalités ci-dessous sont documentées sans minimiser les limites.
|
||||
| Federation (`core/federation/`) | alpha | Export/import de LearningPacks, pas de test terrain |
|
||||
| GPU Resource Manager (`core/gpu/`) | alpha | Gestion Ollama + warmup modèles, code utilisé mais peu testé |
|
||||
| Self-healing / recovery | en cours | Heuristiques présentes, comportement global non stabilisé |
|
||||
| Analytics / reporting | en cours | Prototype, pas de frontend finalisé |
|
||||
| Analytics / reporting | en cours | Prototype, pas de frontend finalisé. SQLite `step_metrics` étendue avec timings vision-aware C1 (`ocr_ms`, `ui_ms`, `analyze_ms`, `cache_hit`, `degraded`). |
|
||||
| Tests end-to-end | en cours | 1 replay E2E réussi, 56 tests d'intégration verts hors cas connus |
|
||||
| Deploy Windows (`deploy/build_package.sh`) | opérationnel | Produit `Lea_v<version>.zip`, vérification des fichiers requis |
|
||||
| Conformité AI Act (journalisation, floutage, rétention logs) | alpha | Mécanismes en place, audit formel non fait |
|
||||
|
||||
336
docs/SYNTHESE_11AVRIL_MATIN.md
Normal file
336
docs/SYNTHESE_11AVRIL_MATIN.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Synthèse — 11 avril 2026 (préparée pendant ton absence)
|
||||
|
||||
Ce document résume ce qui s'est passé pendant ton absence. Lecture : ~10 minutes.
|
||||
|
||||
## 🎯 À lire en premier (60 secondes)
|
||||
|
||||
**Score global guardian : 6.8/10** — direction bonne, 5 commits du jour propres sans régression, mais **3 points critiques** à adresser.
|
||||
|
||||
### Les 3 actions à faire en rentrant (ordre de priorité)
|
||||
|
||||
1. **🔴 P0 — Committer `replay_failure_logger.py`** (fichier utilisé partout mais pas tracké git, un fresh clone ne démarre plus). 15 minutes. Bloquant absolu. Guardian item C1.
|
||||
2. **🔴 P0 — Corriger l'asymétrie `Fenêtre incorrecte` strict → pause apprentissage** (même pattern que `no_screen_change strict` du commit `7cc03f6f1`, mais sur une autre branche). 1-2h. C'est exactement le bug qui a cassé ton test chirurgical ce matin (tu vas voir la timeline dans Partie 1). Guardian item C2.
|
||||
3. **🔴 P1 — Premier replay Notepad E2E réussi pour activer Phase 1 apprentissage**. La DB `data/learning/target_memory.db` est **vide** (0 entrée). La greffe est câblée mais Léa n'a pas encore appris une seule fois en conditions réelles. Tu pourras l'activer dès que #2 est fait. Guardian item E4.
|
||||
|
||||
### Les 3 choses à savoir absolument
|
||||
|
||||
- **Test chirurgical** : échec propre au premier clic parce que Bloc-notes n'était plus au premier plan sur la VM (focus perdu). La chaîne stricte a fait exactement son job de protection (3 retries puis stop), mais elle est retombée dans la branche retry+stop legacy au lieu de la pause d'apprentissage. **C'est la preuve vivante du bug #2 ci-dessus.**
|
||||
- **VWB est un piège** pour le nettoyage de workflows — bug DB runtime + bridge Léa→VWB qui perd 90% de l'info. **Recommandation agent VWB** : écrire un petit outil dédié (200 lignes, 1 jour) plutôt que réparer VWB (4-5 jours). Détails en Partie 4.
|
||||
- **Code agent en 3 copies divergentes** : source à jour, deploy copy très en retard (sans UIA), worktree migration sans `replay_failure_logger.py`. Merger le worktree EN L'ÉTAT casserait le démarrage. C4 du guardian.
|
||||
|
||||
### Ce qui va vraiment bien 🟢
|
||||
|
||||
- Les 5 commits de la journée (`b92cb9db0`, `f82753deb`, `9188bd7df`, `a21f1ea9f`, `7cc03f6f1`) sont tous propres, bien documentés, testés et sans régression
|
||||
- L'instrumentation `[REPLAY]` permet un debug multi-étages lisible
|
||||
- La Phase 1 apprentissage est une greffe minimale et non-intrusive (exactement comme prévu dans le plan)
|
||||
- Le fix de C (`7cc03f6f1`) montre la rigueur post-correction : re-lecture des feedbacks mémoire, pas de rustine
|
||||
- Tests E2E + unit (hors VWB) toujours verts : 56/56
|
||||
|
||||
Le détail complet ci-dessous, partie par partie.
|
||||
|
||||
---
|
||||
|
||||
## Partie 1 — Résultat du test chirurgical
|
||||
|
||||
### Protocole
|
||||
- Replay `replay_free_3935cd0b` lancé sur la session `sess_20260411T084629_2d588e`
|
||||
- 3 actions injectées : click `Fichier` → wait 800ms → click `Enregistrer`
|
||||
- Chaque click en mode `success_strict=True`, `expected_before='test.txt – Bloc-notes'`
|
||||
- Les gardes B (score + drift) et C (pause apprentissage) sont en place
|
||||
|
||||
### Timeline
|
||||
```
|
||||
09:31:09 DISPATCH test_chir_1_fichier (click Fichier)
|
||||
09:31:11 REPORT success=False error="Fenêtre incorrecte: 'Program Manager' (attendu: 'test.txt – Bloc-notes')"
|
||||
09:31:11 VERIFY final_success=False
|
||||
09:31:11 DISPATCH retry1
|
||||
09:31:12 REPORT success=False error="Fenêtre incorrecte: 'Program Manager' ..." (identique)
|
||||
09:31:13 DISPATCH wait_retry (2s)
|
||||
09:31:16 DISPATCH retry2
|
||||
09:31:17 REPORT success=False error="Fenêtre incorrecte: 'Program Manager' ..." (identique)
|
||||
09:31:18 DISPATCH retry3
|
||||
09:31:19 REPORT success=False error="Fenêtre incorrecte: 'Program Manager' ..." (identique)
|
||||
09:31:19 Replay échoué à test_chir_1_fichier_retry3 après 3 retries: status=error
|
||||
```
|
||||
|
||||
### Analyse critique
|
||||
|
||||
**Ce qui a BIEN fonctionné** (à conserver) :
|
||||
- `_validate_match_context` côté agent Windows : a détecté le mismatch de fenêtre active (`Program Manager` vs `test.txt – Bloc-notes`)
|
||||
- **Pas de clic dans le vide** : la pré-vérif stricte refuse de cliquer quand la fenêtre active n'est pas celle attendue, exactement comme prévu
|
||||
- Instrumentation `[REPLAY]` parfaitement lisible : chaque étape, chaque erreur, chaque retry
|
||||
- Retry automatique × 3 avec wait interleaved
|
||||
|
||||
**Ce qui a MAL fonctionné** (à corriger) :
|
||||
|
||||
1. **`Bloc-notes` n'était plus au premier plan** quand le test a démarré. La fenêtre active sur la VM était `Program Manager` (= le bureau Windows). Hypothèses :
|
||||
- Tu as fermé Bloc-notes avant de partir
|
||||
- Un événement Windows a volé le focus (notification, chat Léa, etc.)
|
||||
- Bloc-notes était minimisé
|
||||
|
||||
2. **L'erreur `Fenêtre incorrecte` en mode strict retombe dans la branche retry+stop** (réflexe RPA classique), **pas** dans la pause apprentissage. C'est une **incohérence** avec le correctif C que j'ai fait pour `no_screen_change` : les deux devraient avoir le même traitement.
|
||||
|
||||
### Ce qu'il aurait fallu faire
|
||||
|
||||
Dans `api_stream.py`, la branche qui traite l'erreur `Fenêtre incorrecte` devrait :
|
||||
- En mode **strict** : `status = "paused_need_help"` avec `pause_message = "Je m'attendais à voir 'test.txt – Bloc-notes' mais je vois 'Program Manager'. Peux-tu me montrer la bonne fenêtre ?"` → queue intacte, attente d'intervention humaine
|
||||
- En mode **legacy** (non strict) : retry × 3 puis continue (comportement actuel)
|
||||
|
||||
C'est **symétrique** au correctif C (`no_screen_change strict → pause apprentissage`). À faire dans un prochain commit.
|
||||
|
||||
### Ce que ça valide quand même
|
||||
|
||||
Malgré l'échec sur un problème environnemental (focus perdu), **la chaîne stricte complète fonctionne** :
|
||||
- Pré-vérif stricte ✅
|
||||
- Retry automatique ✅
|
||||
- Arrêt propre après retries ✅
|
||||
- Instrumentation lisible ✅
|
||||
- Pas de clic "aveugle" dans le désordre ✅
|
||||
|
||||
On a juste besoin d'aligner le traitement d'erreur sur la philosophie d'apprentissage.
|
||||
|
||||
---
|
||||
|
||||
## Partie 2 — Dettes techniques connues (pré-audit)
|
||||
|
||||
En attendant le rapport du project-quality-guardian, voici ce que je sais déjà :
|
||||
|
||||
### Dettes hautes (connues et documentées)
|
||||
|
||||
1. **`agent_v0/deploy/windows_client/agent_v1/core/executor.py`** : 1302 lignes de divergence non committée avec le dev copy. Risque à chaque nouveau packaging Windows.
|
||||
2. **Module `replay_failure_logger.py`** : importé dans `api_stream.py` mais PAS tracké dans git. Bug pré-existant signalé par le subagent VWB tout à l'heure. À vérifier : soit le fichier existe sur disque (ignoré par gitignore), soit l'import est cassé silencieusement.
|
||||
3. **Migration `agent_v0/` → top-level** : non mergée, dans un worktree (`.claude/worktrees/agent-a0ebc90f/`). Trois commits prêts mais en attente de ton review.
|
||||
4. **`visual_workflow_builder/backend/instance/workflows.db`** : modifié non committé depuis le début de la session. Probablement des données de test.
|
||||
5. **`live_session_manager.py`** : modifié non committé. Dans l'état initial de la session, devrait être committé séparément ou ignoré.
|
||||
|
||||
### Dettes moyennes
|
||||
|
||||
6. **`_a_trier/`** : dossier de code/scripts à trier, jamais nettoyé. Grande taille, pollue les grep.
|
||||
7. **`archives/`** : ancien code archivé dans le dépôt. Grossit le repo.
|
||||
8. **Phase 1 apprentissage activable mais non testée en conditions réelles** : `TargetMemoryStore` est branché mais aucune session n'a encore déclenché un `memory_record_success` ni un `memory_lookup HIT`. Attend le premier replay complet qui réussit.
|
||||
9. **Agent Windows dev vs deploy vs build/Lea/** : trois copies parallèles du code agent, avec divergences possibles à chaque modification.
|
||||
|
||||
### Dettes basses
|
||||
|
||||
10. **Clics parasites d'arrêt d'enregistrement** : systématiquement capturés dans les sessions (clic sur systray, icône Léa, bouton Arrêter). À filtrer côté captor (ex: ignorer les N dernières secondes, ou tout clic sur fenêtre Léa).
|
||||
11. **Phrases types non externalisées** : `pause_message`, `error_description`, etc. sont hardcodées dans le code. Doivent passer en JSON/YAML i18n-ready.
|
||||
12. **Service `worker` (port 5099)** : toujours inactif. Le worker VLM qui compile les sessions en workflows n'est pas lancé. Résultat : les sessions enregistrées ne sont jamais compilées automatiquement en ExecutionPlan.
|
||||
|
||||
---
|
||||
|
||||
## Partie 3 — Ce que j'ai fait pendant ton absence (commits)
|
||||
|
||||
Aucun commit pendant ton absence. Juste :
|
||||
- Lancement du test chirurgical (échec propre comme analysé)
|
||||
- Lancement de 2 agents d'audit en background
|
||||
- Création de ce document de synthèse
|
||||
|
||||
---
|
||||
|
||||
## Partie 4 — Résultat des agents d'audit
|
||||
|
||||
### Audit projet global — **TERMINÉ** ✅
|
||||
|
||||
**Score global** : **6.8/10** — direction technique bonne, 5 commits du jour propres et sans régression, mais **incohérence philosophique non corrigée** + dette de cohérence multi-copies.
|
||||
|
||||
#### 🔴 Les 3 choses à savoir en rentrant (synthèse exécutive du guardian)
|
||||
|
||||
1. **Phase 1 apprentissage est techniquement branchée MAIS `data/learning/target_memory.db` est VIDE (0 entrée).** Aucun replay n'a encore survécu au post-cond strict pour cristalliser. Ça veut dire que **tu n'as pas encore vu Léa apprendre**, tu as juste câblé l'apprentissage. Le premier replay qui passe en entier déclenchera la boucle.
|
||||
|
||||
2. **Asymétrie strict pré-vérif vs post-vérif** (gros point) : le fix `7cc03f6f1` corrige `no_screen_change strict → paused_need_help`, mais **la branche `Fenêtre incorrecte` en pré-vérif strict retombe toujours en retry+stop legacy**. C'est une **violation directe de `feedback_failure_is_learning.md` sur un chemin différent**. Même pattern, même oubli. C'est exactement ce qui a cassé ton test chirurgical ce matin.
|
||||
|
||||
3. **Trois copies divergentes du code agent** :
|
||||
- `agent_v0/agent_v1/` (source à jour avec UIA, grounding, policy, recovery)
|
||||
- `agent_v0/deploy/windows_client/agent_v1/` (1303 lignes non committées, **sans `uia_helper.py`**)
|
||||
- `.claude/worktrees/agent-a0ebc90f/` (migration top-level, **sans `replay_failure_logger.py`**)
|
||||
|
||||
Tout packaging Windows depuis la deploy copy manque UIA + tous les fix du 10-11 avril. Si tu merges le worktree EN L'ÉTAT, ça casse le démarrage serveur.
|
||||
|
||||
#### Cohérence vision / implémentation
|
||||
|
||||
5 principes directeurs de la mémoire :
|
||||
| # | Principe | Statut | Remarque |
|
||||
|---|---|---|---|
|
||||
| A1 | 100% visuel (pas de raccourcis inventés) | ✅ Respecté | Grep OK côté replay V4 |
|
||||
| A2 | LLM 100% local (Ollama) | ⚠️ **Violé dans VWB** | `vlm_provider.py` priorise OpenAI/Gemini/Anthropic avant Ollama, 3 clés cloud dans `.env.local` |
|
||||
| A3 | Léa n'est pas une boîte à clic | ⚠️ Partiel | Infra en place mais DB vide + asymétrie pré-vérif |
|
||||
| A4 | Échec = apprentissage, pas arrêt | ⚠️ Partiel | Fix `no_screen_change` OK, pas `Fenêtre incorrecte` |
|
||||
| A5 | Citrix / 100% vision | ✅ Cohérent | UIA est accélérateur local VM, cascade visuelle reste le core |
|
||||
|
||||
**Violation critique découverte** : `visual_workflow_builder/backend/vlm_provider.py` ligne 53-72 — la classe `VisionHub` priorise `OpenAI (gpt-4o) → Gemini → Anthropic → Ollama en dernier`. Importé par `app.py:165`. **Un client déployé avec les mêmes clés d'env enverrait ses écrans médicaux à OpenAI.** Grave.
|
||||
|
||||
#### État des fonctionnalités (14 fonctions)
|
||||
|
||||
| # | Fonctionnalité | Statut | Preuve |
|
||||
|---|---|---|---|
|
||||
| B1 | Agent V1 streaming (capture Windows) | ✅ OK | `executor.py` 2177L, /replay/next pollé |
|
||||
| B2 | Streaming server `api_stream.py` | ✅ OK | 4401L, rpa-streaming active running |
|
||||
| B3 | SomEngine (YOLO + docTR + VLM) | ✅ OK (dormant dans cascade) | `_resolve_by_som` défini mais appelé seulement en V4 resolve_order |
|
||||
| B4 | Resolve cascade (OCR/template/VLM/grounding/SoM) | ✅ OK | `_resolve_target_sync:1530` |
|
||||
| B5 | Contrôle strict étapes (title_match) | ⚠️ OK post, **incohérent pré** | Cf point #2 ci-dessus |
|
||||
| B6 | UIA local (lea_uia.exe) | ⚠️ **OK source, ABSENT deploy** | `deploy/windows_client/.../core/` n'a pas `uia_helper.py` |
|
||||
| B7 | TargetMemoryStore Phase 1 | ⚠️ Greffe OK, **DB vide** | `SELECT COUNT(*) FROM target_memory` → 0 |
|
||||
| B8 | Instrumentation `[REPLAY]` | ✅ OK | 13 logs structurés |
|
||||
| B9 | Garde qualité résolution | ✅ OK | 7 tests unitaires inline |
|
||||
| B10 | `no_screen_change strict → pause` | ✅ OK | commit `7cc03f6f1` |
|
||||
| B11 | VWB | ⚠️ Audit séparé — BROKEN en écriture | Voir section VWB ci-dessous |
|
||||
| B12 | Fédération (`core/federation/`) | ✅ Import OK, non testée | |
|
||||
| B13 | Module auth (`core/auth/`) | ✅ Partiellement branché | |
|
||||
| B14 | Workers systemd | ⚠️ Mixte | streaming/agent-chat/api OK, dashboard inactive, worker 5099 NOT LISTENING, healthcheck failed |
|
||||
|
||||
#### Dettes techniques — 20 items priorisés (rapport complet)
|
||||
|
||||
**🔴 Priorité haute** (5 items) :
|
||||
|
||||
| # | Lieu | Nature | Effort |
|
||||
|---|---|---|---|
|
||||
| **C1** | `agent_v0/server_v1/replay_failure_logger.py` | **Fichier ni tracké ni gitignoré**, importé à `api_stream.py:29`. **Un `git clone` ne peut plus démarrer le serveur.** | < 15 min (git add) |
|
||||
| **C2** | `api_stream.py` branche `Fenêtre incorrecte` | Asymétrie avec fix `7cc03f6f1` — bloquer avec `paused_need_help` au lieu de retry+stop | 1-2h |
|
||||
| **C3** | `deploy/windows_client/agent_v1/core/executor.py` | 1303 insertions non committées, manque `uia_helper.py` + `grounding.py` + `policy.py` + `recovery.py` | Demi-journée |
|
||||
| **C4** | `.claude/worktrees/agent-a0ebc90f/` | Worktree ne contient pas `replay_failure_logger.py` → merge cassera l'import | 1-2h |
|
||||
| **C5** | `tests/unit/test_som_integration.py::test_resolve_success` | Mock cassé par refactoring, cible `api_stream._get_som_engine_api` au lieu de `resolve_engine.*` | 30 min |
|
||||
|
||||
**🟡 Priorité moyenne** (7 items) :
|
||||
|
||||
- **C6** : `vlm_provider.py` cloud-first (violation A2) — 1-2h
|
||||
- **C7** : `live_session_manager.py` 118 lignes non committées (code propre utile) — 30 min
|
||||
- **C8** : Worker port 5099 inactif (sessions jamais compilées en ExecutionPlan) — 1-2h
|
||||
- **C9** : Services systemd healthcheck + artifact-retention failed — 30 min
|
||||
- **C10** : Scaffold vide `agent_v1/` top-level — < 15 min
|
||||
- **C11** : 22 fichiers en `M` non committés (diffus) — demi-journée triage
|
||||
- **C12** : `core/detection/vlm_config.py` non tracké — 10 min
|
||||
|
||||
**🟢 Priorité basse** (8 items) :
|
||||
|
||||
- **C13** : `_a_trier/` 561 Mo + `visual_workflow_builder/_a_trier/` 7.6 Go
|
||||
- **C14** : 2 venvs VWB → 15.6 Go disque
|
||||
- **C15** : `core/execution/target_resolver.py` (3495L V3 dormant)
|
||||
- **C16** : README.md obsolète (décembre 2024)
|
||||
- **C17** : `web_dashboard/app.py.bak_20260304_2225`
|
||||
- **C18** : `archives/` 21 Mo committé dans le repo
|
||||
- **C19** : 53 TODO/FIXME/HACK dans Python
|
||||
- **C20** : `data/training/live_sessions/` 5.1 Go sans rotation
|
||||
|
||||
#### Régressions potentielles depuis le matin
|
||||
|
||||
**Aucune nouvelle régression** introduite par les 5 commits du jour. L'incohérence `Fenêtre incorrecte` est **pré-existante** (branche legacy qui aurait dû être corrigée en même temps). Guardian a vérifié les 4 branches de `/replay/result` (lignes 3090-3330) — elles s'enchaînent proprement, pas d'interaction non triviale entre D1-D5.
|
||||
|
||||
#### Recommandations priorisées du guardian
|
||||
|
||||
| # | Priorité | Action | Effort | Justification vision |
|
||||
|---|---|---|---|---|
|
||||
| **E1** | **P0** | Committer `replay_failure_logger.py` | < 15 min | Empêche tout fresh clone/deploiement |
|
||||
| **E2** | **P0** | Corriger asymétrie `Fenêtre incorrecte` → `paused_need_help` | 1-2h | Respect `feedback_failure_is_learning.md` + débloque test chirurgical |
|
||||
| **E3** | **P1** | Sync deploy copy avec source + ajouter uia_helper/grounding/policy/recovery | Demi-journée | Sans ça Léa n'a pas accès à ses propres progrès sur VM |
|
||||
| **E4** | **P1** | **Premier replay E2E réussi** pour activer Phase 1 (Notepad propre, 2 fois, vérifier target_memory.db) | 1-2h | **Seule façon de prouver que Léa apprend vraiment** |
|
||||
| **E5** | **P1** | Décider du sort du worktree (compléter ou refaire) | 1-2h | Éviter dette multi-copies |
|
||||
| **E6** | **P2** | Gater `vlm_provider.py` derrière env var (violation 100% local) | 1-2h | Respect `feedback_local_only.md` |
|
||||
| **E7** | **P2** | Relancer worker 5099 + vérifier compilation sessions | 1-2h | Pipeline apprentissage cassé en bout |
|
||||
| **E8** | **P2** | Committer `live_session_manager.py` | 30 min | Dette git |
|
||||
| **E9** | **P3** | Réparer test_som_integration | 30 min | Suite unit verte |
|
||||
| **E10** | **P3** | Nettoyer 2 venvs VWB | 30 min | 15.6 Go disque |
|
||||
|
||||
### Audit VWB — **TERMINÉ** ✅
|
||||
|
||||
**TL;DR : VWB est un piège pour notre besoin. Recommandation = Option A (petit outil dédié 1 jour).**
|
||||
|
||||
#### État global
|
||||
Partiellement fonctionnel en **lecture**, **CASSÉ en écriture** (bug runtime trivial), et **gravement amputé en contenu** (bridge Léa→VWB perd 90% de l'information). Le scaffolding est là, la chaîne end-to-end ne fonctionne pas.
|
||||
|
||||
#### Bug bloquant immédiat
|
||||
Le processus 1800738 (vwb-backend:5002) tient un handle sur une version **supprimée** de `workflows.db` (6 file descriptors sur `(deleted)`). Toute écriture → `sqlite3.OperationalError: attempt to write a readonly database`. Un `systemctl --user restart rpa-vwb-backend` règle ce point — mais les vrais problèmes restent.
|
||||
|
||||
#### Ambiguïtés structurelles (dette)
|
||||
| Problème | Impact |
|
||||
|---|---|
|
||||
| `frontend/` (vieux, inactif) vs `frontend_v4/` (actif) | Confusion à chaque lecture code |
|
||||
| `app.py` (5002, avec api_v3) vs `app_lightweight.py` (5003, SANS api_v3) | Deux backends parallèles |
|
||||
| `db/models.py` (legacy) vs `instance/workflows.db` SQLAlchemy (api_v3) | **Deux DB parallèles** — workflows visibles ici ≠ workflows visibles là |
|
||||
|
||||
#### Ce qui marche côté code (vérifié)
|
||||
- `GET /api/v3/learned-workflows` : liste 126 workflows Léa sur disque
|
||||
- Routes CRUD Step : add/update/delete/reorder (existent backend + client TS)
|
||||
- PropertiesPanel 1415 lignes — édite délais, texte, direction, hover_duration
|
||||
- UI drag-and-drop ajout step via tool palette → React Flow
|
||||
- `POST /api/v3/execute-windows` : proxy vers streaming server, fonctionnel
|
||||
|
||||
#### Ce qui manque (critique pour le nettoyage)
|
||||
|
||||
1. **Import compound = coquille vide** : 95% des edges Léa sont des actions `type: compound` avec 10-40 sous-étapes (clic + waits + text_input lettre par lettre). Le bridge crée **UN seul step VWB** pour toute la compound → *"Impossible de nettoyer ce qu'on ne voit pas"*
|
||||
2. **Pas d'auth VWB → streaming server** : `requests.get(...)` sans header Authorization, donc `streaming_server_available: false`. VWB ne voit que les workflows déjà compilés (max 4 avril), pas les sessions récentes
|
||||
3. **Pas de screenshots attachés** : les `shots/*.png` de session ne sont jamais liés aux steps importés
|
||||
4. **Pas de réordonnancement UI** : `reorderSteps` existe backend + client, mais **aucun composant React ne l'appelle**
|
||||
5. **Édition target_spec absente** : PropertiesPanel n'a **aucun champ** `x_pct`, `y_pct`, `target_role`, `target_text`, `vlm_description`. Impossible de corriger un faux positif
|
||||
6. **Pas d'ingestion raw events** : `live_events.jsonl`, `build_replay_from_raw_events`, `execution_plan_to_actions` ne sont pas importés dans VWB
|
||||
7. **Pas de liste "sessions récentes"** : aucun endpoint VWB qui liste les `sess_*` sur disque
|
||||
8. **Zéro test** sur le pont Léa ↔ VWB
|
||||
|
||||
#### Effort de réparation estimé
|
||||
|
||||
| Item | Effort |
|
||||
|---|---|
|
||||
| Restart service (fix DB readonly) | 5 min |
|
||||
| Ajouter auth Bearer VWB → :5005 | 30 min |
|
||||
| Décomposer actions compound en N steps VWB | **1 jour** |
|
||||
| Attacher screenshots aux steps | **1 jour** |
|
||||
| Édition target_spec dans PropertiesPanel | 0.5 jour |
|
||||
| Drag-to-reorder UI | 0.5 jour |
|
||||
| Endpoint `/live-sessions` + importer raw | **1-2 jours** |
|
||||
| Tests minimal | 0.5 jour |
|
||||
| **Total** | **4-5 jours** |
|
||||
|
||||
Et encore, **on hériterait de la dette** (deux DB, deux backends, zéro test, frontend abandonné).
|
||||
|
||||
#### Recommandation — 3 options
|
||||
|
||||
**Option A (recommandée) — Outil dédié léger, 1 jour** ⭐
|
||||
Écrire un petit Flask (200 lignes) qui :
|
||||
1. Liste les sessions `live_sessions/*/sess_*` sur disque
|
||||
2. Charge `live_events.jsonl` via `build_replay_from_raw_events` (existe déjà dans `stream_processor.py:1279`)
|
||||
3. Affiche la liste linéaire des actions + screenshots `shots/` correspondants
|
||||
4. Checkbox "supprimer cette étape" + édition texte simple
|
||||
5. Re-sérialise et POST vers `/api/v1/traces/stream/replay/raw`
|
||||
|
||||
**Évite toute la complexité VWB** et cible exactement le besoin : "supprimer 3 clics parasites et relancer".
|
||||
|
||||
**Option B — Réparer VWB minimalement (2 jours)** — restart + auth + décomposer compound. Hérite de toute la dette UX.
|
||||
|
||||
**Option C — Abandonner VWB** — suggéré par l'accumulation de dette (126 workflows "pending_review", zéro test sur le pont, deux backends, frontend abandonné)
|
||||
|
||||
**Vote de l'agent VWB** : *"Option A. Le besoin réel est 'supprimer 3 clics parasites et relancer' — c'est 30 secondes d'UX, pas un Visual Workflow Builder."*
|
||||
|
||||
**Mon vote aussi** : **A**. Parce que ça sert directement notre prochain test replay. B prend plus de temps qu'il ne nous fait gagner. C laisse la dette pourrir.
|
||||
|
||||
---
|
||||
|
||||
## Partie 5 — Actions recommandées quand tu rentres
|
||||
|
||||
Par ordre de priorité :
|
||||
|
||||
### P0 — À faire dans les 10 premières minutes de ton retour
|
||||
|
||||
1. **Vérifier sur la VM** que Bloc-notes est bien fermé (ou pas), et si possible ce qui a volé le focus
|
||||
2. **Lire la partie 1** de cette synthèse (résultat test chirurgical)
|
||||
3. **Lire les rapports des 2 agents** (sections 4)
|
||||
|
||||
### P1 — À discuter avec moi
|
||||
|
||||
4. **Corriger l'incohérence `Fenêtre incorrecte strict → pause apprentissage`** (même pattern que C)
|
||||
5. **Décider** : on continue à stabiliser le replay avec des tests manuels, OU on passe à l'intégration d'OS-Atlas-Base-7B comme grounder, OU on attaque VWB comme outil de correction ?
|
||||
6. **Externaliser les phrases types** en JSON i18n (petit commit)
|
||||
|
||||
### P2 — Plus tard dans la journée
|
||||
|
||||
7. Merger la migration `agent_v0/` → top-level (worktree déjà prêt)
|
||||
8. Investiguer le fichier `replay_failure_logger.py` (importé, pas tracké)
|
||||
9. Démarrer le worker VLM pour que les sessions soient compilées en workflows
|
||||
|
||||
### P3 — Semaine prochaine
|
||||
|
||||
10. Nettoyer `_a_trier/` et `archives/`
|
||||
11. Sync de l'agent deploy copy avec le dev
|
||||
12. Implémenter le filtre "ignore clics de fin d'enregistrement" côté captor
|
||||
|
||||
---
|
||||
|
||||
*Document généré automatiquement pendant l'absence de Dom. Sera mis à jour avec les rapports des agents d'audit.*
|
||||
289
docs/demo/FAQ_EXPERTS_RPA.md
Normal file
289
docs/demo/FAQ_EXPERTS_RPA.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# FAQ — Questions des experts RPA (démo 26 avril 2026)
|
||||
|
||||
**Audience** : DG/DSI de groupements de cliniques, dont **plusieurs ont déjà
|
||||
déployé UiPath, Automation Anywhere ou Power Automate**. Ils connaissent les
|
||||
limites du RPA classique. Ils vont challenger.
|
||||
|
||||
**Posture de réponse** : factuelle, posée, ni défensive ni bravache. Quand
|
||||
on n'a pas, on le dit. On finit toujours par ramener la conversation sur
|
||||
**le cas métier urgences et le ROI chiffré**.
|
||||
|
||||
---
|
||||
|
||||
## Bloc TECHNO
|
||||
|
||||
### Q1. Pourquoi pas UiPath / Automation Anywhere / Power Automate ?
|
||||
|
||||
Les RPA classiques se cassent dès que l'UI change d'un pixel ou qu'on passe
|
||||
par Citrix. Ils fonctionnent bien sur des processus répétitifs ultra-stables
|
||||
(compta, RH), mal sur des métiers visuels variables comme les urgences.
|
||||
Léa ne lit pas le DOM ni l'accessibility tree : elle **voit l'écran comme
|
||||
un humain** et s'adapte quand l'UI bouge. On n'est pas un concurrent
|
||||
généraliste d'UiPath : on est le bon outil pour **un métier précis où
|
||||
UiPath échoue**.
|
||||
|
||||
### Q2. Comment Léa gère Citrix / RDP / VDI ?
|
||||
|
||||
C'est notre terrain principal, pas un cas dégradé. Léa capture l'écran
|
||||
(image), comprend la structure via un modèle de vision, et interagit
|
||||
via clavier/souris standard. Pas d'API, pas de tree, pas de crochet.
|
||||
Citrix ou natif, c'est transparent. En contrepartie, la latence est
|
||||
légèrement plus élevée (100-300 ms par action vs 50 ms en natif) —
|
||||
acceptable pour du codage PMSI, pas pour du trading.
|
||||
|
||||
### Q3. Vous utilisez quel VLM ? Vos modèles sont open source ?
|
||||
|
||||
100 % modèles open source, tournant en local. Le stack actuel combine :
|
||||
Qwen2.5-VL (grounding visuel), UI-TARS-1.5-7B (action sur UI), et des
|
||||
prompts métier spécifiques par domaine (PMSI urgences, facturation, etc.).
|
||||
Rien chez OpenAI, rien chez Anthropic au runtime client. Infrastructure
|
||||
minimum pour un pilote : un serveur GPU (RTX 5070 ou équivalent) sur site
|
||||
ou dans le cloud souverain.
|
||||
|
||||
### Q4. Que se passe-t-il quand l'UI change (mise à jour du DPI) ?
|
||||
|
||||
C'est la vraie question. Trois niveaux de réponse :
|
||||
1. **Petit changement** (position d'un bouton, couleur) : Léa s'adapte
|
||||
toute seule, parce qu'elle reconnaît sémantiquement "bouton Valider",
|
||||
pas "bouton en x=420 y=180".
|
||||
2. **Gros changement** (refonte UI) : Léa **détecte qu'elle ne comprend
|
||||
plus**, se met en pause, et demande à l'humain. Pas de casse silencieuse.
|
||||
3. **Réapprentissage** : la TIM refait le workflow une fois en mode
|
||||
"apprends-moi", Léa se remet à jour. Temps typique : 15-30 minutes.
|
||||
|
||||
### Q5. Vous supportez quels OS ?
|
||||
|
||||
Agent client : **Windows 10/11** (cible principale, c'est ce que les
|
||||
cliniques ont). Serveur : **Linux** (Ubuntu 22.04+). macOS et Linux
|
||||
côté client ne sont pas prioritaires — la demande est marginale en milieu
|
||||
hospitalier.
|
||||
|
||||
### Q6. Que faites-vous contre le drift d'écran entre deux sessions (taille de fenêtre, thème Windows) ?
|
||||
|
||||
Trois couches : (1) capture normalisée en résolution logique, (2)
|
||||
invariance aux thèmes sombres/clairs via apprentissage multi-contextes,
|
||||
(3) mémoire des cibles par signature sémantique plutôt que par coordonnées.
|
||||
En pratique, sur nos tests chez Resurgences, le drift normal (thème,
|
||||
multi-écran) ne casse pas l'exécution.
|
||||
|
||||
### Q7. Qu'est-ce qui se passe si le médecin ou la TIM tape au clavier pendant que Léa exécute ?
|
||||
|
||||
Détection d'interférence humaine : Léa met en pause dès qu'elle détecte une
|
||||
activité clavier/souris non-Léa sur son fil d'exécution. L'humain reprend
|
||||
la main, Léa reprend quand il a fini. Pas de conflit, pas de frappe
|
||||
mélangée.
|
||||
|
||||
---
|
||||
|
||||
## Bloc SCALING / ROBUSTESSE
|
||||
|
||||
### Q8. Combien de postes Léa peut-on déployer en parallèle ?
|
||||
|
||||
Sur un serveur GPU mid-range (RTX 5070), on vise **10-20 postes en
|
||||
parallèle** pour des workflows typiques (pas de vidéo temps réel 4K). Au-
|
||||
delà, on scale horizontalement (plusieurs serveurs) ou on passe sur un GPU
|
||||
plus costaud (A100, DGX). L'architecture est modulaire, le goulet
|
||||
d'étranglement est le GPU, pas le code.
|
||||
|
||||
### Q9. Latence serveur ?
|
||||
|
||||
Action simple (clic, frappe) : 200-400 ms côté Léa (capture → inférence →
|
||||
commande). Action complexe (compréhension d'écran + décision) : 1-3 s.
|
||||
Pour du codage PMSI, ça passe largement. Pour du trading haute fréquence,
|
||||
ce n'est pas le bon produit.
|
||||
|
||||
### Q10. Que fait Léa si le serveur est down ?
|
||||
|
||||
L'agent client bascule en **buffer local** : il continue à capturer ce que
|
||||
l'utilisateur fait, stocke les sessions, et les envoie quand le serveur
|
||||
revient. Pas de perte de données. L'exécution autonome, elle, se met en
|
||||
pause — pas de fallback aveugle.
|
||||
|
||||
### Q11. Reprise après erreur ? Replay automatique ?
|
||||
|
||||
Trois niveaux :
|
||||
1. **Reprise automatique** si l'erreur est transiente (popup, dialogue
|
||||
inattendu reconnu par Léa).
|
||||
2. **Pause + demande à l'humain** si Léa ne comprend pas ce qu'elle voit
|
||||
("je vois un écran que je ne connais pas, merci de m'aider"). C'est la
|
||||
règle : un échec devient un apprentissage, pas un crash.
|
||||
3. **Replay à froid** du workflow complet depuis l'observation humaine
|
||||
initiale, si on veut tout rejouer.
|
||||
|
||||
### Q12. Et si Léa fait une erreur qui a un impact réel (un mauvais code envoyé au PMSI) ?
|
||||
|
||||
Trois garde-fous : (1) **mode strict** où Léa ne valide jamais un envoi
|
||||
final — toujours confirmation humaine, (2) **seuil de confidence** configurable
|
||||
par workflow, (3) **log d'audit complet** (toutes les décisions Léa sont
|
||||
tracées, replay vidéo possible). En 100 % autonomous, Léa agit seulement
|
||||
sur des workflows **validés plusieurs fois** et avec un seuil de confidence
|
||||
haut. Pour les urgences pilote, on démarre en mode "assistante" (copilote),
|
||||
pas en 100 % autonome.
|
||||
|
||||
---
|
||||
|
||||
## Bloc SÉCURITÉ / CONFORMITÉ
|
||||
|
||||
### Q13. RGPD, AI Act, HDS — comment vous vous positionnez ?
|
||||
|
||||
- **RGPD** : 100 % local, aucune donnée patient ne sort du SI. L'agent
|
||||
capture l'écran, traite sur un serveur **sur site ou en cloud souverain**
|
||||
(3DS Outscale, OVH HDS). Pas de routing cloud US.
|
||||
- **AI Act** : Léa est système IA au sens de l'article 50 (cf. LISEZMOI),
|
||||
flag d'information utilisateur intégré. Classification "risque limité" en
|
||||
copilote, "risque élevé" si autonome — documentation dans
|
||||
`docs/RAPPORT_CONFORMITE_AI_ACT.md`.
|
||||
- **HDS** : l'hébergement serveur doit être HDS-certifié. On accompagne le
|
||||
pilote sur le choix de l'hébergeur si besoin.
|
||||
|
||||
### Q14. "100 % local", ça veut dire quoi concrètement ?
|
||||
|
||||
Deux niveaux d'installation possibles :
|
||||
1. **Tout sur site** : serveur GPU dans le SI de la clinique, zéro sortie
|
||||
réseau. Cas le plus sûr, le plus cher (hardware).
|
||||
2. **Serveur en cloud souverain HDS** : données chiffrées en transit et au
|
||||
repos, clés maîtrisées par le client. Aucun transit hors UE.
|
||||
Dans les deux cas : **aucun appel cloud US**, aucun LLM propriétaire
|
||||
externe (pas de ChatGPT, pas de Claude, pas de Gemini).
|
||||
|
||||
### Q15. Logs d'audit, traçabilité des décisions IA ?
|
||||
|
||||
Chaque décision de Léa (ce qu'elle a vu, ce qu'elle a décidé, sur quel
|
||||
élément elle a cliqué) est loggée : screenshot avant/après, prompt soumis
|
||||
au VLM, réponse, action exécutée. Rétention paramétrable (**minimum 180
|
||||
jours** en config par défaut pour conformité). Replay vidéo d'un workflow
|
||||
possible pour audit DIM ou ARS.
|
||||
|
||||
### Q16. Où est stocké le modèle ? Il apprend sur nos données ? Qui les possède ?
|
||||
|
||||
Modèles **open source** stockés sur le serveur client (poids Qwen, UI-TARS,
|
||||
etc.). **Aucun apprentissage en ligne sur les données patient** par défaut —
|
||||
on ne renvoie rien aux éditeurs des modèles. Un mode "fine-tuning local"
|
||||
existe pour spécialiser Léa sur le vocabulaire d'une clinique, **exécuté
|
||||
sur le serveur du client, poids gardés par le client**. Le client est
|
||||
propriétaire de ses données ET de ses fine-tunings.
|
||||
|
||||
### Q17. Qui accède aux données chez vous ? Vos équipes ?
|
||||
|
||||
En prod chez un client : personne chez AIVANOV n'accède aux données sans
|
||||
autorisation écrite. En pilote : un accès de debug peut être demandé avec
|
||||
procédure documentée (lecture seule, logs anonymisés). **Les credentials
|
||||
métier (compte DPI de la TIM) sont stockés dans un vault chiffré
|
||||
Fernet/AES local** — jamais en clair côté serveur, jamais chez nous.
|
||||
|
||||
---
|
||||
|
||||
## Bloc BUSINESS
|
||||
|
||||
### Q18. Licensing ? C'est combien ?
|
||||
|
||||
**Modèle économique aligné sur la valeur**. Les postes utilisateurs ne
|
||||
sont qu'une étape (Shadow puis Copilot) : une fois Léa entraînée, elle
|
||||
tourne **en autonome** sur infrastructure dédiée — facturer "par poste"
|
||||
reviendrait à facturer la phase d'apprentissage, pas la valeur créée.
|
||||
|
||||
**Notre proposition de valeur** : vous ne payez significativement **que
|
||||
sur la valeur démontrée** (gains PMSI récupérés, mesurés via audit trail).
|
||||
Plusieurs modèles possibles selon votre contexte (forfait établissement,
|
||||
% de la valeur récupérée, volume traité, hybride) — **on cale ensemble
|
||||
le modèle qui vous convient en one-to-one**.
|
||||
|
||||
Pour les **premiers pilotes** : accompagnement gratuit 2 mois
|
||||
(infrastructure + setup + apprentissage), puis bascule sur le modèle
|
||||
pérenne choisi ensemble.
|
||||
|
||||
*→ Rediriger vers one-to-one à la pause si question poussée — ne pas
|
||||
annoncer un chiffre précis en plénière.*
|
||||
|
||||
### Q19. Support, maintenance, SLA ?
|
||||
|
||||
Pilote : accompagnement direct Dom + Amina, réponse < 4 h ouvrées.
|
||||
Production : SLA à contractualiser (8 h ouvrées standard, 2 h premium).
|
||||
Maintenance : mises à jour modèles trimestrielles, correctifs sur demande.
|
||||
|
||||
### Q20. Qui êtes-vous, votre équipe, vos références ?
|
||||
|
||||
AIVANOV, SAS française, fondée par Amina ETTORCHI (présidente,
|
||||
ex-TIM/DIM, 15 ans d'expérience PMSI). Équipe technique lead Dom
|
||||
(architecture vision + RPA). Projet Léa en développement depuis 2025,
|
||||
version 1.0 déployable depuis avril 2026. **Premier pilote client à
|
||||
démarrer** — pas de référence client public aujourd'hui, on est
|
||||
transparent sur ce point et c'est une **opportunité pour les premiers
|
||||
cliniques** (conditions pilote spécifiques).
|
||||
|
||||
### Q21. Combien de temps pour un pilote ? Pour une prod ?
|
||||
|
||||
- **Pilote** : 6-8 semaines (semaine 1-2 cadrage + capture workflow,
|
||||
semaine 3-4 test en double avec la TIM, semaine 5-8 mesure ROI).
|
||||
- **Mise en prod** : 2-3 mois après pilote validé, selon intégration SI
|
||||
et HDS.
|
||||
- **Rampe multi-clinique** : 1 clinique par mois en rythme raisonnable.
|
||||
|
||||
### Q22. Qu'est-ce qui n'est pas encore prêt, honnêtement ?
|
||||
|
||||
- Mode **autonomous 100 % non-supervisé** : en développement, pas
|
||||
production-ready. En pilote, on démarre en **copilote** (Léa propose,
|
||||
la TIM valide).
|
||||
- **Fiche d'identité par DPI** : Resurgences et quelques autres sont
|
||||
bien testés, les outils plus rares demandent une phase d'apprentissage
|
||||
dédiée.
|
||||
- **Support macOS / Linux côté client** : pas prioritaire, à la demande.
|
||||
- **Multilingue** : français uniquement aujourd'hui. Anglais prévu S2 2026.
|
||||
|
||||
---
|
||||
|
||||
## Bloc URGENCES (spécifique)
|
||||
|
||||
### Q23. Pourquoi les urgences d'abord ? Pourquoi pas la facturation ou la pharmacie ?
|
||||
|
||||
Deux raisons : (1) **c'est là qu'Amina a prouvé 150 k€/mois de récupération
|
||||
manuelle** — on bâtit sur une preuve terrain chiffrée. (2) C'est un
|
||||
domaine à **forte douleur** (TIM sous pression, codage fait vite, DPI
|
||||
variés). Le ROI est évident, la reproductibilité est haute. Facturation
|
||||
et pharmacie suivront, après un premier carton aux urgences.
|
||||
|
||||
### Q24. Quels DPI urgences supportés aujourd'hui ?
|
||||
|
||||
**Testés et opérationnels** : Resurgences (Softway). **En cours de
|
||||
validation** : Urqual, DxCare, CristalNet Urgences, Hôpital Manager.
|
||||
**Non testés aujourd'hui** : Osiris, outils métier propriétaires
|
||||
d'établissement. Le coût d'ajout d'un nouveau DPI est faible (5-10 jours
|
||||
de capture/apprentissage TIM) grâce à l'approche 100 % vision.
|
||||
|
||||
### Q25. Quel ROI moyen sur les urgences ?
|
||||
|
||||
Borne basse prouvée (sans IA, Amina en manuel) : **150 k€/mois/clinique**.
|
||||
Avec Léa, on **scale cette méthode sur des volumes que la TIM ne pouvait
|
||||
pas traiter manuellement** — on couvre 100 % des dossiers au lieu de 10-20 %.
|
||||
Projection prudente : **+30 à +70 % vs manuel**, soit **200-250 k€/mois/
|
||||
clinique potentiel** pour des groupements avec volumes urgences élevés.
|
||||
**À confirmer sur pilote.**
|
||||
|
||||
### Q26. Est-ce que Léa remplace la TIM ?
|
||||
|
||||
Non. Léa fait le travail répétitif à haute valeur/faible complexité (les
|
||||
80 % des dossiers "évidents") et **libère la TIM pour les 20 % de cas
|
||||
complexes** (dossiers longs, arbitrages médicaux, contrôle qualité). Le
|
||||
discours pour la TIM : "on t'enlève les corvées, tu gardes le cœur du
|
||||
métier". En pratique, on gagne du temps qualifié et on réduit le turn-over
|
||||
TIM (douleur RH forte dans la plupart des cliniques).
|
||||
|
||||
### Q27. Comment vous convainquez un médecin urgentiste que Léa sait coder mieux que lui le soir à 23 h fatigué ?
|
||||
|
||||
On ne le convainc pas, **on lui montre sur son dossier d'hier soir** ce
|
||||
qu'il a oublié, avec la justification ligne par ligne. L'adhésion médecin
|
||||
se joue en démo, pas en réunion. Et ce n'est pas "Léa contre le médecin",
|
||||
c'est "Léa filet de sécurité pour le médecin fatigué à 23 h".
|
||||
|
||||
---
|
||||
|
||||
## Ce qu'on ne dit JAMAIS devant cette audience
|
||||
|
||||
- "On n'a pas encore..." (à transformer en "c'est sur la roadmap S2 2026").
|
||||
- "Notre produit est un prototype" (il est en v1.0, livrable).
|
||||
- "Ça ne marche pas toujours" (on dit "il y a un mode supervisé pour les
|
||||
cas où Léa doute").
|
||||
- "UiPath c'est nul" (on dit "UiPath est excellent sur d'autres terrains,
|
||||
nous on est spécialisés urgences").
|
||||
- "On est une startup de 2 personnes" (on dit "une équipe ramassée, agile,
|
||||
avec 15 ans d'expertise métier en lead").
|
||||
200
docs/demo/GRILLE_INTERVIEW_TIM.md
Normal file
200
docs/demo/GRILLE_INTERVIEW_TIM.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# GRILLE D'INTERVIEW — TIM urgences (préparation démo du 26 avril)
|
||||
|
||||
**Objectif** : identifier **LE** workflow urgences à transformer en démo live
|
||||
percutante devant 10-20 DG/DSI de groupements de cliniques.
|
||||
|
||||
**Durée cible de l'entretien** : **45-60 min** max.
|
||||
|
||||
**Posture** : Amina pose les questions métier, Dom écoute et prend des notes
|
||||
techniques. On ne contredit **jamais** la TIM sur sa description du terrain —
|
||||
même si ça ne correspond pas à ce qu'on avait imaginé. C'est elle l'experte.
|
||||
|
||||
**À la sortie de l'entretien, on doit repartir avec :**
|
||||
1. Un workflow précis (5-15 étapes) à reproduire en démo.
|
||||
2. Un chiffre de ROI estimé (euros/dossier ou euros/mois).
|
||||
3. La liste des logiciels concernés (DPI + outils annexes).
|
||||
4. Un verdict "on demande à la TIM d'être présente à la démo ? Oui/Non".
|
||||
|
||||
---
|
||||
|
||||
## Bloc A — Son quotidien (5 questions)
|
||||
|
||||
### A1. Combien de dossiers urgences tu codes par jour ?
|
||||
|
||||
*Pourquoi on pose cette question : pour calibrer le volume annuel et projeter
|
||||
le ROI — 50/jour vs 200/jour change complètement l'échelle du chiffre qu'on
|
||||
montre aux DG.*
|
||||
|
||||
### A2. Quel est ton temps moyen par dossier (du moment où tu l'ouvres au moment où tu valides) ?
|
||||
|
||||
*Pourquoi on pose cette question : pour chiffrer le gain potentiel en minutes
|
||||
× nombre de dossiers × coût chargé TIM. C'est la promesse "temps gagné", qui
|
||||
est secondaire à la promesse "récupération de valorisation", mais utile.*
|
||||
|
||||
### A3. Quel DPI urgences tu utilises ? (Resurgences, Urqual, DxCare, CristalNet, Hôpital Manager, autre ?)
|
||||
|
||||
*Pourquoi on pose cette question : on veut savoir si elle utilise **un DPI
|
||||
qu'on a déjà testé** (Resurgences côté Softway est le plus probable en
|
||||
clinique). Si c'est un outil exotique, on ajuste la démo ou on bascule sur
|
||||
un de ses collègues.*
|
||||
|
||||
### A4. Où tu saisis le RUM ? Où tu saisis le RPU ? (même logiciel, onglets séparés, deux logiciels ?)
|
||||
|
||||
*Pourquoi on pose cette question : la structure PMSI urgences est double (RUM
|
||||
PMSI + RPU ARS). On veut savoir combien d'écrans/logiciels sont impliqués
|
||||
pour dimensionner la complexité du scénario démo.*
|
||||
|
||||
### A5. À quelle heure/dans quel contexte tu codes ? (temps réel pendant que le médecin voit le patient, J+1 en batch, fin de semaine ?)
|
||||
|
||||
*Pourquoi on pose cette question : si elle code en différé (J+1 ou J+3), le
|
||||
scénario "audit rétrospectif" colle parfaitement — c'est exactement ce que
|
||||
Léa fait de mieux. Si c'est en temps réel, scénario B "assistant temps réel"
|
||||
est plus adapté mais plus risqué.*
|
||||
|
||||
---
|
||||
|
||||
## Bloc B — Les pertes et les douleurs (5 questions)
|
||||
|
||||
### B1. Quels sont les codes les plus souvent oubliés ou sous-valorisés ? Tu peux me citer le top 5 ?
|
||||
|
||||
*Pourquoi on pose cette question : **question clé**. On cherche LE pattern qui
|
||||
revient (ex : "la suture complexe qui devient simple", "le monitoring oublié",
|
||||
"l'anesthésie locale pas codée"). C'est ce qu'on fera détecter à Léa en démo,
|
||||
et c'est crédible parce que ça vient du terrain.*
|
||||
|
||||
### B2. Tu as un exemple récent (cette semaine ou la semaine dernière) d'un dossier où tu as récupéré un acte ou un code que le médecin avait oublié ? Raconte-moi.
|
||||
|
||||
*Pourquoi on pose cette question : on cherche une **histoire vraie, concrète,
|
||||
chiffrable** qu'Amina pourra raconter en ouverture de démo. "Lundi dernier,
|
||||
dossier M. X, ECG non codé = 42 €." C'est 100× plus fort que des stats.*
|
||||
|
||||
### B3. Dans ta journée, combien de temps tu passes à **chercher** dans le DPI (scroll, changement d'onglets, copier-coller d'un écran à l'autre) versus à réellement **coder** ?
|
||||
|
||||
*Pourquoi on pose cette question : on veut quantifier la friction UI. Léa
|
||||
élimine justement la partie "navigation/recherche". Si elle répond "70 % du
|
||||
temps à chercher", on a un slide killer.*
|
||||
|
||||
### B4. Quelles sont les étapes répétitives qui t'énervent le plus ? (celles qui te donnent l'impression de perdre ton temps)
|
||||
|
||||
*Pourquoi on pose cette question : on cherche la **douleur émotionnelle**, pas
|
||||
juste le gain chiffré. Les DG aiment les vraies histoires ("Pendant qu'elle
|
||||
fait ça 80 fois par jour, elle pourrait faire autre chose"). Ça humanise.*
|
||||
|
||||
### B5. À côté de ton DPI, tu utilises des outils Excel / Word / listes papier / mail pour compléter le codage ? (suivi, relance des médecins, consolidation)
|
||||
|
||||
*Pourquoi on pose cette question : c'est là que **Léa est imbattable** — faire
|
||||
le pont entre DPI et outils bureautiques. Si elle tient un Excel des "dossiers
|
||||
à relancer", on a un scénario en or : Léa lit le DPI, remplit l'Excel,
|
||||
envoie le mail au médecin.*
|
||||
|
||||
---
|
||||
|
||||
## Bloc C — Un scénario démo idéal (5 questions)
|
||||
|
||||
### C1. Si tu devais impressionner ton directeur général avec un outil qui fait ton travail pour toi, **qu'est-ce que tu lui montrerais en premier** ?
|
||||
|
||||
*Pourquoi on pose cette question : meilleure question du blocage. Elle nous
|
||||
dit **exactement** ce qui, pour elle, est le plus impressionnant. Si elle
|
||||
hésite, reformuler : "Qu'est-ce qui, pour toi, serait magique ?"*
|
||||
|
||||
### C2. Ton workflow de codage typique, il tient en combien d'étapes ? (5 clics ? 20 ? 50 ?)
|
||||
|
||||
*Pourquoi on pose cette question : pour dimensionner la durée de la démo live.
|
||||
Au-dessus de 15 étapes, on découpe en segments ou on enregistre une vidéo
|
||||
backup pour la partie répétitive.*
|
||||
|
||||
### C3. Est-ce que tu valides parfois plusieurs dossiers à la suite en **batch** (par exemple le lundi matin pour les passages du week-end) ?
|
||||
|
||||
*Pourquoi on pose cette question : le batch est le cas d'usage où Léa brille
|
||||
(2 min × 30 dossiers = 1 h gagnée d'un coup). Si elle dit oui, c'est notre
|
||||
scénario A idéal.*
|
||||
|
||||
### C4. Y a-t-il un cas où tu **croises plusieurs logiciels** (DPI + courrier SAMU + Excel de suivi + mail au médecin) pour clôturer un dossier ?
|
||||
|
||||
*Pourquoi on pose cette question : **démo multi-app = effet "waouh"
|
||||
maximum** devant les RPA-experts qui savent que ça casse leurs bots UiPath.
|
||||
Si elle dit oui et que le workflow est reproductible, c'est notre scénario
|
||||
roi.*
|
||||
|
||||
### C5. Si je te disais "Léa lit un dossier urgence et te dit : il manque un acte (ECG non codé = 42 €)", est-ce que ton DG comprendrait tout de suite la valeur ?
|
||||
|
||||
*Pourquoi on pose cette question : test de pitch. Si elle dit "oui, il
|
||||
comprend direct", on tient notre angle. Si elle dit "non, il s'en fout, c'est
|
||||
la TIM qui décide", on doit repivoter vers un autre interlocuteur (DAF ? DIM ?).*
|
||||
|
||||
---
|
||||
|
||||
## Bloc D — Contraintes pratiques (4 questions)
|
||||
|
||||
### D1. Ton poste c'est quoi exactement ? Windows 10 ou 11 ? Portable ou fixe ? Un ou deux écrans ?
|
||||
|
||||
*Pourquoi on pose cette question : Léa tourne sur Windows 10/11. Si c'est un
|
||||
vieux Windows 7 ou un thin client sans OS local, on a un problème. Le nombre
|
||||
d'écrans change la captation (multi-screen = plus complexe).*
|
||||
|
||||
### D2. Ton DPI urgences, il tourne **en local sur le poste** ou via Citrix / VDI / bureau à distance ?
|
||||
|
||||
*Pourquoi on pose cette question : **question cruciale**. Si c'est Citrix, on
|
||||
est en zone 100 % vision sans accessibility tree. C'est notre cas nominal mais
|
||||
il faut s'y préparer (latence serveur +50-100 ms). Si c'est natif, démo plus
|
||||
fluide.*
|
||||
|
||||
### D3. Est-ce qu'il y a un antivirus corporate agressif sur ton poste (Kaspersky, Sophos, Cortex XDR, Defender ATP) ?
|
||||
|
||||
*Pourquoi on pose cette question : certains AV bloquent les outils qui
|
||||
capturent l'écran ou simulent clavier/souris. Il faut prévoir une exception
|
||||
IT **avant** l'installation, pas le jour de la démo.*
|
||||
|
||||
### D4. Pour la démo, on veut travailler sur des **données fictives anonymisées**, jamais de vrais patients. Tu peux nous préparer 5-10 dossiers urgences fictifs représentatifs, ou on doit les créer ensemble ?
|
||||
|
||||
*Pourquoi on pose cette question : **RGPD non négociable**. Même si la TIM dit
|
||||
"y a pas de souci, on prend des vrais", on refuse. Montrer en démo un vrai
|
||||
dossier = fin de carrière commerciale. Il faut des dossiers fictifs, mais
|
||||
réalistes (pathos plausibles, codes cohérents, chiffres crédibles).*
|
||||
|
||||
---
|
||||
|
||||
## Questions bonus si on a le temps (à piocher)
|
||||
|
||||
- **Depuis combien de temps tu fais ce métier ?** (crédibilise la démo si
|
||||
elle est expérimentée)
|
||||
- **Vous êtes combien de TIM dans l'établissement ?** (volume de déploiement)
|
||||
- **Ton DIM (Département Information Médicale) connaît combien tu récupères
|
||||
par an sur les urgences ?** (pour calibrer le pitch ROI)
|
||||
- **Y a-t-il déjà eu une tentative d'automatisation sur ton poste (UiPath, AA,
|
||||
BluePrism, macro Excel) ?** (pour anticiper le "on a déjà essayé, ça a pas
|
||||
marché" du DG)
|
||||
|
||||
---
|
||||
|
||||
## Après l'entretien — synthèse en 10 minutes
|
||||
|
||||
À remplir **tout de suite après**, pendant que c'est frais :
|
||||
|
||||
- [ ] Workflow démo choisi : __________________________________________
|
||||
- [ ] Nombre d'étapes : _______
|
||||
- [ ] DPI principal : _______
|
||||
- [ ] Outils annexes : _______
|
||||
- [ ] Chiffre ROI par dossier : ___ €
|
||||
- [ ] Volume annuel extrapolable : ___ dossiers/an/clinique → ___ €/an
|
||||
- [ ] Citrix/VDI : Oui / Non
|
||||
- [ ] Antivirus à faire débloquer : _______
|
||||
- [ ] Histoire vraie utilisable en ouverture : _______________________
|
||||
- [ ] TIM invitée à la démo du 26 avril : Oui / Non / Peut-être
|
||||
- [ ] Date de tournage de la **vidéo de backup** (au cas où démo live plante) :
|
||||
_______
|
||||
|
||||
---
|
||||
|
||||
## Règles d'or pendant l'interview
|
||||
|
||||
1. **Ne pas lui vendre Léa.** On écoute, on ne pitch pas. Elle nous parlera
|
||||
plus librement si elle ne se sent pas en position de cliente.
|
||||
2. **Ne pas promettre qu'on va faire X.** On dit "on regarde si c'est faisable",
|
||||
jamais "Léa le fera en démo" — tant qu'on ne l'a pas testé.
|
||||
3. **Prendre des notes écrites.** Pas d'enregistrement audio sans accord
|
||||
écrit (RGPD).
|
||||
4. **Demander si elle est partante pour la démo en présentiel.** Sa présence
|
||||
à Paris/Lyon le 26 avril vaut 10 slides.
|
||||
5. **Lui demander son retour sincère.** À la fin : "Tu nous trouves
|
||||
crédibles ? Qu'est-ce qui te rendrait sceptique à la place des DG ?"
|
||||
259
docs/demo/GUIDE_INSTALL_AGENT_TIM.md
Normal file
259
docs/demo/GUIDE_INSTALL_AGENT_TIM.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# GUIDE D'INSTALLATION — Agent Léa sur poste TIM
|
||||
|
||||
**Public** : TIM (Technicienne Information Médicale) ou son service informatique.
|
||||
**Durée cible** : **10 minutes** (hors téléchargement).
|
||||
**Prérequis** : Windows 10/11, compte avec droits utilisateur standard (pas besoin d'admin sauf étape Python), accès Internet, **DPI urgences fonctionnel** sur le poste.
|
||||
|
||||
> **Avant de commencer** : vérifier que la TIM peut ouvrir son DPI urgences
|
||||
> habituel (Resurgences, Urqual, DxCare, CristalNet, Hôpital Manager…) et y
|
||||
> naviguer normalement. Si le DPI passe par Citrix/VDI, le vérifier avant
|
||||
> d'installer Léa. **Si le DPI ne marche pas, l'agent ne servira à rien.**
|
||||
|
||||
---
|
||||
|
||||
## Pourquoi pas un installeur `.exe` ?
|
||||
|
||||
On livre un **ZIP + scripts**, pas un installeur Inno Setup. Raison : un `.exe`
|
||||
non signé (code-signing EV à 500 €/an) déclenche le SmartScreen rouge Windows
|
||||
("Windows a protégé votre PC") + l'antivirus corporate. Sur le poste d'une TIM
|
||||
en clinique, **c'est la pire première impression possible**.
|
||||
|
||||
L'approche ZIP + `.bat` passe sous le radar du SmartScreen et s'installe dans
|
||||
le dossier utilisateur (pas besoin d'admin pour le copier).
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Récupérer le package (1 min)
|
||||
|
||||
1. Télécharger `Lea_v1.0.0.zip` depuis l'URL fournie par Dom
|
||||
(lien Owncloud interne ou clé USB si réseau isolé).
|
||||
2. Fichier attendu : environ **5 Mo**.
|
||||
3. **Vérifier l'intégrité** : clic droit sur le ZIP → Propriétés → si un bouton
|
||||
"Débloquer" est visible en bas, le cocher puis OK (sinon Windows peut
|
||||
bloquer l'exécution des `.bat`).
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Extraire dans `C:\rpa_vision\Lea\` (1 min)
|
||||
|
||||
1. Créer le dossier **`C:\rpa_vision\Lea\`** (ou `C:\Lea\` si l'admin préfère).
|
||||
2. Clic droit sur `Lea_v1.0.0.zip` → **Extraire tout…** → choisir
|
||||
`C:\rpa_vision\Lea\` → Extraire.
|
||||
3. Vérifier que le dossier contient :
|
||||
|
||||
```
|
||||
C:\rpa_vision\Lea\
|
||||
├── install.bat
|
||||
├── Lea.bat
|
||||
├── config.txt
|
||||
├── LISEZMOI.txt
|
||||
├── requirements_agent.txt
|
||||
└── run_agent_v1.py
|
||||
```
|
||||
|
||||
> **Piège** : si Windows extrait un dossier intermédiaire
|
||||
> (`Lea_v1.0.0\Lea_v1.0.0\...`), **déplacer le contenu d'un cran** pour que
|
||||
> `install.bat` soit à la racine `C:\rpa_vision\Lea\`.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Vérifier Python (1 min)
|
||||
|
||||
Ouvrir une invite de commandes (`Démarrer` → taper `cmd` → Entrée) et taper :
|
||||
|
||||
```
|
||||
python --version
|
||||
```
|
||||
|
||||
- **Si ça affiche** `Python 3.10.x` à `3.12.x` → OK, passer à l'étape 4.
|
||||
- **Si erreur** `'python' n'est pas reconnu` → installer Python :
|
||||
1. Aller sur https://www.python.org/downloads/
|
||||
2. Télécharger Python 3.12.x
|
||||
3. **Important** : pendant l'installation, **cocher
|
||||
"Add Python to PATH"** (case du bas, souvent décochée).
|
||||
4. Terminer, fermer/rouvrir l'invite de commandes, re-tester
|
||||
`python --version`.
|
||||
|
||||
> Python installé en "Microsoft Store" fonctionne aussi mais peut poser des
|
||||
> soucis de PATH. Si ça bloque, désinstaller la version Store et installer
|
||||
> celle de python.org.
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Configurer `config.txt` (2 min)
|
||||
|
||||
Ouvrir `C:\rpa_vision\Lea\config.txt` **avec le Bloc-notes**
|
||||
(clic droit → "Ouvrir avec" → Bloc-notes).
|
||||
|
||||
Vérifier/modifier ces 3 lignes :
|
||||
|
||||
```
|
||||
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
|
||||
RPA_API_TOKEN=<TOKEN_FOURNI_PAR_DOM>
|
||||
RPA_SERVER_HOST=lea.labs.laurinebazin.design
|
||||
```
|
||||
|
||||
- **URL du serveur** : celle de prod publique OU une URL interne si on bascule
|
||||
sur un serveur clinique pendant la démo.
|
||||
- **Token** : Dom fournit un token **dédié à la TIM** (révocable). Ne pas
|
||||
réutiliser le token de dev.
|
||||
- **Sauvegarder** (Ctrl+S), fermer.
|
||||
|
||||
> Ne **pas** toucher aux autres lignes (`RPA_BLUR_SENSITIVE`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Lancer `install.bat` (3-4 min)
|
||||
|
||||
1. Double-cliquer sur `install.bat`.
|
||||
2. Une fenêtre noire s'ouvre avec le titre "Lea - Installation".
|
||||
3. Écrans attendus dans l'ordre :
|
||||
|
||||
```
|
||||
[1/5] Verification de Python...
|
||||
Python 3.12.2 detecte - OK
|
||||
[2/5] Creation de l'environnement isole...
|
||||
Environnement cree - OK
|
||||
[3/5] Activation de l'environnement...
|
||||
Active - OK
|
||||
[4/5] Installation des composants (cela peut prendre 1-2 min)...
|
||||
Composants installes - OK
|
||||
[5/5] Configuration Windows...
|
||||
Configuration terminee - OK
|
||||
Tous les composants sont OK !
|
||||
Installation terminee !
|
||||
```
|
||||
|
||||
4. Appuyer sur une touche pour fermer.
|
||||
|
||||
> **Durée typique** : 2-3 minutes (dépend de la bande passante — `pip install`
|
||||
> télécharge ~50 Mo).
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 — Lancer Léa (30 s)
|
||||
|
||||
1. Double-cliquer sur `Lea.bat`.
|
||||
2. Une fenêtre noire affiche :
|
||||
|
||||
```
|
||||
Demarrage de Lea...
|
||||
(Lea apparait dans la barre des taches, en bas a droite)
|
||||
```
|
||||
|
||||
3. **Au bout de 3-5 secondes**, une **icône ronde** apparaît dans la barre
|
||||
des tâches (en bas à droite de l'écran, à côté de l'horloge, parfois
|
||||
cachée sous la flèche `^`).
|
||||
4. **Clic droit sur l'icône** → menu :
|
||||
- Apprenez-moi une tâche
|
||||
- C'est terminé
|
||||
- Discuter avec Léa
|
||||
- ARRÊT D'URGENCE
|
||||
- Quitter Léa
|
||||
|
||||
Si l'icône apparaît et que le menu s'ouvre → **installation réussie**.
|
||||
|
||||
> La fenêtre noire peut être fermée. L'agent tourne en arrière-plan via
|
||||
> `pythonw.exe`.
|
||||
|
||||
---
|
||||
|
||||
## Étape 7 — Vérifier côté dashboard (1 min)
|
||||
|
||||
Côté Dom, depuis un navigateur :
|
||||
|
||||
```
|
||||
GET https://lea.labs.laurinebazin.design/api/v1/agents/fleet
|
||||
Header: Authorization: Bearer <TOKEN_ADMIN>
|
||||
```
|
||||
|
||||
La réponse doit contenir une entrée avec :
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "<nom_du_poste_TIM>",
|
||||
"status": "online",
|
||||
"last_seen": "2026-04-...",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
Si le poste n'apparaît pas après 30 s, voir la section "Si ça plante" ci-dessous.
|
||||
|
||||
Alternative : ouvrir le dashboard web (`http://<serveur>:5001`) → onglet
|
||||
"Flotte" → le poste de la TIM doit s'afficher en vert.
|
||||
|
||||
---
|
||||
|
||||
## Si ça plante (5 cas les plus probables)
|
||||
|
||||
### 1. "Token invalide" / erreur 401 dans les logs
|
||||
|
||||
- Vérifier que `RPA_API_TOKEN` dans `config.txt` **ne contient pas d'espace**
|
||||
en début/fin, **pas de guillemets**, pas de retour à la ligne.
|
||||
- Le format attendu : `RPA_API_TOKEN=abc123def...` (sans espace autour du `=`).
|
||||
- Si le token a été copié-collé depuis un mail, il peut contenir un caractère
|
||||
invisible. **Retaper à la main** ou recopier depuis un éditeur brut.
|
||||
|
||||
### 2. "Python n'est pas installé" alors qu'il l'est
|
||||
|
||||
- `python --version` marche dans un cmd "normal" mais `install.bat` le trouve
|
||||
pas → Python est installé **pour l'utilisateur** mais la TIM lance le `.bat`
|
||||
en mode admin (session différente).
|
||||
- **Solution** : double-cliquer sur `install.bat` en user normal, PAS en
|
||||
"Exécuter en tant qu'administrateur".
|
||||
|
||||
### 3. Firewall bloque la connexion au serveur
|
||||
|
||||
- Au premier lancement, Windows Defender peut demander
|
||||
"Autoriser pythonw.exe à communiquer sur le réseau ?" → **Autoriser**.
|
||||
- Si firewall corporate plus strict : demander au service IT d'autoriser
|
||||
les connexions sortantes vers `lea.labs.laurinebazin.design:443` (HTTPS).
|
||||
- Test rapide depuis le poste : ouvrir un navigateur sur
|
||||
`https://lea.labs.laurinebazin.design/health` → doit répondre `{"status":"ok"}`.
|
||||
|
||||
### 4. Antivirus bloque `pythonw.exe`
|
||||
|
||||
- Certains AV corporate (Kaspersky, Sophos, Cortex XDR) mettent `pythonw.exe`
|
||||
en quarantaine dès qu'il capture l'écran.
|
||||
- Symptôme : `Lea.bat` affiche "Lea n'a pas demarre correctement" et le mode
|
||||
verbeux montre un `pythonw.exe n'a pas pu se lancer` ou rien du tout.
|
||||
- **Solution** : demander au service IT d'ajouter **une exception pour le
|
||||
dossier** `C:\rpa_vision\Lea\.venv\Scripts\` (et pas juste pour `pythonw.exe`
|
||||
globalement — ce serait une faille de sécurité).
|
||||
|
||||
### 5. Double-clic sur `Lea.bat` ouvre le Notepad
|
||||
|
||||
- Cause classique : la TIM a fait clic droit → "Ouvrir avec…" → "Toujours
|
||||
utiliser Notepad" une fois par erreur. Windows a associé `.bat` à Notepad.
|
||||
- **Solution** :
|
||||
1. Clic droit sur `Lea.bat` → "Ouvrir avec" → "Choisir une autre application"
|
||||
→ "Plus d'applications" → "Rechercher une autre app sur ce PC"
|
||||
→ `C:\Windows\System32\cmd.exe` → **ne PAS cocher** "Toujours utiliser".
|
||||
2. Ou alternative rapide : ouvrir un cmd, taper
|
||||
`cd C:\rpa_vision\Lea` puis `Lea.bat`.
|
||||
|
||||
---
|
||||
|
||||
## Désinstaller Léa (si besoin)
|
||||
|
||||
1. Clic droit sur l'icône Léa → "Quitter Léa".
|
||||
2. Supprimer le dossier `C:\rpa_vision\Lea\`.
|
||||
3. (Optionnel) Supprimer les logs dans `%LOCALAPPDATA%\Lea\` si existant.
|
||||
|
||||
Pas de désinstalleur, pas de clé registre, pas de service : Léa est un **binaire
|
||||
portable**. C'est voulu : aucune trace système, facile à auditer.
|
||||
|
||||
---
|
||||
|
||||
## Check final avant la démo (à faire ensemble avec la TIM)
|
||||
|
||||
- [ ] Icône Léa visible dans la tray.
|
||||
- [ ] Clic droit → menu s'ouvre.
|
||||
- [ ] Dashboard côté serveur affiche le poste en "online".
|
||||
- [ ] Le DPI urgences de la TIM s'ouvre et répond normalement (pas de lenteur).
|
||||
- [ ] Démo d'enregistrement de 30 s : clic droit → "Apprenez-moi une tâche"
|
||||
→ faire 2-3 clics → clic droit → "C'est terminé". Vérifier côté serveur
|
||||
que la session arrive.
|
||||
|
||||
**Si tout est vert → on est prêts pour le 26 avril.**
|
||||
284
docs/demo/SCENARIOS_DEMO_URGENCES.md
Normal file
284
docs/demo/SCENARIOS_DEMO_URGENCES.md
Normal file
@@ -0,0 +1,284 @@
|
||||
**SCÉNARIOS DE DÉMO — Urgences (26 avril 2026)**
|
||||
**Contexte** : 10-20 DG/DSI de groupements de cliniques, dont plusieurs
|
||||
|
||||
RPA-experts (UiPath, Automation Anywhere). Pitch duo Amina + Dom.
|
||||
**Cadre narratif** : Amina a prouvé manuellement 150 k€/mois de
|
||||
|
||||
récupération PMSI urgences par clinique. **Léa est le scaler de cette**
|
||||
**
|
||||
méthode prouvée** — pas une techno RPA de plus.
|
||||

|
||||
**Critères d'évaluation (grille commune aux 3 scénarios)**
|
||||
| | | |
|
||||
|-|-|-|
|
||||
| **Critère** | **Description** | **Note** |
|
||||
| Impact émotionnel DG | Est-ce que ça fait lever les sourcils ? | /5 |
|
||||
| Faisabilité technique | On sait le faire aujourd'hui sans rustine ? | /5 |
|
||||
| Risque démo live | Probabilité que ça plante devant 20 personnes | /5 (5 = très risqué) |
|
||||
| Reproductibilité | On peut le refaire dans 3 pilotes différents | /5 |
|
||||
| Crédibilité ROI | Le chiffre annoncé est défendable si un DAF challenge | /5 |
|
||||
|
||||

|
||||
**Scénario A — "L'audit rétrospectif des actes oubliés"**
|
||||
**Titre pitchable**
|
||||
**"Récupération de 15 000 € d'actes oubliés en 5 minutes — sur un lot de**
|
||||
**
|
||||
30 dossiers urgences de la semaine."**
|
||||
**Durée démo live**
|
||||
**5 minutes** (+ 2 min de commentaire par Amina sur le chiffrage).
|
||||
**Pitch en une phrase**
|
||||
Léa relit les dossiers urgences de la semaine écoulée, détecte les actes
|
||||
|
||||
cliniques non codés, propose les corrections, chiffre le gain PMSI.
|
||||
|
||||
|
||||
**Étapes de la démo**
|
||||
1. **[Dom, 10 s]** Ouvre l'interface Léa (dashboard). Montre un dossier
|
||||
|
||||
d'entrée : "30 passages urgences du 15 au 19 avril, à auditer."
|
||||
2. **[Amina, 15 s]** "Normalement, c'est ce que je fais moi-même, à la main,
|
||||
|
||||
en une après-midi. Je vais vous montrer ce que Léa fait en 3 minutes."
|
||||
3. **[Dom, 3 min]** Lance Léa en mode "audit rétrospectif". À l'écran :
|
||||
|
||||
Léa ouvre le DPI (Resurgences en natif ou en vidéo captée), parcourt
|
||||
|
||||
chaque dossier, lit le compte-rendu médical, compare aux actes cotés.
|
||||
|
||||
Dans une side-pane dashboard, on voit apparaître en temps réel :
|
||||
- Dossier 1 : ✅ OK
|
||||
- Dossier 2 : ⚠️ ECG mentionné dans CR, non codé → +42 €
|
||||
- Dossier 3 : ⚠️ Suture complexe codée comme simple → +78 €
|
||||
- Dossier 4 : ✅ OK
|
||||
- ... (avec un compteur ROI qui monte)
|
||||
4. **[Amina, 30 s]** Commentaire pendant que ça défile : "Regardez, ça, c'est
|
||||
|
||||
exactement ce que j'aurais vu. Et là, on est à 14 800 € de récupération
|
||||
|
||||
sur **une semaine**."
|
||||
5. **[Dom, 30 s]** Fin de l'audit. Léa affiche un rapport final : 12 actes
|
||||
|
||||
oubliés, 3 erreurs de cotation, total **14 850 €**. Export CSV + mail
|
||||
|
||||
automatique au médecin urgentiste pour validation.
|
||||
6. **[Amina, 60 s, closing du scénario]** "Sur un volume annuel de 50 000
|
||||
|
||||
passages urgences par clinique — un groupement de 10 cliniques c'est
|
||||
**plus de 8 M€/an** de valorisation récupérable. Et on ne parle que des
|
||||
|
||||
urgences."
|
||||
**Chiffre clé à afficher**
|
||||
- **14 850 €** récupérés sur 30 dossiers = **495 €/dossier en moyenne**
|
||||
- Projection : **150 000 €/mois/clinique** (la preuve Amina, sans IA, donc
|
||||
borne inférieure)
|
||||
- Groupement 10 cliniques = **18 M€/an de potentiel**
|
||||
|
||||
|
||||
**Prérequis**
|
||||
- **Corpus de 30 dossiers fictifs** réalistes — à préparer avec la TIM
|
||||
- Léa connaît les patterns de codage PMSI urgences (prompts métier déjà
|
||||
|
||||
chargés)
|
||||
- Dashboard avec side-pane "audit en cours" prête
|
||||
- Vidéo de backup enregistrée **avant la démo** (au cas où)
|
||||
**Risques live & mitigation**
|
||||
| | | |
|
||||
|-|-|-|
|
||||
| **Risque** | **Proba** | **Mitigation** |
|
||||
| Léa rate un code évident | Moyenne | Préalable : **tourner le scénario 5× à l'avance**, fixer le corpus |
|
||||
| Latence serveur sur Citrix | Moyenne | Basculer sur DPI natif local pour la démo |
|
||||
| Amina coupe Dom pour commenter trop tôt | Élevée | **Répétition en binôme la veille** |
|
||||
| Un DG dit "vous avez scripté ça" | Haute | Proposer un **pilote 2 semaines chez lui** tout de suite |
|
||||
|
||||
**Notes**
|
||||
- **Scénario préféré** : très visuel, chiffrage direct, colle au narratif
|
||||
|
||||
Amina, risque maîtrisable.
|
||||
- Avantage : la partie "Léa lit/compare" peut être accélérée en post-prod
|
||||
|
||||
si on passe sur une vidéo backup.
|
||||
- Limite : ne montre pas la capacité **autonomous** de Léa (elle ne clique
|
||||
|
||||
pas pour valider, elle propose). À compléter éventuellement par B ou C.
|
||||

|
||||
**Scénario B — "L'assistant temps réel"**
|
||||
**Titre pitchable**
|
||||
**"Pendant que la TIM code un dossier qui vient de sortir, Léa regarde**
|
||||
**
|
||||
par-dessus son épaule et signale ce qu'elle oublie."**
|
||||
|
||||
|
||||
**Durée démo live**
|
||||
**3 minutes**.
|
||||
**Pitch en une phrase**
|
||||
La TIM code normalement. Léa observe l'écran, compare au CR médical, et
|
||||
|
||||
pop-up une alerte quand elle détecte un acte manquant.
|
||||
**Étapes de la démo**
|
||||
1. **[Dom, 15 s]** "La TIM (ou un intervenant qui joue la TIM — idéalement
|
||||
|
||||
la vraie TIM si elle est venue) ouvre un dossier. Léa tourne en arrière-
|
||||
|
||||
plan."
|
||||
2. **[TIM, 90 s]** Code un dossier comme d'habitude. Amina commente ce
|
||||
|
||||
qu'elle fait ("elle ouvre le CR, elle regarde les actes, elle saisit…").
|
||||
3. **[Pop-up Léa, 5 s]** Dans le coin, une bulle apparaît :
|
||||
|
||||
"Acte probablement manquant : monitoring cardiaque (mentionné ligne 3
|
||||
|
||||
du CR) — +28 €. Confirmer ?"
|
||||
4. **[TIM, 15 s]** Clique "Confirmer". Léa ajoute le code dans le DPI
|
||||
|
||||
(ou propose le code et la TIM le saisit — à choisir selon niveau de
|
||||
|
||||
risque).
|
||||
5. **[Amina, 45 s, closing]** "En temps réel. Pas de batch. Pas de
|
||||
|
||||
vérification rétrospective. La TIM garde la main, Léa est le filet."
|
||||
**Chiffre clé à afficher**
|
||||
- **80 % des actes oubliés** détectés en temps réel
|
||||
- **+8 à 12 min par dossier économisées** (pas besoin de revenir dessus
|
||||
J+1)
|
||||
- "Un filet de sécurité sur chaque dossier"
|
||||
|
||||
|
||||
|
||||
|
||||
**Prérequis**
|
||||
- Un dossier fictif avec **au moins un acte clairement détectable** (ECG ou
|
||||
|
||||
monitoring)
|
||||
- La pop-up Léa visuellement propre (pas de dialog Windows moche)
|
||||
- Couplage visuel OCR du CR ↔ interface de saisie (à tester spécifiquement)
|
||||
- Latence < 5 secondes entre le moment où la TIM est sur le bon écran et la
|
||||
|
||||
pop-up Léa
|
||||
**Risques live & mitigation**
|
||||
| | | |
|
||||
|-|-|-|
|
||||
| **Risque** | **Proba** | **Mitigation** |
|
||||
| Pop-up n'apparaît pas / trop tard | **Haute** | Fixer un déclencheur manuel de secours (Amina dit "et regardez, Léa a détecté…") |
|
||||
| Faux positif en direct | Moyenne | Trigger garde-fou : seuil de confidence > 0.8 |
|
||||
| TIM stressée, perd ses moyens | Moyenne | **Répétition 3× la veille** si la TIM joue live |
|
||||
| Écran capturé mal rendu au projecteur | Haute | Test projecteur 1h avant, résolution fixe |
|
||||
|
||||
**Notes**
|
||||
- Effet "magie" maximum si ça marche.
|
||||
- **Risque de plantage > scénario A.** À faire en **deuxième position**, pas
|
||||
|
||||
en ouverture.
|
||||
- Peut être joué en "complément" du A (audit rétrospectif, puis "et en
|
||||
|
||||
temps réel, voici ce que Léa fait aussi"), en 2 min flat.
|
||||
- Si la TIM est venue à la démo : énorme plus-value émotionnelle (elle
|
||||
|
||||
raconte elle-même). Si elle n'est pas là, Dom ou Amina joue son rôle,
|
||||
|
||||
ce qui est moins crédible.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||

|
||||
**Scénario C — "Le codeur autonome"**
|
||||
**Titre pitchable**
|
||||
**"Léa code seule un dossier urgence complet — admission, examens, actes,**
|
||||
**
|
||||
sortie — en 90 secondes. La TIM valide, c'est parti au PMSI."**
|
||||
**Durée démo live**
|
||||
**7 minutes** (2 min setup + 90 s exécution Léa + 3 min commentaires +
|
||||
|
||||
30 s validation).
|
||||
**Pitch en une phrase**
|
||||
Léa prend un CR brut d'urgence, ouvre le DPI, navigue dans les écrans,
|
||||
|
||||
remplit les champs RUM + RPU, valide le codage PMSI. Humain en superviseur.
|
||||
**Étapes de la démo**
|
||||
1. **[Dom, 30 s]** Montre le CR en entrée : texte brut (1 page).
|
||||
2. **[Amina, 30 s]** "Normalement, coder ce dossier me prend 4-5 minutes.
|
||||
|
||||
Regardez Léa."
|
||||
3. **[Dom, 90 s]** Lance Léa en mode autonomous. À l'écran :
|
||||
- Léa ouvre le DPI (clic sur l'icône du bureau)
|
||||
- Navigue dans le menu "nouveau passage"
|
||||
- Saisit nom, prénom, date de naissance (issus du CR)
|
||||
- Remplit motif d'entrée (CIM-10 auto depuis CR)
|
||||
- Navigue vers "actes réalisés", cote chaque acte
|
||||
- Remplit diagnostic principal + associés
|
||||
- Clique "Enregistrer" → dialogue de validation
|
||||
- **S'arrête** sur la validation finale
|
||||
4. **[Amina, 30 s]** "Léa s'arrête ici **volontairement**. C'est la TIM qui
|
||||
|
||||
valide. On garde l'humain dans la boucle."
|
||||
5. **[TIM ou Dom, 15 s]** Valide → message "RUM/RPU envoyés au PMSI".
|
||||
6. **[Amina, 2 min, closing]** "4 minutes économisées par dossier.
|
||||
|
||||
50 dossiers/jour. 10 cliniques. Faites le calcul." (et elle projette.)
|
||||
|
||||
|
||||
**Chiffre clé à afficher**
|
||||
- **90 s vs 4 minutes** (division par 2.5 à 3 du temps)
|
||||
- **Sur un volume de 50 k passages/an** : 3 300 heures TIM économisées/an
|
||||
- Projection ROI : dépend du chargé TIM (60 k€/an chargé = **~160 k€ de**
|
||||
**temps dégagé/clinique/an**, hors récupération PMSI)
|
||||
**Prérequis**
|
||||
- Workflow **très** répété, testé 20 fois au moins avant la démo
|
||||
- DPI cible **fixé et gelé** (pas de mise à jour 48h avant)
|
||||
- Mode autonomous Léa stable (voir Phase 3 roadmap : probablement **pas**
|
||||
**
|
||||
encore prêt le 26 avril**)
|
||||
- Vidéo de backup non négociable
|
||||
- Plan B : passer en "Léa remplit les champs un à un, la TIM valide
|
||||
|
||||
étape par étape" (demi-autonomous, moins risqué)
|
||||
**Risques live & mitigation**
|
||||
| | | |
|
||||
|-|-|-|
|
||||
| **Risque** | **Proba** | **Mitigation** |
|
||||
| Léa rate un clic au milieu | **Très haute** | Vidéo de backup + plan B demi-autonomous |
|
||||
| DPI a changé d'UI depuis la capture | Haute | Freeze DPI version 48h avant |
|
||||
| Timing perçu comme "lent" par le public | Moyenne | Accélérer en post-prod (si vidéo) |
|
||||
| Question acerbe d'un RPA-expert sur l'UI drift | Haute | Réponse cadrée (cf. FAQ, question "UI qui change") |
|
||||
| Dom stressé et Léa refuse de démarrer | Moyenne | 15 min de setup tranquille avant + test final 5 min avant |
|
||||
|
||||
**Notes**
|
||||
- **Le plus spectaculaire**, mais aussi **le plus risqué**.
|
||||
- **À GARDER POUR PLUS TARD** — début juin, voire fin mai. Le 26 avril,
|
||||
|
||||
Léa en full autonomous devant des RPA-experts = roulette russe.
|
||||
- Option : montrer ce scénario **en vidéo enregistrée** en bonus (2 min),
|
||||
pas en live. On garde l'impact sans le risque.
|
||||
|
||||

|
||||
|
||||
**Recommandation**
|
||||
**Plan proposé pour la démo du 26 avril**
|
||||
1. **Ouverture Amina** (2 min, storytelling 150 k€/mois urgences).
|
||||
2. **Démo principale = scénario A** (audit rétrospectif, 5 min).
|
||||
3. **Bonus = scénario B** (assistant temps réel, 3 min), **uniquement si**
|
||||
**
|
||||
la TIM est présente et à l'aise**. Sinon on saute.
|
||||
4. **Teaser = scénario C en vidéo** (2 min, "voilà ce qu'on déploiera en
|
||||
|
||||
pilote"), pas en live.
|
||||
5. **Closing Amina** (3 min, ROI projetté, appel à pilote).
|
||||
**Pourquoi ce plan**
|
||||
- **A en premier** : visuel, chiffré, quasi zéro risque live, parle
|
||||
directement aux DG.
|
||||
- **B en bonus** : effet "waouh" si on a les billes, skipable sinon.
|
||||
- **C en vidéo** : montre l'ambition/roadmap sans se prendre un plantage
|
||||
en pleine figure.
|
||||
- **Amina bookends** : c'est elle qui ouvre et ferme. Elle est la crédibilité
|
||||
métier. Dom est l'exécution.
|
||||
**Question ouverte à trancher**
|
||||
**Est-ce qu'on invite la TIM à la démo du 26 avril ?**
|
||||
- Oui = scénario B devient solide, mais +1 logistique (transport, hôtel,
|
||||
|
||||
briefing, déblocage de sa journée avec la clinique).
|
||||
- Non = on joue tous les scénarios en simulation, narratif un peu moins fort.
|
||||
- **À décider avec Amina demain matin** en fonction de son feeling
|
||||
|
||||
sur la TIM pendant l'interview.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user