Compare commits
51 Commits
sp4/trajec
...
882e4e1f3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882e4e1f3a | ||
|
|
cac965cef9 | ||
|
|
ebed4d7546 | ||
|
|
9a8242add5 | ||
|
|
f9a0531325 | ||
|
|
ab78ae390a | ||
|
|
e59489e2cd | ||
|
|
86e31ada34 | ||
|
|
94fd93ad19 | ||
|
|
50f34b5727 | ||
|
|
a1b3062991 | ||
|
|
a210e5ee32 | ||
|
|
5d235e49f1 | ||
|
|
e679804cfd | ||
|
|
e57b54a100 | ||
|
|
d34c1f2697 | ||
|
|
61664c9a36 | ||
|
|
9ab5ed4671 | ||
|
|
144a5c288a | ||
|
|
e3f61de4ad | ||
|
|
2a1b1ed80e | ||
|
|
f09b8b8cfd | ||
|
|
6a78a0059b | ||
|
|
813b33b47e | ||
|
|
a50057d499 | ||
|
|
3ed9798f06 | ||
|
|
b65710ae43 | ||
|
|
509a026cfc | ||
|
|
a62b720144 | ||
|
|
14b1bf844a | ||
|
|
c82829f2bb | ||
|
|
6075717353 | ||
|
|
13f760a3b9 | ||
|
|
9883cad012 | ||
|
|
5ed5ae2d4b | ||
|
|
7fb58195fb | ||
|
|
fccc06e4a2 | ||
|
|
6461f0a21b | ||
|
|
e84cdee393 | ||
|
|
30d8f65e9a | ||
|
|
8e4d09594c | ||
|
|
46ad5973d1 | ||
|
|
4a38000e74 | ||
|
|
2597ca9110 | ||
|
|
bbe897e614 | ||
|
|
a29b7a2f21 | ||
|
|
105ade959d | ||
|
|
29cb466595 | ||
|
|
de73cbd404 | ||
|
|
1b491326be | ||
|
|
3b592dd867 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -126,7 +126,14 @@ tools/codex_windows_correction_rapport.py
|
||||
docs/clients/
|
||||
|
||||
.qw-baseline.log
|
||||
# Coordination ephemeral — inbox messages, active decisions, loop state
|
||||
docs/coordination/.loop_state/
|
||||
docs/coordination/.inbox_baseline.txt
|
||||
docs/coordination/.loop_log.txt
|
||||
docs/coordination/inbox_qwen/
|
||||
docs/coordination/inbox_codex/
|
||||
docs/coordination/inbox_claude/
|
||||
docs/coordination/active/
|
||||
|
||||
# Runtime Python embedded pour l'installateur Inno Setup (local, ~11M, non versionné)
|
||||
deploy/installer/python-3.12-embed/
|
||||
|
||||
@@ -27,7 +27,7 @@ if platform.system() == "Windows":
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
AGENT_VERSION = "1.0.1"
|
||||
AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.2")
|
||||
|
||||
# Identifiant unique de la machine (utilisé pour le multi-machine)
|
||||
# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS
|
||||
@@ -82,6 +82,38 @@ BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true"
|
||||
# Configurable via variable d'environnement pour permettre l'ajustement
|
||||
LOG_RETENTION_DAYS = int(os.environ.get("RPA_LOG_RETENTION_DAYS", "180"))
|
||||
|
||||
# Remontée automatique des logs vers le serveur (push-log-DGX).
|
||||
# Diagnostic des postes clinique SANS AnyDesk : les logs (déjà écrits sur disque)
|
||||
# sont poussés au serveur, rangés par machine_id, consultables au dashboard.
|
||||
# Défaut PRUDENT = désactivé : on l'active poste par poste via config.txt /
|
||||
# variable d'environnement, sans rebuild de l'installateur.
|
||||
LOG_SHIP_ENABLED = os.environ.get("RPA_LOG_SHIP_ENABLED", "false").lower() in (
|
||||
"true", "1", "yes",
|
||||
)
|
||||
# Intervalle de flush du buffer de logs (secondes).
|
||||
LOG_SHIP_INTERVAL_S = float(os.environ.get("RPA_LOG_SHIP_INTERVAL_S", "30"))
|
||||
|
||||
# Mise à jour silencieuse du client Léa (DETTE-022 v2).
|
||||
# Le client interroge le serveur (GET /api/v1/agents/update/check), télécharge
|
||||
# le ZIP en staging et vérifie le SHA256. Le SWAP réel des fichiers / l'édition
|
||||
# de Lea.bat / le redémarrage restent RÉSERVÉS RÉVISION HUMAINE (voir
|
||||
# network/updater.py : stubs apply_update / write_boot_ok_marker).
|
||||
# Défaut PRUDENT = désactivé : activé poste par poste via config.txt / variable
|
||||
# d'environnement, sans rebuild de l'installateur (même esprit que LOG_SHIP).
|
||||
AUTO_UPDATE_ENABLED = os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
|
||||
"true", "1", "yes", "on",
|
||||
)
|
||||
# Intervalle entre deux interrogations serveur pour une MAJ (secondes).
|
||||
# Défaut 1 h : une MAJ n'est jamais urgente ; on interroge peu pour ne pas
|
||||
# charger le réseau clinique. Le check ne fait de toute façon aucun swap.
|
||||
AUTO_UPDATE_INTERVAL_S = float(os.environ.get("RPA_AUTO_UPDATE_INTERVAL_S", "3600"))
|
||||
# Dossier de STAGING des ZIP d'update (jamais les fichiers vivants). Équivalent
|
||||
# de `Lea_next\\`. Sous LOCALAPPDATA en prod Windows, sinon à côté de l'agent.
|
||||
AUTO_UPDATE_STAGING_DIR = os.environ.get(
|
||||
"RPA_AUTO_UPDATE_STAGING_DIR",
|
||||
str(BASE_DIR / "_update_staging"),
|
||||
)
|
||||
|
||||
# Monitoring
|
||||
PERF_MONITOR_INTERVAL_S = 30
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
|
||||
@@ -32,6 +32,7 @@ from pynput.keyboard import Key, KeyCode
|
||||
# Importation relative pour rester dans le module v1
|
||||
from ..vision.capturer import VisionCapturer
|
||||
from ..vision.system_info import get_screen_metadata
|
||||
from .log_safe import _sanitize_metadata
|
||||
# from ..monitoring.system import SystemMonitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -676,7 +677,7 @@ class EventCaptorV1:
|
||||
metadata = get_screen_metadata()
|
||||
with self._screen_metadata_lock:
|
||||
self._screen_metadata = metadata
|
||||
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
|
||||
logger.debug(f"Métadonnées système rafraîchies : {_sanitize_metadata(metadata)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur refresh métadonnées système : {e}")
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Any, Dict, Optional
|
||||
# DPI awareness est configure (SetProcessDpiAwareness(2) sur Windows).
|
||||
# Sans cela, pynput et mss utilisent des coordonnees logiques (virtualisees).
|
||||
from ..config import MACHINE_ID as _ # noqa: F401 — side-effect import
|
||||
from .log_safe import _title_hash
|
||||
|
||||
import mss
|
||||
from pynput.mouse import Button, Controller as MouseController
|
||||
@@ -862,7 +863,7 @@ class ActionExecutorV1:
|
||||
)
|
||||
if handled:
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
|
||||
f"fenetre -> bouton '{button_text}' "
|
||||
f"[{resolved.get('method', 'server')}]"
|
||||
)
|
||||
@@ -890,7 +891,7 @@ class ActionExecutorV1:
|
||||
)
|
||||
if handled:
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
|
||||
f"fenetre -> bouton '{button_text}' [dialog_window_text_template]"
|
||||
)
|
||||
return handled
|
||||
@@ -917,7 +918,7 @@ class ActionExecutorV1:
|
||||
)
|
||||
if handled:
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere par geometrie "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere par geometrie "
|
||||
f"fenetre -> bouton '{button_text}'"
|
||||
)
|
||||
return handled
|
||||
@@ -967,7 +968,7 @@ class ActionExecutorV1:
|
||||
if not handled:
|
||||
continue
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
|
||||
f"-> bouton '{button_text}' [{resolved.get('method', 'server')}]"
|
||||
)
|
||||
return handled
|
||||
@@ -992,13 +993,13 @@ class ActionExecutorV1:
|
||||
if not handled:
|
||||
continue
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
|
||||
f"-> bouton '{button_text}' [dialog_text_template]"
|
||||
)
|
||||
return handled
|
||||
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] Aucun bouton resolu pour '{current_title}'"
|
||||
f"[RUNTIME-DIALOG] Aucun bouton resolu pour [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1258,7 +1259,7 @@ class ActionExecutorV1:
|
||||
|
||||
if dialog_spec.get("skip_current_action_after_handle", False):
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] Dialogue '{current_title}' gere -> "
|
||||
f"[RUNTIME-DIALOG] Dialogue [title_hash={_title_hash(current_title)}] gere -> "
|
||||
f"action {action.get('action_id', 'unknown')} skippée"
|
||||
)
|
||||
return {
|
||||
@@ -1587,7 +1588,7 @@ class ActionExecutorV1:
|
||||
]
|
||||
for pattern in popup_patterns:
|
||||
if pattern in current_title:
|
||||
logger.info(f"Observer : popup détectée par titre — '{current_title}'")
|
||||
logger.info(f"Observer : popup détectée par titre — [title_hash={_title_hash(current_title)}]")
|
||||
# On ne peut pas résoudre les coords juste par le titre
|
||||
# → retourner popup sans coords, le caller fera handle_popup_vlm()
|
||||
return {
|
||||
@@ -1874,8 +1875,8 @@ class ActionExecutorV1:
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[LEA] Fenêtre incorrecte : attendu '{expected_title}', "
|
||||
f"actuel '{current_title}'"
|
||||
f"[LEA] Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
|
||||
f"actuel [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
auto_result = self._maybe_handle_runtime_dialog_before_pause(
|
||||
action=action,
|
||||
@@ -1888,8 +1889,8 @@ class ActionExecutorV1:
|
||||
if auto_result is not None:
|
||||
return auto_result
|
||||
print(
|
||||
f" [PRÉ-VÉRIF] Fenêtre '{current_title}' ≠ "
|
||||
f"attendu '{expected_title}' → mode apprentissage"
|
||||
f" [PRÉ-VÉRIF] Fenêtre [title_hash={_title_hash(current_title)}] ≠ "
|
||||
f"attendu [title_hash={_title_hash(expected_title)}] → mode apprentissage"
|
||||
)
|
||||
try:
|
||||
self.notifier.replay_learning_mode(
|
||||
@@ -1936,8 +1937,8 @@ class ActionExecutorV1:
|
||||
# des coordonnées devenues invalides.
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"Fenêtre incorrecte : attendu '{expected_title}', "
|
||||
f"actuel '{current_title}'"
|
||||
f"Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
|
||||
f"actuel [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
result["warning"] = "wrong_window"
|
||||
result["target_description"] = expected_title
|
||||
@@ -1945,11 +1946,11 @@ class ActionExecutorV1:
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
logger.warning(
|
||||
f"[LEA] Wrong window sans correction → pause "
|
||||
f"(attendu '{expected_title}', actuel '{current_title}')"
|
||||
f"(attendu [title_hash={_title_hash(expected_title)}], actuel [title_hash={_title_hash(current_title)}])"
|
||||
)
|
||||
return result
|
||||
else:
|
||||
logger.info(f"[LEA] Pré-vérif OK : '{current_title}'")
|
||||
logger.info(f"[LEA] Pré-vérif OK : [title_hash={_title_hash(current_title)}]")
|
||||
|
||||
# ── OBSERVER : pré-analyse écran avant résolution ──
|
||||
# Détecte popups, dialogues, états inattendus AVANT de chercher la cible.
|
||||
@@ -1964,8 +1965,8 @@ class ActionExecutorV1:
|
||||
# Popup détectée AVANT la résolution — la fermer
|
||||
popup_label = observation.get("popup_label", "popup")
|
||||
popup_coords = observation.get("popup_coords")
|
||||
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
|
||||
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
|
||||
print(f" [OBSERVER] Popup détectée : [title_hash={_title_hash(popup_label)}] — fermeture")
|
||||
logger.info(f"Observer : popup [title_hash={_title_hash(popup_label)}] détectée avant résolution")
|
||||
|
||||
# ── SÉCURITÉ : refuser de cliquer sur un dialogue système ──
|
||||
# Avant de suivre les coordonnées du serveur (VLM-based,
|
||||
@@ -2365,8 +2366,8 @@ class ActionExecutorV1:
|
||||
recheck_title = recheck_info.get("title", "")
|
||||
if not _matches_expected_window(recheck_title):
|
||||
logger.warning(
|
||||
f"P0.9 transition instable : matched '{post_title}' "
|
||||
f"puis '{recheck_title}' à T+0.5s ≠ '{expected_after}'"
|
||||
f"P0.9 transition instable : matched [title_hash={_title_hash(post_title)}] "
|
||||
f"puis [title_hash={_title_hash(recheck_title)}] à T+0.5s ≠ [title_hash={_title_hash(expected_after)}]"
|
||||
)
|
||||
matched = False
|
||||
post_title = recheck_title
|
||||
@@ -2376,19 +2377,19 @@ class ActionExecutorV1:
|
||||
result["runtime_dialog"] = runtime_dialog_handled
|
||||
print(
|
||||
f" [POST-VÉRIF] Dialogue runtime géré "
|
||||
f"→ retour '{post_title}'"
|
||||
f"→ retour [title_hash={_title_hash(post_title)}]"
|
||||
)
|
||||
logger.info(
|
||||
"POST-VÉRIF runtime dialog géré : '%s' -> '%s'",
|
||||
runtime_dialog_handled.get("dialog_title", ""),
|
||||
post_title,
|
||||
"POST-VÉRIF runtime dialog géré : [title_hash=%s] -> [title_hash=%s]",
|
||||
_title_hash(runtime_dialog_handled.get("dialog_title", "")),
|
||||
_title_hash(post_title),
|
||||
)
|
||||
else:
|
||||
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — '{post_title}'")
|
||||
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : '{post_title}'")
|
||||
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — [title_hash={_title_hash(post_title)}]")
|
||||
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : [title_hash={_title_hash(post_title)}]")
|
||||
else:
|
||||
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — '{post_title}' ≠ '{expected_after}'")
|
||||
logger.warning(f"POST-VÉRIF TIMEOUT : '{post_title}' ≠ '{expected_after}'")
|
||||
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
|
||||
logger.warning(f"POST-VÉRIF TIMEOUT : [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
|
||||
if runtime_dialog_handled:
|
||||
result["warning"] = (
|
||||
f"runtime_dialog_handled_post_verify:{post_title}"
|
||||
@@ -2396,9 +2397,9 @@ class ActionExecutorV1:
|
||||
result["runtime_dialog"] = runtime_dialog_handled
|
||||
logger.warning(
|
||||
"POST-VÉRIF runtime dialog géré mais "
|
||||
"fenêtre finale inattendue : '%s' ≠ '%s'",
|
||||
post_title,
|
||||
expected_after,
|
||||
"fenêtre finale inattendue : [title_hash=%s] ≠ [title_hash=%s]",
|
||||
_title_hash(post_title),
|
||||
_title_hash(expected_after),
|
||||
)
|
||||
# Contrôle strict : si success_strict, on STOP.
|
||||
# On durcit aussi les vrais changements de fenêtre
|
||||
@@ -2416,8 +2417,8 @@ class ActionExecutorV1:
|
||||
if bool(action.get("success_strict")) or requires_transition:
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"Post-vérif échouée : fenêtre '{post_title}' "
|
||||
f"au lieu de '{expected_after}'"
|
||||
f"Post-vérif échouée : fenêtre [title_hash={_title_hash(post_title)}] "
|
||||
f"au lieu de [title_hash={_title_hash(expected_after)}]"
|
||||
)
|
||||
result["warning"] = "wrong_window"
|
||||
result["needs_human"] = True
|
||||
@@ -2458,7 +2459,7 @@ class ActionExecutorV1:
|
||||
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
|
||||
self._type_text(text, paste=bool(action.get("paste", False)))
|
||||
print(f" [TYPE] Termine.")
|
||||
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
|
||||
logger.info(f"Replay type : [{len(text)} chars] (raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
elif action_type == "key_combo":
|
||||
keys = action.get("keys", [])
|
||||
@@ -2524,12 +2525,12 @@ class ActionExecutorV1:
|
||||
if not self._window_title_matches_any(current_title, patterns):
|
||||
logger.warning(
|
||||
"[LEA] verify_screen garde KO : attendu un titre "
|
||||
"contenant %s, actuel '%s'",
|
||||
patterns, current_title,
|
||||
"contenant %s, actuel [title_hash=%s]",
|
||||
patterns, _title_hash(current_title),
|
||||
)
|
||||
print(
|
||||
f" [VERIFY] Garde titre KO "
|
||||
f"(patterns={patterns}, actuel='{current_title}') "
|
||||
f"(patterns={patterns}, actuel=[title_hash={_title_hash(current_title)}]) "
|
||||
"→ apprentissage humain"
|
||||
)
|
||||
try:
|
||||
@@ -2557,15 +2558,15 @@ class ActionExecutorV1:
|
||||
result["error"] = (
|
||||
f"verify_screen titre fenêtre KO : attendu "
|
||||
f"un titre contenant {patterns}, "
|
||||
f"actuel '{current_title}'"
|
||||
f"actuel [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
result["warning"] = "setup_guard_window_mismatch"
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
logger.info(
|
||||
"[LEA] verify_screen garde OK : '%s' matche %s",
|
||||
current_title, patterns,
|
||||
"[LEA] verify_screen garde OK : [title_hash=%s] matche %s",
|
||||
_title_hash(current_title), patterns,
|
||||
)
|
||||
|
||||
print(f" [VERIFY] Termine (verification deferred au serveur).")
|
||||
@@ -3736,8 +3737,8 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
real_x = int(x_pct * sw)
|
||||
real_y = int(y_pct * sh)
|
||||
label = server_result.get("matched_element", {}).get("label", "popup")
|
||||
print(f" [POPUP-SERVER] Popup détectée ! Clic sur '{label}' → ({real_x}, {real_y})")
|
||||
logger.info(f"[POPUP-SERVER] Clic popup '{label}' à ({real_x}, {real_y})")
|
||||
print(f" [POPUP-SERVER] Popup détectée ! Clic sur [title_hash={_title_hash(label)}] → ({real_x}, {real_y})")
|
||||
logger.info(f"[POPUP-SERVER] Clic popup [title_hash={_title_hash(label)}] à ({real_x}, {real_y})")
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
@@ -3856,8 +3857,8 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
raw_content = resp.json().get("message", {}).get("content", "")
|
||||
full_response = prefill + raw_content
|
||||
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : {full_response.strip()}")
|
||||
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : {full_response.strip()}")
|
||||
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : [len={len(full_response)}, has_target={'target' in full_response}]")
|
||||
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : [len={len(full_response)}, has_target={'target' in full_response}]")
|
||||
|
||||
# Extraire le texte du bouton depuis la réponse
|
||||
button_text = raw_content.strip().strip('"').strip("'").strip(".")
|
||||
@@ -4172,7 +4173,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
try:
|
||||
self.keyboard.type(char)
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de taper '{char}': {e}")
|
||||
logger.debug(f"Impossible de taper [1 char typed]: {e}")
|
||||
# Délai humain entre les frappes (40-120ms)
|
||||
time.sleep(random.uniform(0.04, 0.12))
|
||||
|
||||
|
||||
48
agent_v0/agent_v1/core/log_safe.py
Normal file
48
agent_v0/agent_v1/core/log_safe.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Helpers de logging PII-safe pour le client Léa (agent_v1).
|
||||
|
||||
Convention : ne jamais logger le contenu brut d'une variable utilisateur
|
||||
(texte tapé, titre de fenêtre, nom de workflow, réponse VLM, chemin fichier).
|
||||
Le remplacer par :
|
||||
- une longueur ou un hash court (corrélation de diagnostic sans révéler) ;
|
||||
- un dict de métadonnées filtré (sans titre / fenêtre active).
|
||||
|
||||
À importer dans tout module d'agent_v1 qui logge une donnée potentiellement
|
||||
sensible. Branche feat/push-log-dgx — DETTE-020 (assainissement à la source).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
|
||||
def _title_hash(title: str) -> str:
|
||||
"""Hash SHA1 tronqué (8 hex) d'un titre.
|
||||
|
||||
Corrélation stable (même titre → même hash → « même popup re-détectée »)
|
||||
sans exposer le contenu. `errors="replace"` pour ne jamais lever sur un
|
||||
encodage exotique (titres Windows multi-langues).
|
||||
"""
|
||||
return hashlib.sha1((title or "").encode("utf-8", errors="replace")).hexdigest()[:8]
|
||||
|
||||
|
||||
# Clés de métadonnées susceptibles de contenir du contenu utilisateur (PII).
|
||||
_PII_METADATA_KEYS = ("title", "active_window", "window_title")
|
||||
|
||||
|
||||
def _sanitize_metadata(metadata: dict) -> dict:
|
||||
"""Copie d'un dict de métadonnées sans les clés porteuses de PII.
|
||||
|
||||
Garde les champs techniques (resolution, dpi, theme, langue…), retire
|
||||
titre / fenêtre active. Ne mute pas le dict d'origine.
|
||||
"""
|
||||
return {k: v for k, v in metadata.items() if k not in _PII_METADATA_KEYS}
|
||||
|
||||
|
||||
def _path_ext(path: str) -> str:
|
||||
"""Extension seule d'un chemin (ex. « .png »), sans nom ni dossier.
|
||||
|
||||
Un chemin peut nommer un patient ; l'extension suffit au diagnostic.
|
||||
Chaîne vide si pas de chemin ou pas d'extension.
|
||||
"""
|
||||
return os.path.splitext(path)[1] if path else ""
|
||||
@@ -24,6 +24,8 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .log_safe import _title_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -168,8 +170,8 @@ class RecoveryEngine:
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
active = get_active_window_info()
|
||||
active_title = active.get("title", "")
|
||||
logger.info(f"Recovery : Alt+F4 sur '{active_title}'")
|
||||
print(f" [RECOVERY] Alt+F4 — fermeture de '{active_title}'")
|
||||
logger.info(f"Recovery : Alt+F4 sur [title_hash={_title_hash(active_title)}]")
|
||||
print(f" [RECOVERY] Alt+F4 — fermeture de [title_hash={_title_hash(active_title)}]")
|
||||
except Exception:
|
||||
logger.info("Recovery : Alt+F4 (fenêtre active inconnue)")
|
||||
print(" [RECOVERY] Alt+F4 — fermeture fenêtre indésirable")
|
||||
@@ -182,7 +184,7 @@ class RecoveryEngine:
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.CLOSE_WINDOW,
|
||||
success=True,
|
||||
detail=f"Alt+F4 exécuté sur '{active_title if 'active_title' in dir() else '?'}'",
|
||||
detail=f"Alt+F4 exécuté sur [title_hash={_title_hash(active_title) if 'active_title' in dir() else '?'}]",
|
||||
)
|
||||
|
||||
elif strategy == RecoveryAction.CLICK_AWAY:
|
||||
|
||||
56
agent_v0/agent_v1/logging_setup.py
Normal file
56
agent_v0/agent_v1/logging_setup.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Journalisation client Léa — DETTE-021.
|
||||
|
||||
Branche un handler **fichier** (`TimedRotatingFileHandler`) sur le logger racine,
|
||||
en plus de la console. Sans cela, sous `pythonw.exe` (pas de console), les logs
|
||||
partent sur stderr et sont **perdus** — diagnostic terrain impossible.
|
||||
|
||||
Rotation quotidienne + rétention `retention_days` (Règlement IA Art. 12 :
|
||||
journalisation automatique + conservation minimum 180 j).
|
||||
"""
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
_FMT = "%(asctime)s %(levelname)-7s %(name)-25s %(message)s"
|
||||
|
||||
|
||||
def setup_logging(log_file, level=logging.INFO, retention_days=180):
|
||||
"""Configure le logging racine : fichier (rotation quotidienne, `retention_days`
|
||||
fichiers conservés) + console. **Idempotent** : ne réempile pas nos handlers.
|
||||
|
||||
Args:
|
||||
log_file: chemin du fichier de log (`config.LOG_FILE` en prod).
|
||||
level: niveau racine (INFO par défaut ; DEBUG géré par l'appelant).
|
||||
retention_days: nb de fichiers quotidiens conservés (180 = Règlement IA Art. 12).
|
||||
|
||||
Returns:
|
||||
Le `TimedRotatingFileHandler` créé.
|
||||
"""
|
||||
log_file = Path(log_file)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
|
||||
# Idempotence : retirer nos propres handlers posés par un appel précédent.
|
||||
for h in list(root.handlers):
|
||||
if getattr(h, "_lea_managed", False):
|
||||
h.close()
|
||||
root.removeHandler(h)
|
||||
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
str(log_file), when="midnight", backupCount=retention_days, encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(_FMT, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
file_handler.setLevel(level)
|
||||
file_handler._lea_managed = True
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# Console conservée (utile en dev / si lancé avec une console).
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(logging.Formatter(_FMT, datefmt="%H:%M:%S"))
|
||||
console.setLevel(level)
|
||||
console._lea_managed = True
|
||||
root.addHandler(console)
|
||||
|
||||
return file_handler
|
||||
@@ -15,9 +15,10 @@ import time
|
||||
import logging
|
||||
import threading
|
||||
from .config import (
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE,
|
||||
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
|
||||
STREAMING_ENDPOINT,
|
||||
STREAMING_ENDPOINT, LOG_SHIP_ENABLED, LOG_SHIP_INTERVAL_S,
|
||||
AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_S, AUTO_UPDATE_STAGING_DIR,
|
||||
)
|
||||
from .core.captor import EventCaptorV1
|
||||
from .core.executor import ActionExecutorV1
|
||||
@@ -29,6 +30,7 @@ from .ui.capture_server import CaptureServer
|
||||
from .session.storage import SessionStorage
|
||||
from .vision.capturer import VisionCapturer
|
||||
from .finalize_contract import dispatch_finalize_result
|
||||
from .core.log_safe import _title_hash
|
||||
|
||||
# Import optionnel du client serveur (pour le chat et les workflows)
|
||||
# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1)
|
||||
@@ -43,16 +45,44 @@ except (ImportError, ValueError):
|
||||
# Configuration du logging — format structuré et lisible pour un TIM
|
||||
# Niveau de détail : INFO par défaut, DEBUG si RPA_AGENT_DEBUG=1
|
||||
_log_level = logging.DEBUG if os.environ.get("RPA_AGENT_DEBUG") == "1" else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=_log_level,
|
||||
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
# DETTE-021 : journaliser dans un FICHIER (rotation quotidienne + rétention 180 j,
|
||||
# Règlement IA Art. 12). Sous `pythonw.exe` (sans console), un basicConfig→stderr
|
||||
# serait perdu. Fallback console si le fichier est indisponible — ne JAMAIS
|
||||
# empêcher Léa de démarrer pour un problème de log.
|
||||
try:
|
||||
from .logging_setup import setup_logging
|
||||
setup_logging(LOG_FILE, level=_log_level, retention_days=LOG_RETENTION_DAYS)
|
||||
except Exception:
|
||||
logging.basicConfig(
|
||||
level=_log_level,
|
||||
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
# Réduire le bruit de certaines libs
|
||||
for _noisy in ("urllib3", "requests.packages.urllib3", "PIL", "mss"):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
# push-log-DGX : remontée automatique des logs vers le serveur (diagnostic des
|
||||
# postes SANS AnyDesk). GARDÉ derrière RPA_LOG_SHIP_ENABLED (défaut désactivé) —
|
||||
# activable poste par poste via config.txt, sans rebuild. Le handler est attaché
|
||||
# au logger racine APRÈS setup_logging (les logs partent aussi dans le fichier).
|
||||
_log_shipper = None
|
||||
if LOG_SHIP_ENABLED:
|
||||
try:
|
||||
from .network.log_shipper import LogShipper
|
||||
_log_shipper = LogShipper(
|
||||
machine_id=MACHINE_ID,
|
||||
max_batch=int(os.environ.get("RPA_AGENT_LOGS_MAX_BATCH", "1000")),
|
||||
flush_interval_s=LOG_SHIP_INTERVAL_S,
|
||||
)
|
||||
logging.getLogger().addHandler(_log_shipper.handler)
|
||||
_log_shipper.start()
|
||||
except Exception as _e:
|
||||
# Ne JAMAIS empêcher Léa de démarrer pour un problème de remontée de logs.
|
||||
logging.getLogger(__name__).warning("Log shipper non démarré : %s", _e)
|
||||
_log_shipper = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Intervalle de polling replay (secondes)
|
||||
@@ -129,6 +159,31 @@ class AgentV1:
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
|
||||
|
||||
# DETTE-022 v2 : MAJ silencieuse — boucle de check GATED (défaut OFF).
|
||||
# Interroge le serveur (canary-aware) et télécharge en STAGING ; le swap
|
||||
# réel reste réservé révision humaine (updater.apply_update = stub no-op).
|
||||
# Activable poste par poste via RPA_AUTO_UPDATE_ENABLED, sans rebuild.
|
||||
if AUTO_UPDATE_ENABLED:
|
||||
threading.Thread(
|
||||
target=self._auto_update_loop, daemon=True, name="lea-auto-update"
|
||||
).start()
|
||||
|
||||
# MAJ silencieuse — confirmation de boot post-swap. Si Lea.bat vient
|
||||
# d'appliquer une MAJ (marqueur PENDING_BOOT), on désarme le rollback
|
||||
# après ~90 s de tourne STABLE (liveness LOCALE, indépendante du DGX).
|
||||
# Un quit propre avant 90 s confirme aussi (cf. main()). Seul un vrai
|
||||
# crash laisse PENDING_BOOT → rollback au prochain lancement.
|
||||
if _pending_boot_marker_exists():
|
||||
def _boot_confirm():
|
||||
import os as _os
|
||||
import time as _time
|
||||
_time.sleep(float(_os.environ.get("RPA_BOOT_CONFIRM_DELAY_S", "90")))
|
||||
if self.running:
|
||||
_confirm_boot_ok()
|
||||
threading.Thread(
|
||||
target=_boot_confirm, daemon=True, name="lea-boot-confirm"
|
||||
).start()
|
||||
|
||||
# Mini-serveur HTTP pour captures a la demande (port 5006)
|
||||
self._capture_server = CaptureServer()
|
||||
self._capture_server.start()
|
||||
@@ -253,7 +308,7 @@ class AgentV1:
|
||||
# Ne PAS en relancer une ici — deux threads poll simultanés causent
|
||||
# une race condition où les actions sont consommées mais pas exécutées.
|
||||
|
||||
logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...")
|
||||
logger.info(f"Session {self.session_id} [wf_hash={_title_hash(workflow_name)}] sur machine {self.machine_id} en cours...")
|
||||
|
||||
def _command_watchdog_loop(self):
|
||||
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""
|
||||
@@ -412,6 +467,67 @@ class AgentV1:
|
||||
logger.debug(f"[HEARTBEAT] Erreur: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def _auto_update_loop(self):
|
||||
"""DETTE-022 v2 — boucle de MAJ silencieuse GATED (défaut OFF).
|
||||
|
||||
Interroge périodiquement le serveur (endpoint canary-aware), et si une
|
||||
MAJ est proposée pour CE poste, la télécharge dans le STAGING après
|
||||
vérif SHA256. Le swap réel N'EST PAS fait ici : `updater.run_update_cycle`
|
||||
s'arrête au staging (apply_update = stub réservé révision humaine + swap
|
||||
hors-process par Lea.bat au prochain démarrage).
|
||||
|
||||
SÉCURITÉ — « au bon moment » : on NE stage PAS pendant un enregistrement
|
||||
ou un replay actif (self.session_id / self._replay_active), pour ne pas
|
||||
perturber le travail utilisateur ni consommer du réseau au mauvais
|
||||
moment. Best-effort : aucune exception ne remonte (ne casse jamais Léa).
|
||||
"""
|
||||
try:
|
||||
from .network.updater import run_update_cycle
|
||||
except Exception as e:
|
||||
logger.warning("[UPDATE] Module updater indisponible : %s", e)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[UPDATE] Boucle MAJ silencieuse démarrée (intervalle=%.0fs, "
|
||||
"version=%s) — check seul, swap réservé révision humaine",
|
||||
AUTO_UPDATE_INTERVAL_S, AGENT_VERSION,
|
||||
)
|
||||
|
||||
while self.running:
|
||||
# Découpe l'attente pour réagir vite à l'arrêt.
|
||||
waited = 0.0
|
||||
step = 1.0
|
||||
while self.running and waited < AUTO_UPDATE_INTERVAL_S:
|
||||
time.sleep(step)
|
||||
waited += step
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# « Au bon moment » : jamais en plein travail (enregistrement/replay).
|
||||
if self.session_id or getattr(self, "_replay_active", False):
|
||||
logger.debug("[UPDATE] Report du check (session/replay active)")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = run_update_cycle(
|
||||
local_version=AGENT_VERSION,
|
||||
machine_id=self.machine_id,
|
||||
staging_dir=AUTO_UPDATE_STAGING_DIR,
|
||||
)
|
||||
status = result.get("status")
|
||||
if status == "staged":
|
||||
logger.info(
|
||||
"[UPDATE] MAJ %s téléchargée en staging (SHA256=%s) — "
|
||||
"swap réservé révision humaine, non appliqué",
|
||||
result.get("target_version"),
|
||||
result.get("sha256_verified"),
|
||||
)
|
||||
elif status not in ("up_to_date", "disabled"):
|
||||
logger.debug("[UPDATE] Cycle: %s", result)
|
||||
except Exception as e:
|
||||
# run_update_cycle est déjà best-effort ; double filet ici.
|
||||
logger.debug("[UPDATE] Erreur boucle MAJ : %s", e)
|
||||
|
||||
def stop_session(self):
|
||||
# Sauvegarder le session_id avant de l'annuler (pour les logs)
|
||||
ended_session_id = self.session_id
|
||||
@@ -578,29 +694,20 @@ class AgentV1:
|
||||
def run(self):
|
||||
self.ui.run()
|
||||
|
||||
def _headless_keepalive(agent):
|
||||
"""Maintient le main thread vivant quand l'UI tray ne peut pas tourner.
|
||||
def _install_signal_handlers(agent, watchdog) -> None:
|
||||
"""Installe SIGTERM/SIGINT/SIGBREAK pour un arrêt propre du main thread.
|
||||
|
||||
Sans cela, ``agent.run()`` retourne immédiatement (pystray échoue quand
|
||||
Léa est lancée via SSH sans session interactive Windows), le main thread
|
||||
se termine, et TOUS les daemon threads — y compris ``_replay_poll_loop``
|
||||
— meurent avec lui. Observé 3 fois en 24h les 24/05 :
|
||||
- SSH ``Permission denied`` (1231)
|
||||
- polls morts après relance distante (1620)
|
||||
- polls morts ``replay_sess_506d6fa2`` (1627)
|
||||
|
||||
Le keepalive ne se déclenche QUE si ``agent.run()`` est sorti tout en
|
||||
laissant ``agent.running=True`` (cas anormal). En mode interactif
|
||||
normal, ``pystray.Icon.run()`` ne sort jamais, donc ce code est
|
||||
invisible.
|
||||
Met ``agent.running=False`` (les daemon threads s'arrêtent) et réveille
|
||||
le watchdog (qui sort de sa boucle de surveillance). Sans session
|
||||
interactive (pystray.Icon.stop indisponible), c'est le SEUL moyen
|
||||
d'arrêter Léa proprement : ``kill -TERM <pid>`` ou Ctrl+C.
|
||||
"""
|
||||
import signal as _sig
|
||||
_stop = threading.Event()
|
||||
|
||||
def _handler(sig, frame):
|
||||
logger.info(f"[MAIN] Signal {sig} recu — arret propre")
|
||||
_stop.set()
|
||||
agent.running = False
|
||||
watchdog.stop()
|
||||
|
||||
for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"):
|
||||
sig_obj = getattr(_sig, sig_name, None)
|
||||
@@ -611,33 +718,78 @@ def _headless_keepalive(agent):
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"[MAIN] Keepalive headless actif — main thread bloque pour maintenir "
|
||||
"les daemon threads (_replay_poll_loop, heartbeat, capture_server) vivants. "
|
||||
"Pour stopper Lea : kill -TERM <pid> ou Ctrl+C."
|
||||
)
|
||||
|
||||
def _agent_should_live(agent) -> bool:
|
||||
"""Vrai tant que Léa doit vivre : agent actif ET pas de Quitter explicite.
|
||||
|
||||
Un « Quitter » utilisateur (``ui._quit_requested``) doit stopper le
|
||||
watchdog pour de bon ; une simple déconnexion RDP ne met JAMAIS ce flag
|
||||
→ le tray revient tout seul à la reconnexion.
|
||||
"""
|
||||
if not getattr(agent, "running", False):
|
||||
return False
|
||||
ui = getattr(agent, "ui", None)
|
||||
if ui is not None and getattr(ui, "_quit_requested", False):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _pending_boot_marker_exists() -> bool:
|
||||
"""True si Lea.bat a posé PENDING_BOOT (boot post-MAJ à valider)."""
|
||||
try:
|
||||
_stop.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
agent.running = False
|
||||
logger.info("[MAIN] Keepalive termine — agent.running=False, daemon threads vont s'arreter")
|
||||
from .network.updater import _resolve_app_dir
|
||||
return (_resolve_app_dir(None) / "PENDING_BOOT").exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _confirm_boot_ok() -> None:
|
||||
"""Confirme un boot post-MAJ : écrit boot_ok + retire PENDING_BOOT.
|
||||
|
||||
Désarme le rollback de Lea.bat. No-op si pas de PENDING_BOOT (boot normal).
|
||||
Best-effort — ne doit jamais casser l'arrêt/la vie de Léa.
|
||||
"""
|
||||
try:
|
||||
if not _pending_boot_marker_exists():
|
||||
return
|
||||
from .network import updater
|
||||
updater.write_boot_ok_marker(AGENT_VERSION)
|
||||
logger.info("[MAJ] Boot confirmé (v%s) — rollback désarmé", AGENT_VERSION)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("confirm_boot_ok: %s", e)
|
||||
|
||||
|
||||
def main():
|
||||
agent = AgentV1()
|
||||
try:
|
||||
agent.run()
|
||||
except Exception:
|
||||
logger.exception("[MAIN] agent.run() a leve une exception")
|
||||
from .ui.session_watchdog import InteractiveSessionWatchdog
|
||||
|
||||
if getattr(agent, "running", False):
|
||||
logger.warning(
|
||||
"[MAIN] agent.run() est sorti mais agent.running=True — "
|
||||
"probablement pystray sans session interactive (SSH). "
|
||||
"Bascule en keepalive headless."
|
||||
)
|
||||
_headless_keepalive(agent)
|
||||
agent = AgentV1()
|
||||
|
||||
# Résilience RDP/Citrix : au lieu de bloquer le main thread pour toujours
|
||||
# quand pystray sort (session interactive perdue), on surveille la
|
||||
# session et on ré-affiche le tray + le chat à chaque reconnexion.
|
||||
# agent.run() (== agent.ui.run()) est ré-entrant : les threads de fond
|
||||
# ne démarrent qu'une fois, seule l'icône est recréée. Les daemon threads
|
||||
# de capture/heartbeat/replay tournent contre agent.running et restent
|
||||
# uniques — le watchdog n'y touche pas.
|
||||
watchdog = InteractiveSessionWatchdog(
|
||||
run_ui=agent.run,
|
||||
is_running=lambda: _agent_should_live(agent),
|
||||
)
|
||||
_install_signal_handlers(agent, watchdog)
|
||||
|
||||
try:
|
||||
watchdog.run()
|
||||
# Sortie normale du watchdog = quit propre (tray / session) → le boot
|
||||
# était sain : on confirme (couvre un quit AVANT les 90 s, évite un faux
|
||||
# rollback). No-op si ce n'est pas un boot post-MAJ.
|
||||
_confirm_boot_ok()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[MAIN] Interruption clavier — arret propre")
|
||||
except Exception:
|
||||
logger.exception("[MAIN] Le watchdog de session a leve une exception")
|
||||
finally:
|
||||
agent.running = False
|
||||
logger.info("[MAIN] Sortie — agent.running=False, daemon threads vont s'arreter")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
317
agent_v0/agent_v1/network/log_shipper.py
Normal file
317
agent_v0/agent_v1/network/log_shipper.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# agent_v1/network/log_shipper.py
|
||||
"""Remontée AUTOMATIQUE des logs du client Léa vers le serveur (push-log-DGX).
|
||||
|
||||
But : diagnostiquer les postes Windows clinique SANS AnyDesk. Les logs déjà
|
||||
écrits sur disque par `logging_setup.py` (rotation quotidienne, rétention 180 j,
|
||||
Règlement IA Art. 12) sont en plus poussés au serveur, rangés par `machine_id`,
|
||||
consultables au dashboard.
|
||||
|
||||
Serveur (déjà prêt — NE PAS toucher) :
|
||||
POST /api/v1/agents/logs
|
||||
body = {machine_id: str, logs: [{ts, level, logger, message}]}
|
||||
borne RPA_AGENT_LOGS_MAX_BATCH (défaut 1000) — 413 si dépassée.
|
||||
|
||||
Conception :
|
||||
- `LogShipperHandler(logging.Handler)` : sur `emit(record)`, formate au
|
||||
schéma EXACT `{ts, level, logger, message}`, applique un assainissement
|
||||
PII au message (défense en profondeur — la discipline `log_safe` à la
|
||||
source logue déjà des hashes/longueurs, pas du contenu brut), puis
|
||||
empile dans un buffer borné.
|
||||
- `LogShipper` : flush par BATCH (≤ max_batch) via un `sender` callable
|
||||
INJECTABLE `(machine_id, logs) -> bool`. Défaut = POST réel Bearer
|
||||
(pattern `streamer.py`).
|
||||
- Résilience (ZÉRO perte) : si `sender` renvoie False ou lève, les logs
|
||||
RESTENT dans le buffer et sont rejoués au flush suivant. Le fichier de
|
||||
log local reste de toute façon la source durable (survit au crash) ; le
|
||||
buffer RAM est un best-effort de remontée, volontairement NON persisté en
|
||||
SQLite (le `PersistentBuffer` est session/event-scoped — y mêler des logs
|
||||
polluerait la DB d'events). Borne mémoire = `max_buffer` (drop des plus
|
||||
VIEUX au-delà — un log récent vaut mieux qu'un vieux pour le diagnostic).
|
||||
|
||||
Pattern d'import PII : on tente `anonymize_text` (server_v1.pii_sanitizer,
|
||||
source de vérité des tokens typés) via le même import paresseux tolérant que
|
||||
`ui/messages.py`. Sur un vrai poste (sans server_v1), on retombe sur l'identité :
|
||||
acceptable car la PII de message est déjà neutralisée à la source par la
|
||||
discipline `log_safe`. Le sanitizer reste INJECTABLE pour les tests/évolutions.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Callable, Deque, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Schéma d'une entrée de log poussée au serveur.
|
||||
# ts : epoch (float) — l'heure de l'évènement
|
||||
# level : nom du niveau ("INFO", "WARNING"...)
|
||||
# logger : nom du logger (record.name)
|
||||
# message : message formaté (args interpolés) ET assaini PII
|
||||
|
||||
# Défaut aligné sur la borne serveur RPA_AGENT_LOGS_MAX_BATCH (api_stream.py).
|
||||
DEFAULT_MAX_BATCH = 1000
|
||||
|
||||
# Borne mémoire du buffer : au-delà, on droppe les plus VIEUX (diagnostic =
|
||||
# on préfère les logs récents). Quelques milliers d'entrées = quelques Mo RAM.
|
||||
DEFAULT_MAX_BUFFER = 5000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assainissement PII du message (défense en profondeur)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_message_sanitizer(text: str) -> str:
|
||||
"""Sanitizer par défaut côté client = identité.
|
||||
|
||||
Le **rempart PII des logs est le SERVEUR** : `sanitize_log_entries`
|
||||
ré-assainit chaque message à la réception (`/api/v1/agents/logs`), via le
|
||||
même `anonymize_text` que les events. Tenter un import de `server_v1` côté
|
||||
poste à CHAQUE ligne de log est inutile (absent du bundle client) et coûteux
|
||||
(exception attrapée par emit). La discipline `log_safe` neutralise déjà la
|
||||
PII à la source. Reste INJECTABLE pour tests/évolutions.
|
||||
"""
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler — empile les LogRecords dans un buffer partagé
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogShipperHandler(logging.Handler):
|
||||
"""Handler logging qui sérialise chaque record et l'empile pour envoi.
|
||||
|
||||
Ne fait AUCUN réseau : il alimente seulement le buffer du `LogShipper`.
|
||||
L'envoi est piloté par `LogShipper.flush()` (thread dédié périodique).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buffer: Deque[Dict],
|
||||
lock: threading.Lock,
|
||||
message_sanitizer: Callable[[str], str],
|
||||
max_buffer: int = DEFAULT_MAX_BUFFER,
|
||||
level=logging.NOTSET,
|
||||
):
|
||||
super().__init__(level=level)
|
||||
self._buffer = buffer
|
||||
self._lock = lock
|
||||
self._sanitize = message_sanitizer
|
||||
self._max_buffer = max_buffer
|
||||
|
||||
def _format_record(self, record: logging.LogRecord) -> Dict:
|
||||
"""Construit l'entrée au schéma EXACT {ts, level, logger, message}.
|
||||
|
||||
`record.getMessage()` interpole les args (%s...). Le message est ensuite
|
||||
passé au sanitizer PII. Tolérant : un message non formatable ne doit pas
|
||||
faire perdre l'entrée.
|
||||
"""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception:
|
||||
message = str(record.msg)
|
||||
try:
|
||||
message = self._sanitize(message)
|
||||
except Exception:
|
||||
# Le sanitizer ne doit jamais casser le logging.
|
||||
pass
|
||||
return {
|
||||
"ts": record.created,
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Sérialise et empile le record (best-effort, ne lève jamais)."""
|
||||
try:
|
||||
entry = self._format_record(record)
|
||||
with self._lock:
|
||||
# deque(maxlen) droppe automatiquement le plus VIEUX au-delà
|
||||
# de la borne — pas de croissance mémoire non bornée.
|
||||
self._buffer.append(entry)
|
||||
except Exception:
|
||||
# handleError respecte logging.raiseExceptions (silencieux en prod).
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shipper — flush périodique par batch via un sender injectable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogShipper:
|
||||
"""Orchestre la remontée des logs : buffer + flush par batch.
|
||||
|
||||
Args:
|
||||
machine_id : identifiant du poste (config.MACHINE_ID en prod).
|
||||
sender : callable INJECTABLE `(machine_id, logs) -> bool`. True =
|
||||
accusé de réception serveur. Défaut = POST réel Bearer.
|
||||
max_batch : taille max d'un batch (≤ borne serveur). Défaut 1000.
|
||||
max_buffer : borne mémoire du buffer (drop des plus vieux au-delà).
|
||||
message_sanitizer : assainissement PII du message. Défaut = pii_sanitizer
|
||||
si disponible, sinon identité.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
machine_id: str,
|
||||
sender: Optional[Callable[[str, List[Dict]], bool]] = None,
|
||||
max_batch: int = DEFAULT_MAX_BATCH,
|
||||
max_buffer: int = DEFAULT_MAX_BUFFER,
|
||||
message_sanitizer: Optional[Callable[[str], str]] = None,
|
||||
flush_interval_s: float = 30.0,
|
||||
):
|
||||
self.machine_id = machine_id
|
||||
self.max_batch = max(1, int(max_batch))
|
||||
self.flush_interval_s = flush_interval_s
|
||||
self._sender = sender if sender is not None else self._default_sender
|
||||
self._sanitize = message_sanitizer or _default_message_sanitizer
|
||||
self._lock = threading.Lock()
|
||||
self._buffer: Deque[Dict] = deque(maxlen=max_buffer)
|
||||
self.handler = LogShipperHandler(
|
||||
buffer=self._buffer,
|
||||
lock=self._lock,
|
||||
message_sanitizer=self._sanitize,
|
||||
max_buffer=max_buffer,
|
||||
)
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Introspection (diagnostic / tests)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def peek_buffer(self) -> List[Dict]:
|
||||
"""Copie des entrées en attente (lecture seule, pour diagnostic/tests)."""
|
||||
with self._lock:
|
||||
return list(self._buffer)
|
||||
|
||||
def pending(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._buffer)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flush — envoie le buffer par batches ≤ max_batch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def flush(self) -> int:
|
||||
"""Envoie le buffer par batches successifs. Retourne le nb de logs ACK.
|
||||
|
||||
Résilience ZÉRO perte : on retire un batch du buffer, on tente l'envoi.
|
||||
- Succès → les entrées sont définitivement consommées.
|
||||
- Échec (False ou exception) → on REMET les entrées en tête du buffer
|
||||
et on ARRÊTE la passe (serveur probablement down) ; rejeu au flush
|
||||
suivant. Les entrées non encore extraites restent en place.
|
||||
"""
|
||||
sent = 0
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._buffer:
|
||||
break
|
||||
batch: List[Dict] = []
|
||||
for _ in range(min(self.max_batch, len(self._buffer))):
|
||||
batch.append(self._buffer.popleft())
|
||||
|
||||
try:
|
||||
ok = self._sender(self.machine_id, batch)
|
||||
except Exception as e:
|
||||
ok = False
|
||||
logger.debug("Log shipper sender a levé : %s", e)
|
||||
|
||||
if ok:
|
||||
sent += len(batch)
|
||||
continue
|
||||
|
||||
# Échec : on remet le batch en tête (ordre préservé) et on arrête.
|
||||
with self._lock:
|
||||
self._buffer.extendleft(reversed(batch))
|
||||
break
|
||||
|
||||
return sent
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sender réel — POST Bearer (pattern streamer.py)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _auth_headers() -> dict:
|
||||
"""Headers Bearer (pattern streamer.py)."""
|
||||
try:
|
||||
from ..config import API_TOKEN
|
||||
except Exception:
|
||||
API_TOKEN = ""
|
||||
if API_TOKEN:
|
||||
return {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
return {}
|
||||
|
||||
def _default_sender(self, machine_id: str, logs: List[Dict]) -> bool:
|
||||
"""POST réel vers /api/v1/agents/logs. True si HTTP 2xx.
|
||||
|
||||
Best-effort : tout échec réseau/serveur → False (logs conservés,
|
||||
rejoués). Aucune exception ne remonte au-delà du sender.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
from ..config import SERVER_URL
|
||||
|
||||
url = f"{SERVER_URL}/agents/logs"
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={"machine_id": machine_id, "logs": logs},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
return bool(resp.ok)
|
||||
except Exception as e:
|
||||
logger.debug("Log shipper POST échoué : %s", e)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Boucle de flush périodique (thread daemon)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Démarre le thread de flush périodique (idempotent)."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="lea-log-shipper"
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
"Log shipper démarré (machine_id=%s, intervalle=%.0fs, batch≤%d)",
|
||||
self.machine_id, self.flush_interval_s, self.max_batch,
|
||||
)
|
||||
|
||||
def stop(self, final_flush: bool = True) -> None:
|
||||
"""Arrête la boucle et tente un dernier flush (best-effort)."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
if final_flush:
|
||||
try:
|
||||
self.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
# Découpe l'attente pour réagir vite à stop().
|
||||
waited = 0.0
|
||||
step = 0.5
|
||||
while self._running and waited < self.flush_interval_s:
|
||||
time.sleep(step)
|
||||
waited += step
|
||||
if not self._running:
|
||||
break
|
||||
try:
|
||||
self.flush()
|
||||
except Exception as e:
|
||||
logger.debug("Log shipper flush loop : %s", e)
|
||||
@@ -36,6 +36,7 @@ import requests
|
||||
from PIL import Image
|
||||
|
||||
from ..config import API_TOKEN, BASE_DIR, STREAMING_ENDPOINT
|
||||
from ..core.log_safe import _title_hash
|
||||
from .persistent_buffer import MAX_ATTEMPTS, PersistentBuffer
|
||||
|
||||
|
||||
@@ -138,7 +139,7 @@ class TraceStreamer:
|
||||
target=self._buffer_drain_loop, daemon=True
|
||||
)
|
||||
self._drain_thread.start()
|
||||
logger.info(f"Streamer pour {self.session_id} démarré")
|
||||
logger.info(f"Streamer démarré")
|
||||
|
||||
def stop(self):
|
||||
"""Arrêter le streaming et finaliser la session côté serveur.
|
||||
@@ -166,7 +167,7 @@ class TraceStreamer:
|
||||
self._drain_thread.join(timeout=2.0)
|
||||
|
||||
self._finalize_session()
|
||||
logger.info(f"Streamer pour {self.session_id} arrêté")
|
||||
logger.info(f"Streamer arrêté")
|
||||
|
||||
def push_event(self, event_data: dict):
|
||||
"""Enfile un événement pour envoi immédiat.
|
||||
@@ -632,7 +633,7 @@ class TraceStreamer:
|
||||
self._check_redirect(resp, url)
|
||||
if resp.ok:
|
||||
result = resp.json()
|
||||
logger.info(f"Session finalisée: {result}")
|
||||
logger.info(f"Session finalisée [status={result.get('status')}, wf_hash={_title_hash(result.get('workflow_name',''))}]")
|
||||
if self._on_finalize_result is not None:
|
||||
try:
|
||||
self._on_finalize_result(result)
|
||||
|
||||
481
agent_v0/agent_v1/network/updater.py
Normal file
481
agent_v0/agent_v1/network/updater.py
Normal file
@@ -0,0 +1,481 @@
|
||||
# agent_v1/network/updater.py
|
||||
"""NOYAU client de la mise à jour silencieuse de Léa (DETTE-022 v2).
|
||||
|
||||
GATED — flag `RPA_AUTO_UPDATE_ENABLED` (défaut OFF). Tant qu'il est OFF,
|
||||
rien ne se déclenche : l'intégration de ce module au runtime (boucle de poll
|
||||
de `main.py`) ne fait aucune MAJ.
|
||||
|
||||
Ce module ne contient que les parties PURES / testables, sans réseau réel :
|
||||
|
||||
- `parse_version` / `is_newer` (R3) : self-contained (le bundle client
|
||||
n'embarque PAS `server_v1` — duplication assumée, même algorithme).
|
||||
- `should_update(local_version, server_response)` : décide « faut-il
|
||||
updater ? quelle version/type ? » à partir de la réponse serveur. Double
|
||||
garde semver côté client (jamais de downgrade) = défense en profondeur.
|
||||
- `download_update(plan, staging_dir, downloader)` : télécharge le ZIP via un
|
||||
`downloader` callable INJECTABLE (aucun réseau réel en test), vérifie le
|
||||
SHA256, écrit le ZIP dans le **staging** (`Lea_next\\`-like) — JAMAIS dans
|
||||
les fichiers vivants. Retourne un plan d'application.
|
||||
- `auto_update_enabled()` : lit le flag (défaut OFF).
|
||||
|
||||
⚠️ SWAP — répartition claire des responsabilités :
|
||||
`apply_update` / `write_boot_ok_marker` ci-dessous ne font que l'ARMEMENT côté
|
||||
Python (extraction vers `agent_v1_new/` + marqueurs) — ils n'écrasent JAMAIS un
|
||||
fichier vivant. Le remplacement ATOMIQUE (renames), le redémarrage et le
|
||||
rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (revu ligne à ligne).
|
||||
|
||||
Pattern d'import / résilience aligné sur `log_shipper.py` (même branche).
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Niveaux de livraison (R2). `code-only` par défaut = 99 % des MAJ (~500 Ko).
|
||||
VALID_UPDATE_TYPES = ("code-only", "full")
|
||||
DEFAULT_UPDATE_TYPE = "code-only"
|
||||
|
||||
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag d'activation — OFF par défaut (lu à chaque appel pour faciliter tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def auto_update_enabled() -> bool:
|
||||
"""True si la MAJ auto client est activée (flag RPA_AUTO_UPDATE_ENABLED).
|
||||
|
||||
Défaut PRUDENT = OFF. On l'active poste par poste via config.txt / variable
|
||||
d'environnement, sans rebuild de l'installateur (même esprit que
|
||||
LOG_SHIP_ENABLED).
|
||||
"""
|
||||
return os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
|
||||
"true", "1", "yes", "on",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3 — parse_version self-contained (le bundle client n'a pas server_v1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_version(v) -> Tuple[int, ...]:
|
||||
"""Parse une version semver en tuple d'entiers. Voir server_v1/update_check.
|
||||
|
||||
"1.0.2" → (1, 0, 2) ; "1.0.10" → (1, 0, 10) ; "v1.2.3" → (1, 2, 3).
|
||||
Tolérant et SANS exception : invalide → fallback `(0,)`.
|
||||
"""
|
||||
if not isinstance(v, str):
|
||||
return _FALLBACK_VERSION
|
||||
s = v.strip().lstrip("vV").strip()
|
||||
if not s:
|
||||
return _FALLBACK_VERSION
|
||||
try:
|
||||
from packaging.version import Version
|
||||
|
||||
return tuple(Version(s).release)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return tuple(int(x) for x in s.split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return _FALLBACK_VERSION
|
||||
|
||||
|
||||
def is_newer(candidate: str, baseline: str) -> bool:
|
||||
"""True si `candidate` strictement plus récent que `baseline` (semver)."""
|
||||
return parse_version(candidate) > parse_version(baseline)
|
||||
|
||||
|
||||
def _normalize_update_type(update_type) -> str:
|
||||
if update_type in VALID_UPDATE_TYPES:
|
||||
return update_type
|
||||
return DEFAULT_UPDATE_TYPE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Décision client : faut-il updater ?
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def should_update(local_version: str, server_response) -> Optional[dict]:
|
||||
"""Décide à partir de la réponse serveur s'il faut updater.
|
||||
|
||||
Args:
|
||||
local_version : version courante du client (config.AGENT_VERSION).
|
||||
server_response : dict renvoyé par l'endpoint serveur
|
||||
{update_available, latest_version, update_type, url, [sha256]}.
|
||||
|
||||
Returns:
|
||||
Un PLAN d'update `{target_version, update_type, url, sha256}` si une MAJ
|
||||
valide est à faire, sinon None.
|
||||
|
||||
Défense en profondeur : même si `update_available` est True, le client
|
||||
REVÉRIFIE en semver (`is_newer`) — il ne descend JAMAIS vers une version
|
||||
<= locale. Tolérant : réponse malformée → None (jamais d'exception).
|
||||
"""
|
||||
if not isinstance(server_response, dict):
|
||||
return None
|
||||
if not server_response.get("update_available"):
|
||||
return None
|
||||
|
||||
target = server_response.get("latest_version")
|
||||
url = server_response.get("url")
|
||||
if not target or not url:
|
||||
return None
|
||||
|
||||
# Double garde semver : pas de downgrade, pas d'égalité.
|
||||
if not is_newer(target, local_version):
|
||||
return None
|
||||
|
||||
return {
|
||||
"target_version": target,
|
||||
"update_type": _normalize_update_type(server_response.get("update_type")),
|
||||
"url": url,
|
||||
"sha256": server_response.get("sha256"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Téléchargement — downloader INJECTABLE, SHA256, staging only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_downloader(url: str) -> bytes:
|
||||
"""Téléchargement réel du ZIP (best-effort, pattern streamer/log_shipper).
|
||||
|
||||
Résout l'URL relative contre SERVER_BASE, ajoute le Bearer si présent.
|
||||
INJECTABLE : remplacé par un fake en test (aucun réseau réel).
|
||||
"""
|
||||
import requests # import tardif (absent de certains envs de test)
|
||||
|
||||
full_url = url
|
||||
headers = {}
|
||||
try:
|
||||
from ..config import SERVER_BASE, API_TOKEN
|
||||
|
||||
if url.startswith("/"):
|
||||
full_url = f"{SERVER_BASE}{url}"
|
||||
if API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {API_TOKEN}"
|
||||
except Exception:
|
||||
# Hors package (test isolé) : on utilise l'URL telle quelle.
|
||||
pass
|
||||
|
||||
resp = requests.get(full_url, headers=headers, timeout=30, stream=False)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def download_update(
|
||||
plan: dict,
|
||||
staging_dir,
|
||||
downloader: Optional[Callable[[str], bytes]] = None,
|
||||
) -> dict:
|
||||
"""Télécharge le ZIP d'update dans le staging et vérifie son intégrité.
|
||||
|
||||
NE TOUCHE PAS aux fichiers vivants : écrit uniquement dans `staging_dir`
|
||||
(équivalent de `Lea_next\\`). L'application réelle (swap) est un stub
|
||||
réservé révision humaine (voir `apply_update`).
|
||||
|
||||
Args:
|
||||
plan : sortie de `should_update` (target_version, update_type, url, sha256).
|
||||
staging_dir : dossier de staging (créé si absent).
|
||||
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
|
||||
|
||||
Returns:
|
||||
Succès : {ok: True, staged_zip: str, update_type, target_version,
|
||||
sha256_verified: bool}
|
||||
Échec : {ok: False, error: str}
|
||||
Best-effort : aucune exception ne remonte ; un échec laisse le staging propre
|
||||
(pas de ZIP corrompu).
|
||||
"""
|
||||
dl = downloader if downloader is not None else _default_downloader
|
||||
staging = Path(staging_dir)
|
||||
|
||||
try:
|
||||
data = dl(plan["url"])
|
||||
except Exception as e:
|
||||
logger.warning("Téléchargement update échoué : %s", e)
|
||||
return {"ok": False, "error": f"download_failed: {e}"}
|
||||
|
||||
expected_sha = (plan.get("sha256") or "").strip().lower()
|
||||
sha256_verified = False
|
||||
if expected_sha:
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual != expected_sha:
|
||||
logger.warning(
|
||||
"SHA256 mismatch update (attendu=%s, obtenu=%s) — rejeté",
|
||||
expected_sha, actual,
|
||||
)
|
||||
return {"ok": False, "error": "sha256 mismatch — ZIP rejeté"}
|
||||
sha256_verified = True
|
||||
else:
|
||||
# Best-effort : pas de SHA fourni → on accepte mais on le signale.
|
||||
logger.info("Pas de SHA256 fourni pour l'update — intégrité non vérifiée")
|
||||
|
||||
try:
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
target_version = plan.get("target_version", "unknown")
|
||||
staged_zip = staging / f"lea_update_{target_version}.zip"
|
||||
staged_zip.write_bytes(data)
|
||||
except Exception as e:
|
||||
logger.warning("Écriture ZIP staging échouée : %s", e)
|
||||
return {"ok": False, "error": f"staging_write_failed: {e}"}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"staged_zip": str(staged_zip),
|
||||
"update_type": _normalize_update_type(plan.get("update_type")),
|
||||
"target_version": plan.get("target_version"),
|
||||
"sha256_verified": sha256_verified,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interrogation serveur — checker INJECTABLE (GET /agents/update/check)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_update_checker(local_version: str, machine_id: str):
|
||||
"""Interroge le serveur : y a-t-il une MAJ ? (best-effort, INJECTABLE).
|
||||
|
||||
GET SERVER_URL/agents/update/check?current_version=..&machine_id=..
|
||||
(endpoint gated côté serveur — 503 si RPA_AUTO_UPDATE_SERVER_ENABLED OFF,
|
||||
auquel cas on renvoie None : pas de MAJ). Bearer si présent. Pattern aligné
|
||||
sur `log_shipper._default_sender`. INJECTABLE : remplacé par un fake en test.
|
||||
|
||||
Returns:
|
||||
Le dict réponse serveur (`should_update` sait le lire), ou None si
|
||||
indisponible / gated / erreur (jamais d'exception ne remonte).
|
||||
"""
|
||||
try:
|
||||
import requests # import tardif
|
||||
|
||||
headers = {}
|
||||
try:
|
||||
from ..config import SERVER_URL, API_TOKEN
|
||||
|
||||
base = SERVER_URL
|
||||
if API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {API_TOKEN}"
|
||||
except Exception:
|
||||
base = ""
|
||||
url = f"{base}/agents/update/check"
|
||||
resp = requests.get(
|
||||
url,
|
||||
params={"current_version": local_version, "machine_id": machine_id},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
# 503 = endpoint gated OFF côté serveur → pas de MAJ (silencieux).
|
||||
if resp.status_code == 503:
|
||||
return None
|
||||
if not resp.ok:
|
||||
logger.debug("update/check HTTP %s", resp.status_code)
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("update/check indisponible : %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrateur GATED — check → décide → download (staging) → stub apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_update_cycle(
|
||||
local_version: str,
|
||||
machine_id: str,
|
||||
staging_dir,
|
||||
checker: Optional[Callable[[str, str], object]] = None,
|
||||
downloader: Optional[Callable[[str], bytes]] = None,
|
||||
app_dir=None,
|
||||
) -> dict:
|
||||
"""Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap.
|
||||
|
||||
Enchaîne :
|
||||
1. GATE `auto_update_enabled()` (RPA_AUTO_UPDATE_ENABLED, défaut OFF) —
|
||||
si OFF, ne fait STRICTEMENT rien (aucun appel réseau).
|
||||
2. `checker(local_version, machine_id)` → réponse serveur (canary-aware).
|
||||
3. `should_update(...)` → plan (double garde semver, jamais de downgrade).
|
||||
4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche
|
||||
JAMAIS les fichiers vivants.
|
||||
5. `apply_update` ARME le swap (extraction `agent_v1_new/` + marqueur
|
||||
UPDATE_READY) mais NE swappe PAS : le remplacement atomique + le
|
||||
redémarrage sont faits par Lea.bat au prochain démarrage. `applied`
|
||||
reste False tant que Léa n'a pas redémarré sur la nouvelle version.
|
||||
|
||||
Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict
|
||||
d'état pour le diagnostic / le log :
|
||||
status ∈ {disabled, check_failed, up_to_date, download_failed, staged}
|
||||
|
||||
Args:
|
||||
checker : callable `(local_version, machine_id) -> dict|None`
|
||||
INJECTABLE (défaut = HTTP réel vers l'endpoint gated).
|
||||
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
|
||||
"""
|
||||
if not auto_update_enabled():
|
||||
return {"status": "disabled", "applied": False}
|
||||
|
||||
chk = checker if checker is not None else _default_update_checker
|
||||
|
||||
try:
|
||||
server_response = chk(local_version, machine_id)
|
||||
except Exception as e:
|
||||
logger.warning("update check a levé : %s", e)
|
||||
return {"status": "check_failed", "applied": False, "error": str(e)}
|
||||
|
||||
plan = should_update(local_version, server_response)
|
||||
if plan is None:
|
||||
return {"status": "up_to_date", "applied": False}
|
||||
|
||||
staged = download_update(plan, staging_dir, downloader=downloader)
|
||||
if not staged.get("ok"):
|
||||
return {
|
||||
"status": "download_failed",
|
||||
"applied": False,
|
||||
"error": staged.get("error"),
|
||||
}
|
||||
|
||||
# Armement du swap : extraction du ZIP vers agent_v1_new\ + marqueur
|
||||
# UPDATE_READY. Le swap ATOMIQUE (renames) et le redémarrage sont faits
|
||||
# HORS-PROCESS par Lea.bat au prochain démarrage — JAMAIS depuis ici
|
||||
# (on n'écrase pas les fichiers d'un Léa en cours d'exécution).
|
||||
armed = apply_update(staged, app_dir=app_dir)
|
||||
|
||||
return {
|
||||
"status": "armed" if armed.get("armed") else "arm_failed",
|
||||
"applied": False, # le swap effectif est fait par Lea.bat, pas ici
|
||||
"armed": bool(armed.get("armed", False)),
|
||||
"target_version": staged.get("target_version"),
|
||||
"update_type": staged.get("update_type"),
|
||||
"staged_zip": staged.get("staged_zip"),
|
||||
"sha256_verified": staged.get("sha256_verified", False),
|
||||
"marker": armed.get("marker"),
|
||||
"error": armed.get("error"),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SWAP — côté Python : ARMEMENT SEULEMENT (extraction + marqueurs).
|
||||
# Le remplacement ATOMIQUE des fichiers vivants + le redémarrage + le
|
||||
# rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (renames).
|
||||
# Python n'écrase JAMAIS les fichiers d'un Léa en cours d'exécution.
|
||||
# ===========================================================================
|
||||
|
||||
def _resolve_app_dir(app_dir) -> Path:
|
||||
"""Répertoire d'install (contient `agent_v1/`, `run_agent_v1.py`, `Lea.bat`).
|
||||
|
||||
INJECTABLE (tests : tmp_path). Défaut = parent du package agent_v1.
|
||||
"""
|
||||
if app_dir is not None:
|
||||
return Path(app_dir)
|
||||
try:
|
||||
from ..config import BASE_DIR # BASE_DIR = dossier du package agent_v1
|
||||
return Path(BASE_DIR).parent
|
||||
except Exception:
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def apply_update(prepared: dict, app_dir=None) -> dict:
|
||||
"""ARME le swap : extrait le ZIP staging vers `agent_v1_new/` + marqueur.
|
||||
|
||||
NE swappe PAS et NE redémarre PAS (c'est le rôle de `Lea.bat`). Écrit
|
||||
uniquement à côté des fichiers vivants (dossier neuf + marqueur), donc
|
||||
l'opération est sûre même sur un Léa en cours d'exécution.
|
||||
|
||||
1. Extrait `prepared["staged_zip"]` → `<app_dir>/agent_v1_new/`
|
||||
(nettoyé au préalable ; garde-fou zip-slip).
|
||||
2. Écrit `<app_dir>/UPDATE_READY` (JSON : version, type, chemins) que
|
||||
`Lea.bat` lira au prochain démarrage pour faire le swap atomique.
|
||||
|
||||
Best-effort : aucune exception ne remonte (ne doit jamais casser Léa).
|
||||
|
||||
Returns:
|
||||
succès : {armed: True, applied: False, target_version, update_type,
|
||||
marker, extracted_to}
|
||||
échec : {armed: False, applied: False, error}
|
||||
"""
|
||||
if not isinstance(prepared, dict):
|
||||
return {"armed": False, "applied": False, "error": "prepared invalide"}
|
||||
staged_zip = prepared.get("staged_zip")
|
||||
target_version = prepared.get("target_version", "unknown")
|
||||
update_type = _normalize_update_type(prepared.get("update_type"))
|
||||
try:
|
||||
root = _resolve_app_dir(app_dir)
|
||||
zip_path = Path(staged_zip) if staged_zip else None
|
||||
if zip_path is None or not zip_path.is_file():
|
||||
return {"armed": False, "applied": False, "error": "ZIP staging introuvable"}
|
||||
|
||||
new_dir = root / "agent_v1_new"
|
||||
if new_dir.exists():
|
||||
shutil.rmtree(new_dir, ignore_errors=True) # nettoie un staging partiel
|
||||
new_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import zipfile
|
||||
new_root = new_dir.resolve()
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for name in zf.namelist(): # garde-fou zip-slip (chemins ../)
|
||||
dest = (new_dir / name).resolve()
|
||||
if not str(dest).startswith(str(new_root)):
|
||||
shutil.rmtree(new_dir, ignore_errors=True)
|
||||
return {"armed": False, "applied": False,
|
||||
"error": f"zip-slip refusé : {name}"}
|
||||
zf.extractall(new_dir)
|
||||
|
||||
marker = root / "UPDATE_READY"
|
||||
marker.write_text(json.dumps({
|
||||
"target_version": target_version,
|
||||
"update_type": update_type,
|
||||
"extracted_to": str(new_dir),
|
||||
"staged_zip": str(zip_path),
|
||||
}), encoding="utf-8")
|
||||
|
||||
logger.info(
|
||||
"Update ARMÉ : %s (%s) → %s ; swap au prochain démarrage (Lea.bat)",
|
||||
target_version, update_type, new_dir,
|
||||
)
|
||||
return {"armed": True, "applied": False, "target_version": target_version,
|
||||
"update_type": update_type, "marker": str(marker),
|
||||
"extracted_to": str(new_dir)}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("apply_update (armement) a échoué : %s", e)
|
||||
return {"armed": False, "applied": False, "error": f"arm_failed: {e}"}
|
||||
|
||||
|
||||
def write_boot_ok_marker(version: str, app_dir=None) -> dict:
|
||||
"""Confirme un boot sain : écrit `boot_ok_{version}` + désarme le rollback.
|
||||
|
||||
Appelé par `main.py` après ~90 s de tourne STABLE (liveness LOCALE,
|
||||
indépendante du DGX — évite un faux rollback quand le réseau est coupé).
|
||||
Retirer `PENDING_BOOT*` dit à `Lea.bat` que la nouvelle version a démarré
|
||||
correctement (sinon, au prochain lancement, Lea.bat rollback vers la
|
||||
version précédente).
|
||||
|
||||
Best-effort : aucune exception ne remonte.
|
||||
"""
|
||||
try:
|
||||
root = _resolve_app_dir(app_dir)
|
||||
marker = root / f"boot_ok_{version}"
|
||||
marker.write_text("ok", encoding="utf-8")
|
||||
cleared = []
|
||||
for p in root.glob("PENDING_BOOT*"):
|
||||
try:
|
||||
p.unlink()
|
||||
cleared.append(p.name)
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("boot_ok écrit (%s) ; PENDING_BOOT retiré : %s",
|
||||
version, cleared or "aucun")
|
||||
return {"written": True, "marker": str(marker), "cleared_pending": cleared}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("write_boot_ok_marker a échoué : %s", e)
|
||||
return {"written": False, "error": str(e)}
|
||||
@@ -3,6 +3,7 @@ mss>=9.0.1 # Capture d'écran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
|
||||
Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
httpx>=0.27 # Client HTTP orchestrateur Léa (POST /api/learn/start) — brique conversationnelle
|
||||
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
|
||||
|
||||
@@ -29,6 +29,8 @@ from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from ..core.log_safe import _title_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,7 +134,7 @@ class ActivityPanel:
|
||||
)
|
||||
self._notifier_changement()
|
||||
self._rafraichir_ui()
|
||||
logger.info(f"[ACTIVITY] Workflow démarré : {nom} ({nb_etapes} étapes)")
|
||||
logger.info(f"[ACTIVITY] Workflow démarré : [wf_hash={_title_hash(nom)}] ({nb_etapes} étapes)")
|
||||
|
||||
def mettre_a_jour(
|
||||
self,
|
||||
|
||||
@@ -27,6 +27,8 @@ import os
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
from ..core.log_safe import _path_ext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
|
||||
@@ -312,7 +314,7 @@ class _FileActionHandlerLocal:
|
||||
})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
|
||||
logger.info(f"Liste dossier [ext={_path_ext(path_str)}] : {len(files)} fichiers")
|
||||
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
|
||||
|
||||
def _create_dir(self, params: dict) -> dict:
|
||||
@@ -328,7 +330,7 @@ class _FileActionHandlerLocal:
|
||||
target = _Path(path_str)
|
||||
existed = target.exists()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
|
||||
logger.info(f"Dossier [ext={_path_ext(path_str)}] {'existait deja' if existed else 'cree'}")
|
||||
return {"created": not existed, "path": path_str, "already_existed": existed}
|
||||
|
||||
def _move_file(self, params: dict) -> dict:
|
||||
@@ -350,7 +352,7 @@ class _FileActionHandlerLocal:
|
||||
|
||||
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||
_shutil.move(src, dst)
|
||||
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
|
||||
logger.info(f"Fichier deplace : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
|
||||
return {"moved": True, "source": src, "destination": dst}
|
||||
|
||||
def _copy_file(self, params: dict) -> dict:
|
||||
@@ -376,7 +378,7 @@ class _FileActionHandlerLocal:
|
||||
_shutil.copytree(src, dst)
|
||||
else:
|
||||
_shutil.copy2(src, dst)
|
||||
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
|
||||
logger.info(f"Fichier copie : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
|
||||
return {"copied": True, "source": src, "destination": dst}
|
||||
|
||||
def _sort_by_extension(self, params: dict) -> dict:
|
||||
@@ -425,7 +427,7 @@ class _FileActionHandlerLocal:
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(
|
||||
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
|
||||
f"Classement par extension [ext={_path_ext(source_dir_str)}] : {len(moved)} fichiers"
|
||||
)
|
||||
return {
|
||||
"moved": moved,
|
||||
|
||||
197
agent_v0/agent_v1/ui/session_watchdog.py
Normal file
197
agent_v0/agent_v1/ui/session_watchdog.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# agent_v1/ui/session_watchdog.py
|
||||
"""Watchdog de session interactive Windows — résilience RDP/Citrix.
|
||||
|
||||
Problème résolu (preuve poste clinique Émilie, 01/07) :
|
||||
09:46:28 [MAIN] agent.run() est sorti mais agent.running=True — probablement
|
||||
pystray sans session interactive (SSH)
|
||||
09:46:28 [MAIN] Keepalive headless actif — main thread bloque...
|
||||
|
||||
Sur les postes cliniques (tous RDP/Citrix), la session interactive
|
||||
disparaît quand l'utilisateur se déconnecte / la session bascule en
|
||||
verrouillage. `pystray.Icon.run()` sort alors immédiatement (plus de
|
||||
bureau interactif `WinSta0\\Default` pour recevoir les entrées et afficher
|
||||
l'icône). L'ancien `_headless_keepalive` bloquait le main thread *pour
|
||||
toujours* : l'icône tray + la fenêtre chat DISPARAISSAIENT et ne
|
||||
revenaient JAMAIS, même après reconnexion RDP. Les soignants croyaient
|
||||
que Léa avait planté (la capture continuait pourtant en fond).
|
||||
|
||||
Solution : un watchdog qui surveille la disponibilité du bureau
|
||||
interactif via `OpenInputDesktop()` (signal Win32 canonique — échoue quand
|
||||
la session est déconnectée/verrouillée, réussit à la reconnexion) et
|
||||
(re)lance l'UI tray dès qu'une session redevient disponible. Les threads
|
||||
de fond (heartbeat, replay poll, capture_server) NE SONT JAMAIS touchés :
|
||||
ils tournent contre `agent.running` et restent uniques. On ne relance
|
||||
JAMAIS un second `AgentV1` — seulement la couche UI (tray + chat).
|
||||
|
||||
État de l'art (recherche 01/07) :
|
||||
- `OpenInputDesktop()` échoue (ERROR_ACCESS_DENIED / ERROR_INVALID_...)
|
||||
quand le processus n'est pas rattaché au windowstation interactif
|
||||
`WinSta0` — c'est exactement le cas quand la session RDP est
|
||||
déconnectée. C'est la méthode fiable recommandée (comparer les
|
||||
*noms* de bureau via GetUserObjectInformation n'apporte rien de plus
|
||||
ici : on a juste besoin d'un booléen « input desktop dispo ? »).
|
||||
- `WTSGetActiveConsoleSessionId` renvoie une pseudo-session même sans
|
||||
login → PAS fiable pour ce besoin.
|
||||
- `pystray.Icon.run()` ne sort jamais en session interactive normale ;
|
||||
il sort immédiatement sinon → c'est notre signal de « session perdue ».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Intervalle de sondage du bureau interactif (secondes).
|
||||
# 3s = compromis : réactif à la reconnexion sans marteler l'API Win32.
|
||||
POLL_INTERVAL_S = 3.0
|
||||
|
||||
|
||||
def is_interactive_desktop_available() -> bool:
|
||||
"""Retourne True si un bureau interactif Windows est disponible.
|
||||
|
||||
Utilise `OpenInputDesktop()` : succès => le windowstation interactif
|
||||
(`WinSta0\\Default`) est accessible et peut afficher un tray. Échec =>
|
||||
session RDP/Citrix déconnectée ou verrouillée sans bureau d'entrée.
|
||||
|
||||
Hors Windows (Linux/dev/tests) : renvoie toujours True (pas de notion
|
||||
de bureau interactif verrouillable ici — on laisse l'UI tourner).
|
||||
Toute erreur d'appel Win32 est traitée comme « indisponible » (prudent)
|
||||
SAUF l'indisponibilité de l'API elle-même (pywin32 absent) → True pour
|
||||
ne pas priver un poste de son tray à cause d'une dépendance manquante.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return True
|
||||
|
||||
try:
|
||||
import win32con # type: ignore
|
||||
import win32service # type: ignore
|
||||
except Exception:
|
||||
# pywin32 indisponible : on ne peut pas sonder → on suppose dispo
|
||||
# (comportement historique : tenter l'UI plutôt que la bloquer).
|
||||
logger.debug("pywin32 indisponible — sondage bureau interactif ignoré")
|
||||
return True
|
||||
|
||||
hdesk = None
|
||||
try:
|
||||
# DESKTOP_SWITCHDESKTOP (0x0100) = droit minimal, aligné sur l'usage
|
||||
# documenté pour tester la présence du bureau d'entrée.
|
||||
hdesk = win32service.OpenInputDesktop(0, False, win32con.DESKTOP_SWITCHDESKTOP)
|
||||
return hdesk is not None
|
||||
except Exception:
|
||||
# OpenInputDesktop lève quand aucun bureau d'entrée n'est accessible
|
||||
# (session déconnectée / verrouillée). C'est le cas « indisponible ».
|
||||
return False
|
||||
finally:
|
||||
if hdesk is not None:
|
||||
try:
|
||||
# PyHANDLE se ferme via .Close() (pywin32) ; fallback silencieux.
|
||||
hdesk.Close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class InteractiveSessionWatchdog:
|
||||
"""Surveille la session interactive et (re)lance l'UI tray à la reconnexion.
|
||||
|
||||
Ne détient AUCUN état de capture. Sa seule responsabilité : garantir
|
||||
qu'il existe au plus UN tray vivant à la fois, et le ressusciter quand
|
||||
une session interactive redevient disponible. Les daemon threads de
|
||||
l'agent (heartbeat/replay/capture) sont indépendants et intacts.
|
||||
|
||||
Paramètres :
|
||||
run_ui : callable bloquant qui lance le tray (typiquement
|
||||
``agent.ui.run`` / ``agent.run``). Retourne quand le
|
||||
tray sort (normal en fin de session interactive).
|
||||
is_running : callable -> bool ; True tant que l'agent doit vivre
|
||||
(typiquement ``lambda: agent.running``).
|
||||
is_available : callable -> bool de détection de session (injectable
|
||||
pour les tests). Défaut = is_interactive_desktop_available.
|
||||
poll_interval_s : période de sondage quand la session est absente.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_ui: Callable[[], None],
|
||||
is_running: Callable[[], bool],
|
||||
is_available: Optional[Callable[[], bool]] = None,
|
||||
poll_interval_s: float = POLL_INTERVAL_S,
|
||||
) -> None:
|
||||
self._run_ui = run_ui
|
||||
self._is_running = is_running
|
||||
self._is_available = is_available or is_interactive_desktop_available
|
||||
self._poll_interval_s = poll_interval_s
|
||||
self._wake = threading.Event()
|
||||
# Sérialise le lancement de l'UI : jamais deux trays en parallèle.
|
||||
self._ui_lock = threading.Lock()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Réveille le watchdog pour qu'il réévalue ``is_running`` et sorte."""
|
||||
self._wake.set()
|
||||
|
||||
def _run_ui_once(self) -> None:
|
||||
"""Lance l'UI tray une fois (bloquant) sous verrou, avec garde d'erreur.
|
||||
|
||||
Le verrou empêche formellement qu'un second appel démarre un tray
|
||||
alors qu'un premier tourne encore (invariant « un seul tray »).
|
||||
"""
|
||||
with self._ui_lock:
|
||||
try:
|
||||
self._run_ui()
|
||||
except Exception:
|
||||
# Un crash du tray ne doit jamais tuer le watchdog : on log et
|
||||
# on laisse la boucle décider (retry ou sortie selon is_running).
|
||||
logger.exception("[WATCHDOG] Le tray UI a levé une exception")
|
||||
|
||||
def run(self) -> None:
|
||||
"""Boucle principale (bloque le main thread à la place du keepalive).
|
||||
|
||||
Cycle :
|
||||
1. Attendre qu'un bureau interactif soit disponible.
|
||||
2. (Re)lancer le tray — bloque jusqu'à sa sortie (déconnexion RDP).
|
||||
3. Recommencer tant que ``is_running`` est vrai.
|
||||
|
||||
Ne consomme pas de CPU en boucle serrée : sonde toutes les
|
||||
``poll_interval_s`` via un Event interruptible (réveil immédiat au stop).
|
||||
"""
|
||||
logger.info(
|
||||
"[WATCHDOG] Surveillance session interactive active "
|
||||
"(re-affichage auto du tray + chat à la reconnexion RDP/Citrix)."
|
||||
)
|
||||
first_cycle = True
|
||||
|
||||
while self._is_running():
|
||||
if not self._is_available():
|
||||
# Session absente : sonder périodiquement sans brûler le CPU.
|
||||
if first_cycle:
|
||||
logger.warning(
|
||||
"[WATCHDOG] Aucune session interactive — Léa reste active "
|
||||
"en fond (capture/heartbeat), tray masqué. En attente de "
|
||||
"reconnexion RDP/Citrix pour ré-afficher l'interface."
|
||||
)
|
||||
# Event.wait renvoie True si stop() a été appelé → on sort.
|
||||
if self._wake.wait(timeout=self._poll_interval_s):
|
||||
break
|
||||
first_cycle = False
|
||||
continue
|
||||
|
||||
# Session disponible : (re)lancer le tray.
|
||||
if not first_cycle:
|
||||
logger.info(
|
||||
"[WATCHDOG] Session interactive détectée — ré-affichage du "
|
||||
"tray et de la fenêtre chat de Léa."
|
||||
)
|
||||
first_cycle = False
|
||||
|
||||
# Bloque jusqu'à la sortie du tray (fin de session interactive).
|
||||
self._run_ui_once()
|
||||
|
||||
# Le tray est sorti. Si l'agent doit vivre, on reboucle (le
|
||||
# prochain tour re-sondera la session et re-affichera le tray).
|
||||
if not self._is_running():
|
||||
break
|
||||
|
||||
logger.info("[WATCHDOG] Arrêt de la surveillance de session interactive.")
|
||||
@@ -137,6 +137,15 @@ class SmartTrayV1:
|
||||
self._state_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Résilience RDP/Citrix : run() peut être rappelé plusieurs fois par le
|
||||
# watchdog de session (ré-affichage du tray à la reconnexion). Les
|
||||
# threads de fond (connexion, cache workflows, hotkey) et l'accueil ne
|
||||
# doivent démarrer QU'UNE fois — sinon on duplique les threads.
|
||||
self._bg_started = False
|
||||
# Signalé quand l'utilisateur a demandé Quitter : le watchdog ne doit
|
||||
# alors PAS relancer le tray.
|
||||
self._quit_requested = False
|
||||
|
||||
# Notifications
|
||||
self._notifier = NotificationManager()
|
||||
|
||||
@@ -709,6 +718,11 @@ class SmartTrayV1:
|
||||
"""Arrete proprement l'agent et quitte."""
|
||||
logger.info("Arret demande par l'utilisateur")
|
||||
|
||||
# Marquer l'arret volontaire : le watchdog de session ne doit PAS
|
||||
# relancer le tray après un Quitter explicite (à distinguer d'une
|
||||
# simple déconnexion RDP où le tray doit revenir tout seul).
|
||||
self._quit_requested = True
|
||||
|
||||
# Arreter la session si en cours
|
||||
if self.is_recording:
|
||||
self.on_stop()
|
||||
@@ -885,17 +899,24 @@ class SmartTrayV1:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
"""Demarre le tray, les threads de fond, et entre dans la boucle principale."""
|
||||
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
|
||||
self._notifier.greet()
|
||||
"""Demarre (ou ré-affiche) le tray et entre dans la boucle pystray.
|
||||
|
||||
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
|
||||
self._start_hotkey()
|
||||
Ré-entrant : le watchdog de session (session_watchdog.py) rappelle
|
||||
cette méthode à chaque reconnexion RDP/Citrix pour ré-afficher le
|
||||
tray + la fenêtre chat. Les initialisations one-shot (accueil,
|
||||
hotkey, threads de fond connexion/cache) sont protégées par
|
||||
``_bg_started`` pour ne PAS dupliquer les threads. Seule l'icône
|
||||
pystray est recréée à chaque appel (l'ancienne est morte avec la
|
||||
session précédente).
|
||||
"""
|
||||
self._start_background_once()
|
||||
|
||||
# Tooltip avec identifiant machine pour le multi-machine
|
||||
tray_title = f"Agent V1 - {self.machine_id}"
|
||||
|
||||
# Menu statique — reconstruit via _update_icon() quand l'état change
|
||||
# Menu statique — reconstruit via _update_icon() quand l'état change.
|
||||
# Nouvelle icône à chaque (ré)affichage : l'objet pystray précédent
|
||||
# est invalide une fois sa boucle sortie (session interactive perdue).
|
||||
self.icon = pystray.Icon(
|
||||
"AgentV1",
|
||||
self._current_icon(),
|
||||
@@ -903,6 +924,33 @@ class SmartTrayV1:
|
||||
menu=pystray.Menu(*self._get_menu_items()),
|
||||
)
|
||||
|
||||
# Rafraîchir les workflows au (ré)affichage — utile après reconnexion.
|
||||
if self._bg_started and self.server_client is not None:
|
||||
threading.Thread(target=self._fetch_workflows, daemon=True).start()
|
||||
|
||||
# Boucle principale pystray (bloquante). Sort quand la session
|
||||
# interactive disparaît (RDP déconnecté) OU sur _on_quit → le
|
||||
# watchdog décide alors de relancer ou non.
|
||||
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
|
||||
self.icon.run()
|
||||
|
||||
def _start_background_once(self) -> None:
|
||||
"""Initialisations one-shot : accueil, hotkey, threads de fond.
|
||||
|
||||
Idempotent : les appels suivants (ré-affichage tray) sont des no-op.
|
||||
Garantit qu'on n'accumule pas de threads connexion/cache à chaque
|
||||
reconnexion RDP.
|
||||
"""
|
||||
if self._bg_started:
|
||||
return
|
||||
self._bg_started = True
|
||||
|
||||
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
|
||||
self._notifier.greet()
|
||||
|
||||
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
|
||||
self._start_hotkey()
|
||||
|
||||
# Demarrer le thread de verification connexion
|
||||
if self.server_client is not None:
|
||||
conn_thread = threading.Thread(
|
||||
@@ -924,7 +972,3 @@ class SmartTrayV1:
|
||||
threading.Thread(
|
||||
target=self._fetch_workflows, daemon=True
|
||||
).start()
|
||||
|
||||
# Boucle principale pystray (bloquante)
|
||||
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
|
||||
self.icon.run()
|
||||
|
||||
110
agent_v0/agent_v1/vision/capture_io.py
Normal file
110
agent_v0/agent_v1/vision/capture_io.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Politique de sauvegarde des captures — réduction du poids disque.
|
||||
|
||||
Constat : tous les shots étaient sauvés en PNG plein écran lossless
|
||||
(``img.save(path, "PNG", quality=...)`` — PNG ignore ``quality``), d'où
|
||||
~90 Go pour 13 sessions. La majorité de ce poids n'a aucune valeur de
|
||||
grounding (full + full_blurred en doublon, heartbeats plein écran).
|
||||
|
||||
Cette politique distingue le **type** de shot et écrit le format adapté :
|
||||
|
||||
- ``crop`` → PNG lossless. C'est la cible de grounding qwen3-vl ; on
|
||||
préserve chaque pixel (perte JPEG = bruit sur de petites icônes). Le crop
|
||||
fait 80×80 → poids négligeable, aucun intérêt à le dégrader.
|
||||
- ``full`` / ``window`` / ``context`` → JPEG ``quality=SCREENSHOT_QUALITY,
|
||||
optimize=True``. Ce sont des vues contextuelles / humaines : la
|
||||
compression JPEG (~5-10x) est sans impact fonctionnel.
|
||||
- ``heartbeat`` → JPEG **downscalé** (largeur max ``HEARTBEAT_MAX_WIDTH``,
|
||||
ratio préservé). C'est de la *liveness* (le serveur vérifie juste qu'un
|
||||
écran a changé), pas du grounding → la pleine résolution est du gaspillage.
|
||||
|
||||
``save_capture`` retourne le chemin RÉELLEMENT écrit, extension ajustée selon
|
||||
le format. L'appelant doit utiliser ce retour (et non un chemin ``.png``
|
||||
présumé) pour streamer / référencer le bon fichier.
|
||||
|
||||
⚠️ Contrat avec le serveur : l'extension du crop NE DOIT PAS changer (le
|
||||
serveur retrouve le crop par basename via ``vision_info.crop`` — voir
|
||||
``stream_processor._extract_crop_b64`` stratégie 1). C'est pourquoi ``crop``
|
||||
reste PNG. Les full/window/context/heartbeat sont retrouvés par
|
||||
``screenshot_id`` avec extension ``.png`` hardcodée côté serveur, mais le
|
||||
serveur réécrit toujours l'upload sous ``{shot_id}.png`` (le suffixe envoyé
|
||||
sur le fil est ignoré) → changer l'extension LOCALE de ces types est sûr.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ..config import SCREENSHOT_QUALITY
|
||||
|
||||
# Types sauvés en JPEG (vue contextuelle / humaine, pas de grounding pixel).
|
||||
_JPEG_KINDS: frozenset = frozenset({"full", "window", "context"})
|
||||
|
||||
# Largeur max d'un heartbeat downscalé. 1280 px suffit largement pour de la
|
||||
# liveness (détecter qu'un écran a changé) ; on divise le poids d'un 2560 px
|
||||
# par ~4 (surface) avant compression JPEG.
|
||||
HEARTBEAT_MAX_WIDTH = 1280
|
||||
|
||||
|
||||
def _ensure_jpeg_ready(img: Image.Image) -> Image.Image:
|
||||
"""Convertit en RGB si nécessaire (JPEG ne supporte ni alpha ni palette)."""
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
return img.convert("RGB")
|
||||
return img
|
||||
|
||||
|
||||
def _downscale_to_width(img: Image.Image, max_width: int) -> Image.Image:
|
||||
"""Réduit l'image à ``max_width`` en préservant le ratio (no-op si plus petite)."""
|
||||
if img.width <= max_width:
|
||||
return img
|
||||
new_height = max(1, round(img.height * max_width / img.width))
|
||||
return img.resize((max_width, new_height), Image.LANCZOS)
|
||||
|
||||
|
||||
def save_capture(img: Image.Image, path_base: str, kind: str) -> str:
|
||||
"""Sauve ``img`` selon la politique du ``kind`` et retourne le chemin écrit.
|
||||
|
||||
Args:
|
||||
img: image PIL à sauvegarder.
|
||||
path_base: chemin SANS extension (ex.
|
||||
``.../shots/shot_0001_full``). L'extension finale (``.png`` ou
|
||||
``.jpg``) est ajoutée par la politique.
|
||||
kind: type de shot — ``"crop"`` | ``"full"`` | ``"window"`` |
|
||||
``"context"`` | ``"heartbeat"``.
|
||||
|
||||
Returns:
|
||||
Le chemin RÉELLEMENT écrit, avec la bonne extension.
|
||||
|
||||
Raises:
|
||||
ValueError: si ``kind`` n'est pas reconnu (fail-closed : on refuse
|
||||
d'écrire un fichier dont la politique est indéterminée).
|
||||
"""
|
||||
if kind == "crop":
|
||||
out_path = f"{path_base}.png"
|
||||
img.save(out_path, "PNG")
|
||||
return out_path
|
||||
|
||||
if kind in _JPEG_KINDS:
|
||||
out_path = f"{path_base}.jpg"
|
||||
_ensure_jpeg_ready(img).save(
|
||||
out_path, "JPEG", quality=SCREENSHOT_QUALITY, optimize=True
|
||||
)
|
||||
return out_path
|
||||
|
||||
if kind == "heartbeat":
|
||||
out_path = f"{path_base}.jpg"
|
||||
small = _downscale_to_width(_ensure_jpeg_ready(img), HEARTBEAT_MAX_WIDTH)
|
||||
small.save(out_path, "JPEG", quality=SCREENSHOT_QUALITY)
|
||||
return out_path
|
||||
|
||||
raise ValueError(
|
||||
f"kind de capture inconnu : {kind!r} "
|
||||
f"(attendu: crop, full, window, context, heartbeat)"
|
||||
)
|
||||
|
||||
|
||||
def known_kinds() -> Iterable[str]:
|
||||
"""Retourne les ``kind`` supportés (utile pour la validation appelant)."""
|
||||
return ("crop", *sorted(_JPEG_KINDS), "heartbeat")
|
||||
@@ -18,8 +18,9 @@ import platform
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
|
||||
from ..config import TARGETED_CROP_SIZE, BLUR_SENSITIVE
|
||||
from .blur_sensitive import blur_sensitive_regions
|
||||
from .capture_io import save_capture
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -425,6 +426,18 @@ class VisionCapturer:
|
||||
# On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows
|
||||
self.last_img_hash = None
|
||||
|
||||
def _ensure_shots_dir(self) -> None:
|
||||
"""Garantit l'existence de `shots/` avant toute écriture.
|
||||
|
||||
Le dossier est créé dans `__init__`, mais l'auto-cleanup de
|
||||
`SessionStorage` (`shutil.rmtree` par âge/taille) peut supprimer tout
|
||||
le dossier de session — y compris la session permanente `_background`.
|
||||
Sans ce garde, la capture suivante lève `[Errno 2] No such file or
|
||||
directory` (bug observé poste Émilie). On recrée donc le répertoire
|
||||
cible juste avant chaque sauvegarde.
|
||||
"""
|
||||
os.makedirs(self.shots_dir, exist_ok=True)
|
||||
|
||||
def capture_full_context(self, name_suffix: str, force=False) -> str:
|
||||
"""
|
||||
Capture l'écran complet.
|
||||
@@ -460,9 +473,15 @@ class VisionCapturer:
|
||||
if BLUR_SENSITIVE:
|
||||
blur_sensitive_regions(img)
|
||||
|
||||
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
|
||||
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
return path
|
||||
# Politique d'écriture : les heartbeats sont de la liveness pure
|
||||
# (le serveur vérifie juste qu'un écran a changé) → JPEG downscalé.
|
||||
# Les autres contextes (focus_change, result_of_*) → JPEG q85.
|
||||
kind = "heartbeat" if "heartbeat" in name_suffix else "context"
|
||||
self._ensure_shots_dir()
|
||||
path_base = os.path.join(
|
||||
self.shots_dir, f"context_{int(time.time())}_{name_suffix}"
|
||||
)
|
||||
return save_capture(img, path_base, kind)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur Context Capture: {e}")
|
||||
return ""
|
||||
@@ -506,10 +525,10 @@ class VisionCapturer:
|
||||
return result
|
||||
return {}
|
||||
|
||||
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
|
||||
full_base = os.path.join(self.shots_dir, f"{screenshot_id}_full")
|
||||
|
||||
# Capture du Crop (Cœur de l'apprentissage qwen3-vl)
|
||||
crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png")
|
||||
crop_base = os.path.join(self.shots_dir, f"{screenshot_id}_crop")
|
||||
w, h = TARGETED_CROP_SIZE
|
||||
left = max(0, x - w // 2)
|
||||
top = max(0, y - h // 2)
|
||||
@@ -523,8 +542,11 @@ class VisionCapturer:
|
||||
blur_sensitive_regions(img)
|
||||
blur_sensitive_regions(crop_img)
|
||||
|
||||
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
# Politique d'écriture : full = vue contextuelle → JPEG q85 ;
|
||||
# crop = cible de grounding qwen3-vl → PNG lossless (contrat serveur).
|
||||
self._ensure_shots_dir()
|
||||
full_path = save_capture(img, full_base, "full")
|
||||
crop_path = save_capture(crop_img, crop_base, "crop")
|
||||
|
||||
# Mise à jour du hash pour le prochain heartbeat
|
||||
self.last_img_hash = self._compute_quick_hash(img)
|
||||
@@ -648,11 +670,12 @@ class VisionCapturer:
|
||||
if BLUR_SENSITIVE:
|
||||
blur_sensitive_regions(window_img)
|
||||
|
||||
# Sauvegarde
|
||||
window_path = os.path.join(
|
||||
self.shots_dir, f"{screenshot_id}_window.png"
|
||||
# Sauvegarde — fenêtre = vue contextuelle → JPEG q85 (politique).
|
||||
self._ensure_shots_dir()
|
||||
window_base = os.path.join(
|
||||
self.shots_dir, f"{screenshot_id}_window"
|
||||
)
|
||||
window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
window_path = save_capture(window_img, window_base, "window")
|
||||
|
||||
result = {
|
||||
"window_image": window_path,
|
||||
|
||||
@@ -19,6 +19,8 @@ import platform
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .core.log_safe import _title_hash
|
||||
|
||||
|
||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
||||
@@ -372,7 +374,7 @@ if __name__ == "__main__":
|
||||
for i in range(5):
|
||||
info = get_active_window_info()
|
||||
rect = get_active_window_rect()
|
||||
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
|
||||
print(f"[{i+1}] App: {info['app_name']:20s} | Title: [title_hash={_title_hash(info['title'])}]")
|
||||
if rect:
|
||||
print(f" Rect: {rect['rect']} | Size: {rect['size']}")
|
||||
else:
|
||||
|
||||
77
agent_v0/server_v1/agent_logs_store.py
Normal file
77
agent_v0/server_v1/agent_logs_store.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Store des logs poussés par les clients Léa (push-log-DGX).
|
||||
|
||||
Persiste les logs reçus du client, rangés par `machine_id`, pour consultation
|
||||
au dashboard (diagnostic des postes sans AnyDesk). Stockage fichier JSONL
|
||||
(un fichier par jour et par machine_id), rétention configurable.
|
||||
|
||||
DETTE-020/021 (observabilité). Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# machine_id = entrée réseau → neutraliser tout caractère hors liste blanche
|
||||
# (anti path-traversal : '/', '\\', '..' ne doivent pas s'échapper du base_dir).
|
||||
_SAFE_MACHINE_ID_RE = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
class AgentLogsStore:
|
||||
"""Persiste et relit les logs clients rangés par machine_id (JSONL)."""
|
||||
|
||||
def __init__(self, base_dir: str | Path = "data/agent_logs"):
|
||||
self.base_dir = Path(base_dir)
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _machine_dir(self, machine_id: str) -> Path:
|
||||
safe = _SAFE_MACHINE_ID_RE.sub("_", machine_id or "").strip("._") or "unknown"
|
||||
d = self.base_dir / safe
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
def append(self, machine_id: str, entries: list[dict]) -> int:
|
||||
"""Ajoute un batch de logs pour un poste. Retourne le nb de lignes écrites."""
|
||||
if not entries:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc)
|
||||
day_file = self._machine_dir(machine_id) / f"{now.date().isoformat()}.jsonl"
|
||||
with day_file.open("a", encoding="utf-8") as f:
|
||||
for entry in entries:
|
||||
record = dict(entry)
|
||||
record.setdefault("received_at", now.isoformat())
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
return len(entries)
|
||||
|
||||
def read(self, machine_id: str) -> list[dict]:
|
||||
"""Relit toutes les entrées d'un poste, triées par fichier (date) puis ordre d'écriture."""
|
||||
d = self._machine_dir(machine_id)
|
||||
out: list[dict] = []
|
||||
for jsonl in sorted(d.glob("*.jsonl")):
|
||||
with jsonl.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
out.append(json.loads(line))
|
||||
return out
|
||||
|
||||
def purge_old(self, retention_days: int = 30, now: datetime | None = None) -> int:
|
||||
"""Supprime les fichiers-jour antérieurs à la rétention. Retourne le nb supprimé.
|
||||
|
||||
Rétention basée sur la date encodée dans le nom du fichier (`YYYY-MM-DD.jsonl`),
|
||||
pas sur le mtime (déterministe, non altérable). `now` injectable pour les tests.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
cutoff = (now - timedelta(days=retention_days)).date()
|
||||
removed = 0
|
||||
for jsonl in self.base_dir.rglob("*.jsonl"):
|
||||
try:
|
||||
file_date = datetime.strptime(jsonl.stem, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue # nom inattendu → on ne touche pas
|
||||
if file_date < cutoff:
|
||||
jsonl.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
@@ -27,6 +27,7 @@ from fastapi import BackgroundTasks, Depends, FastAPI, File, HTTPException, Requ
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .pii_sanitizer import sanitize_event, sanitize_log_entries
|
||||
from .replay_failure_logger import log_replay_failure
|
||||
from .replay_verifier import ReplayVerifier, VerificationResult
|
||||
from .replay_learner import ReplayLearner
|
||||
@@ -422,6 +423,7 @@ from .replay_engine import (
|
||||
_SERVER_SIDE_ACTION_TYPES,
|
||||
_handle_extract_text_action,
|
||||
_handle_extract_table_action,
|
||||
_handle_extract_dossier_action,
|
||||
_handle_t2a_decision_action,
|
||||
_handle_llm_generate_action,
|
||||
_handle_concat_text_vars_action,
|
||||
@@ -434,6 +436,9 @@ from .replay_engine import (
|
||||
_notify_error_callback as _notify_error_callback_impl,
|
||||
)
|
||||
|
||||
# Navigate handler — import direct depuis core/navigation (pas via replay_engine)
|
||||
from core.navigation import _handle_navigate_action
|
||||
|
||||
|
||||
|
||||
# Wrappers pour les fonctions replay_engine qui accèdent aux variables globales du module.
|
||||
@@ -583,6 +588,17 @@ _AGENTS_DB_PATH = os.environ.get(
|
||||
)
|
||||
agent_registry = AgentRegistry(db_path=_AGENTS_DB_PATH)
|
||||
|
||||
# push-log-DGX : store des logs poussés par les clients, rangés par machine_id
|
||||
# (observabilité des postes sans AnyDesk — DETTE-020/021).
|
||||
from .agent_logs_store import AgentLogsStore # noqa: E402
|
||||
|
||||
_AGENT_LOGS_DIR = os.environ.get(
|
||||
"RPA_AGENT_LOGS_DIR", str(ROOT_DIR / "data" / "agent_logs")
|
||||
)
|
||||
# Garde-fou anti-flood (G3) : nb max d'entrées acceptées par batch.
|
||||
_AGENT_LOGS_MAX_BATCH = int(os.environ.get("RPA_AGENT_LOGS_MAX_BATCH", "1000"))
|
||||
agent_logs_store = AgentLogsStore(base_dir=_AGENT_LOGS_DIR)
|
||||
|
||||
|
||||
def _agent_registry_has_entries() -> bool:
|
||||
try:
|
||||
@@ -1562,6 +1578,16 @@ class AgentUninstallRequest(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class AgentLogsRequest(BaseModel):
|
||||
"""Batch de logs poussé par un client Léa (push-log-DGX).
|
||||
|
||||
`logs` = liste d'entrées {ts, level, logger, message} (format libre côté
|
||||
serveur ; le client garantit le PII-safe avant push).
|
||||
"""
|
||||
machine_id: str
|
||||
logs: list[dict] = []
|
||||
|
||||
|
||||
# Thread de nettoyage périodique des replays terminés et sessions expirées
|
||||
_cleanup_thread: Optional[threading.Thread] = None
|
||||
_cleanup_running = False
|
||||
@@ -1901,6 +1927,11 @@ async def stream_event(data: StreamEvent):
|
||||
# Auto-enregistrer la session si inconnue (robustesse au redémarrage serveur)
|
||||
_ensure_session_registered(session_id, machine_id=machine_id)
|
||||
|
||||
# ── Assainissement PII : sanitize une fois, les 3 chemins reçoivent la copie ──
|
||||
sanitized_event = sanitize_event(
|
||||
data.event, mapping=_session_pii_mapping[session_id]
|
||||
)
|
||||
|
||||
# Persister sur disque (journal JSONL, dans un sous-dossier par machine si multi-machine)
|
||||
if machine_id and machine_id != "default":
|
||||
session_path = LIVE_SESSIONS_DIR / machine_id / session_id
|
||||
@@ -1909,21 +1940,26 @@ async def stream_event(data: StreamEvent):
|
||||
session_path.mkdir(parents=True, exist_ok=True)
|
||||
event_file = session_path / "live_events.jsonl"
|
||||
with open(event_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(data.dict()) + "\n")
|
||||
f.write(json.dumps({
|
||||
"session_id": data.session_id,
|
||||
"timestamp": data.timestamp,
|
||||
"event": sanitized_event,
|
||||
"machine_id": machine_id,
|
||||
}) + "\n")
|
||||
|
||||
# Traitement direct via StreamProcessor
|
||||
result = worker.process_event_direct(session_id, data.event)
|
||||
result = worker.process_event_direct(session_id, sanitized_event)
|
||||
|
||||
# ── Observation Shadow (si mode Shadow activé pour cette session) ──
|
||||
# L'appel est protégé et non bloquant : si l'observer n'est pas
|
||||
# actif, ou s'il lève, la capture continue normalement.
|
||||
shadow_observe_event(session_id, data.event)
|
||||
shadow_observe_event(session_id, sanitized_event)
|
||||
|
||||
# ── Enrichissement SomEngine temps réel pour les mouse_click ──
|
||||
# Après l'enregistrement de l'event, tenter l'enrichissement si le
|
||||
# screenshot est déjà arrivé. Sinon, l'event est mis en attente et
|
||||
# sera enrichi quand le screenshot arrivera (voir stream_image).
|
||||
event = data.event
|
||||
event = sanitized_event
|
||||
if event.get("type") == "mouse_click" and event.get("screenshot_id"):
|
||||
session = processor.session_manager.get_session(session_id)
|
||||
if session:
|
||||
@@ -1941,6 +1977,9 @@ async def stream_event(data: StreamEvent):
|
||||
# =========================================================================
|
||||
|
||||
# Ensemble des screenshots déjà analysés (évite les doublons de retry)
|
||||
# Mapping PII par session — tokens cohérents intra-session (même patient → même [NOM_1])
|
||||
_session_pii_mapping: Dict[str, Dict] = defaultdict(dict)
|
||||
|
||||
_analyzed_shots: Dict[str, set] = defaultdict(set)
|
||||
|
||||
# Hash du dernier screenshot analysé par session (déduplication par similarité)
|
||||
@@ -2337,9 +2376,12 @@ async def stream_image(
|
||||
# Le fichier brut (shot_XXXX_full.png) reste intact pour le replay,
|
||||
# le grounding VLM et l'entraînement. La version floutée est écrite en
|
||||
# parallèle sous shot_XXXX_full_blurred.png.
|
||||
# focus_* : plein écran avec PII dans les titres (blind spot Qwen 28/06,
|
||||
# 1440 fichiers/350 Mo non floutés) — désormais inclus dans le blur.
|
||||
if _PII_BLUR_ENABLED and _blur_pii_on_image is not None and (
|
||||
("_full" in shot_id and shot_id.startswith("shot_"))
|
||||
or shot_id.startswith("heartbeat_")
|
||||
or shot_id.startswith("focus_")
|
||||
):
|
||||
_pii_blur_executor.submit(_produce_blurred_version, file_path_str, shot_id)
|
||||
|
||||
@@ -4405,6 +4447,24 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "extract_dossier":
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_extract_dossier_action,
|
||||
action, owning_replay, session_id,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "navigate":
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_navigate_action,
|
||||
action, owning_replay, session_id,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "t2a_decision":
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
@@ -7200,6 +7260,62 @@ async def agents_fleet():
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/agents/logs")
|
||||
async def agents_logs(request: AgentLogsRequest):
|
||||
"""Réception des logs poussés par un client Léa (push-log-DGX).
|
||||
|
||||
Range les logs par machine_id (AgentLogsStore) pour consultation au
|
||||
dashboard — diagnostic des postes sans AnyDesk. Mêmes garde-fous fleet
|
||||
que stream/poll : un poste révoqué/inconnu est refusé (403).
|
||||
"""
|
||||
machine_id = (request.machine_id or "").strip()
|
||||
if not machine_id:
|
||||
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
|
||||
|
||||
if len(request.logs) > _AGENT_LOGS_MAX_BATCH:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail={
|
||||
"error": "batch_too_large",
|
||||
"max_batch": _AGENT_LOGS_MAX_BATCH,
|
||||
"received": len(request.logs),
|
||||
},
|
||||
)
|
||||
|
||||
# Bloque les postes révoqués/désinstallés + met à jour last_seen_at.
|
||||
_guard_agent_registry_access(machine_id, endpoint="agents/logs")
|
||||
|
||||
# Assainissement PII côté serveur avant persistance (couche 1 regex, sans NER).
|
||||
# Un mapping partagé sur le batch garantit la cohérence des tokens ([NOM_1]…).
|
||||
safe_logs = sanitize_log_entries(request.logs)
|
||||
received = agent_logs_store.append(machine_id, safe_logs)
|
||||
return {"status": "ok", "received": received, "machine_id": machine_id}
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/logs/{machine_id}")
|
||||
async def get_agents_logs(machine_id: str, limit: int = 1000):
|
||||
"""Lecture des logs poussés par un poste (push-log-DGX, brique 3).
|
||||
|
||||
Route de diagnostic dashboard : restitue les logs rangés par machine_id
|
||||
(poste sans AnyDesk). Lecture admin read-only — volontairement SANS garde
|
||||
fleet : on doit pouvoir consulter un poste révoqué ou en panne. Seul le
|
||||
Bearer (dépendance globale `_verify_token`) protège l'accès.
|
||||
|
||||
`limit` borne la réponse aux N entrées les plus récentes (tail) pour éviter
|
||||
de renvoyer plusieurs jours de logs d'un coup.
|
||||
"""
|
||||
entries = agent_logs_store.read(machine_id)
|
||||
total = len(entries)
|
||||
if limit and limit > 0:
|
||||
entries = entries[-limit:]
|
||||
return {
|
||||
"machine_id": machine_id,
|
||||
"total": total,
|
||||
"count": len(entries),
|
||||
"logs": entries,
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# R2 MVP P0 — DialogResolver (catalogue centralisé des modaux runtime)
|
||||
# Flag OFF par défaut. Activer en posant RPA_DIALOG_RESOLVER_ENABLED=true.
|
||||
@@ -7736,6 +7852,81 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request):
|
||||
return payload_out
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# DETTE-022 v2 — GET /api/v1/agents/update/check (MAJ silencieuse client Léa)
|
||||
# Flag OFF par défaut (RPA_AUTO_UPDATE_SERVER_ENABLED). Best-effort, additif :
|
||||
# expose la DÉCISION d'update (logique PURE dans update_check.py, testée hors
|
||||
# serveur — DETTE-013). NE FAIT PAS le swap (réservé révision humaine côté
|
||||
# client + Lea.bat).
|
||||
# =========================================================================
|
||||
from .update_check import decide_update as _decide_update # noqa: E402
|
||||
from .update_policy import ( # noqa: E402
|
||||
resolve_target_version_from_env as _resolve_target_version_from_env,
|
||||
)
|
||||
|
||||
|
||||
def _auto_update_server_enabled() -> bool:
|
||||
"""Flag d'activation serveur — lu à chaque appel (faciliter les tests)."""
|
||||
return os.environ.get("RPA_AUTO_UPDATE_SERVER_ENABLED", "").lower() in (
|
||||
"1", "true", "yes", "on",
|
||||
)
|
||||
|
||||
|
||||
def _latest_agent_version(machine_id: Optional[str] = None) -> str:
|
||||
"""Version d'agent cible POUR CE POSTE (canary-aware, DETTE-022 v2).
|
||||
|
||||
⭐ SÉCURITÉ flotte ⭐ — la version servie est résolue PAR MACHINE via la
|
||||
politique canary (`update_policy.resolve_target_version_from_env`) : un
|
||||
poste canary (Émilie `lea-4zbgwxty`) reçoit la nouvelle version en premier ;
|
||||
tous les autres restent sur le floor stable. Piloté 100 % par env, sans
|
||||
rebuild :
|
||||
RPA_AGENT_STABLE_VERSION (défaut 1.0.1) — servi à toute la flotte.
|
||||
RPA_AGENT_CANARY_VERSION — servi AUX SEULS postes canary.
|
||||
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
|
||||
|
||||
Rétrocompat : si `RPA_AGENT_LATEST_VERSION` (ancienne var globale) est
|
||||
positionnée, elle prime — évite toute régression d'un déploiement existant.
|
||||
"""
|
||||
legacy = os.environ.get("RPA_AGENT_LATEST_VERSION")
|
||||
if legacy:
|
||||
return legacy
|
||||
return _resolve_target_version_from_env(machine_id)
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/update/check")
|
||||
async def check_agent_update(
|
||||
current_version: str,
|
||||
machine_id: Optional[str] = None,
|
||||
update_type: Optional[str] = None,
|
||||
):
|
||||
"""Indiquer au client Léa si une MAJ est disponible (DETTE-022 v2).
|
||||
|
||||
Réponse : {update_available, latest_version, update_type, url}.
|
||||
|
||||
La version cible est résolue PAR MACHINE (canary) : voir
|
||||
`_latest_agent_version`. Un poste hors canary ne se voit JAMAIS proposer la
|
||||
version canary (blast radius borné à la liste canary).
|
||||
|
||||
GATED : si RPA_AUTO_UPDATE_SERVER_ENABLED n'est pas positionné → 503
|
||||
(aucun effet sur le pipeline existant — anti-régression). Auth Bearer
|
||||
requise (dépendance globale `_verify_token`).
|
||||
"""
|
||||
if not _auto_update_server_enabled():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"MAJ auto désactivée (flag RPA_AUTO_UPDATE_SERVER_ENABLED). "
|
||||
"DETTE-022 : endpoint exposé mais OFF par défaut."
|
||||
),
|
||||
)
|
||||
return _decide_update(
|
||||
current_version=current_version,
|
||||
latest_version=_latest_agent_version(machine_id),
|
||||
update_type=update_type,
|
||||
machine_id=machine_id,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
|
||||
273
agent_v0/server_v1/pii_sanitizer.py
Normal file
273
agent_v0/server_v1/pii_sanitizer.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Assainissement PII des données capturées (titres de fenêtre, texte saisi, OCR).
|
||||
|
||||
Côté serveur. Remplace la PII par des **tokens typés et cohérents**
|
||||
(`[IPP_1]`, `[AGE_1]`, `[NOM_1]`…) : on protège la donnée **et** on garde la
|
||||
structure (champ de type NOM/IPP) utile à l'apprentissage des variables.
|
||||
|
||||
Couche 1 (ce module, sans modèle) : filet **regex** sur la PII structurée
|
||||
(IPP, NIR, téléphone, email, âge) + règles **structurelles** des titres
|
||||
cliniques (`NOM (NAISSANCE) Prénom`, `[Nom Prénom]` des fenêtres PACS). Regex
|
||||
réutilisées du projet `anonymisation`.
|
||||
Couche 2 (à venir) : NER CamemBERT-bio (ONNX) pour les noms libres que la
|
||||
couche 1 ne capte pas — branchée plus tard, ce module marche sans.
|
||||
|
||||
Branche feat/push-log-dgx — assainissement PII clinique.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# --- Filet regex (réutilisé de anonymisation/anonymizer_core_refactored_onnx.py) ---
|
||||
RE_IPP = re.compile(r"\b(?:I\.?P\.?P\.?|IPP|N°\s*Ipp)\s*[:\-]?\s*([A-Za-z0-9]{6,})\b", re.IGNORECASE)
|
||||
RE_NIR = re.compile(r"(?<!\d)[12]\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{3}\s?\d{3}\s?\d{2}(?!\d)")
|
||||
RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
RE_TEL = re.compile(r"(?<!\d)(?:\+33\s?|0)\d(?:[ .\-]?\d){8}(?!\d)")
|
||||
# Âge format « titre » (« 90 ans »), plus large que le regex prose de anonymisation.
|
||||
RE_AGE = re.compile(r"\b(\d{1,3})\s*ans\b", re.IGNORECASE)
|
||||
|
||||
_MAJ = r"A-ZÉÈÀÂÊÎÔÛÄËÏÖÜÇ"
|
||||
_MIN = r"a-zàâäéèêëïîôöùûüç"
|
||||
# Format clinique « NOM (NOM_NAISSANCE) Prénom » (ex. « ROSSIGNOL (SOUBIE) Pierrette »).
|
||||
RE_NOM_NAISSANCE = re.compile(
|
||||
rf"\b[{_MAJ}][{_MAJ}\-']+\s+\([{_MAJ}][{_MAJ}\-']+\)\s+[{_MAJ}][{_MIN}\-']+\b"
|
||||
)
|
||||
# Patient entre crochets des fenêtres PACS (ex. « [DATTIN Alix] »), ≥ 2 tokens capitalisés.
|
||||
RE_NOM_BRACKET = re.compile(
|
||||
rf"\[((?:[{_MAJ}][\w{_MIN}'\-]*\s+){{1,3}}[{_MAJ}][\w{_MIN}'\-]*)\]"
|
||||
)
|
||||
# « Prénom NOM » inversé, sans parenthèses ni crochets (ex. « Alix DATTIN »).
|
||||
# 2e mot tout en MAJUSCULES → faible risque de FP (« Mozilla Firefox » ne matche pas).
|
||||
RE_PRENOM_NOM = re.compile(rf"\b[{_MAJ}][{_MIN}]+\s+[{_MAJ}][{_MAJ}\-']+\b")
|
||||
|
||||
# GXD5 Diagnostics : numéro de dossier + nom patient tout-majuscules.
|
||||
# Format réel : « GXD5 Diagnostics - 128008 - BENVENISTE MARIE-LAURENCE »
|
||||
# Le numéro (128008) = ID dossier patient (PII). Le nom = PII.
|
||||
# 2 groupes de capture : (1)=numéro, (2)=nom complet.
|
||||
RE_GXD5_DIAG = re.compile(
|
||||
rf"GXD5\s+Diagnostics\s*-\s*(\d+)\s*-\s*([{_MAJ}][{_MAJ}\-' ]+)"
|
||||
)
|
||||
|
||||
# Ordre = priorité ; group = portion à remplacer (0 = match entier).
|
||||
_DETECTORS: List[Tuple[re.Pattern, str, int]] = [
|
||||
(RE_NOM_NAISSANCE, "NOM", 0),
|
||||
(RE_NOM_BRACKET, "NOM", 0),
|
||||
(RE_GXD5_DIAG, "DOSSIER", 1), # numéro de dossier
|
||||
(RE_PRENOM_NOM, "NOM", 0),
|
||||
(RE_EMAIL, "EMAIL", 0),
|
||||
(RE_NIR, "NIR", 0),
|
||||
(RE_IPP, "IPP", 1),
|
||||
(RE_TEL, "TEL", 0),
|
||||
(RE_AGE, "AGE", 0),
|
||||
]
|
||||
# GXD5 nom (groupe 2) traité séparément — même regex, priorité juste après.
|
||||
_DETECTORS.append((RE_GXD5_DIAG, "NOM", 2))
|
||||
|
||||
# Anti-faux-positifs : termes logiciels/UI à ne jamais prendre pour un nom.
|
||||
# (Sous-ensemble inline ; les gazetteers complets arrivent avec la couche NER.)
|
||||
_SOFTWARE_BLACKLIST = {
|
||||
"FIREFOX", "MOZILLA", "CHROME", "EDGE", "EXPERT", "SANTE", "SANTÉ", "PACS",
|
||||
"CIM", "ARES", "EASILY", "CONSULTATION", "URGENCES", "SAISIE", "COURRIER",
|
||||
"DOSSIER", "PATIENT", "FENETRE", "FENÊTRE", "GXD", "WINDOWS", "CITRIX",
|
||||
}
|
||||
|
||||
|
||||
def _normalize(etype: str, value: str) -> str:
|
||||
"""Clé de cohérence : même entité -> même token."""
|
||||
if etype in ("IPP", "NIR", "TEL"):
|
||||
return re.sub(r"\s+", "", value)
|
||||
if etype == "EMAIL":
|
||||
return value.lower()
|
||||
return re.sub(r"\s+", " ", value).strip().upper()
|
||||
|
||||
|
||||
def _is_blacklisted_name(value: str) -> bool:
|
||||
toks = [t for t in re.split(r"[^\wÀ-ÿ]+", value) if t]
|
||||
return bool(toks) and all(t.upper() in _SOFTWARE_BLACKLIST for t in toks)
|
||||
|
||||
|
||||
def _assign_token(mapping: Dict, etype: str, norm: str) -> str:
|
||||
key = (etype, norm)
|
||||
if key in mapping:
|
||||
return mapping[key]
|
||||
n = 1 + sum(1 for k in mapping if isinstance(k, tuple) and k[0] == etype)
|
||||
token = f"[{etype}_{n}]"
|
||||
mapping[key] = token
|
||||
return token
|
||||
|
||||
|
||||
def anonymize_text(
|
||||
text: str, *, mapping: Optional[Dict] = None
|
||||
) -> Tuple[str, List[Dict]]:
|
||||
"""Remplace la PII de `text` par des tokens typés cohérents.
|
||||
|
||||
`mapping` : table de cohérence partagée (ex. à l'échelle d'une session) —
|
||||
la même valeur PII reçoit le même token d'un appel à l'autre. Mutée en place ;
|
||||
si None, une table locale est utilisée.
|
||||
|
||||
Retourne `(texte_assaini, entités)` où chaque entité =
|
||||
`{"type", "original", "token", "start", "end"}` (positions dans le texte source).
|
||||
"""
|
||||
if not text:
|
||||
return text, []
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
|
||||
# 1) collecte des candidats (start, end, type, valeur)
|
||||
spans: List[Tuple[int, int, str, str]] = []
|
||||
for pattern, etype, group in _DETECTORS:
|
||||
for m in pattern.finditer(text):
|
||||
start, end = m.span(group)
|
||||
if start == end:
|
||||
continue
|
||||
value = m.group(group)
|
||||
if etype == "NOM" and _is_blacklisted_name(value):
|
||||
continue
|
||||
spans.append((start, end, etype, value))
|
||||
|
||||
# 2) résolution des chevauchements (priorité = rang détecteur, puis -longueur)
|
||||
# _DETECTORS est ordonné par priorité ; le rang dans cette liste détermine
|
||||
# qui gagne quand deux patterns chevauchent. Plus prioritaire + plus long
|
||||
# = accepté en premier, les plus courts/moins prioritaires sont éliminés.
|
||||
# Fix FN « Dossier VIOLA (VIOLA) Liliane » : RE_PRENOM_NOM captait
|
||||
# « Dossier VIOLA » (rang 2) et bloquait RE_NOM_NAISSANCE « VIOLA (VIOLA)
|
||||
# Liliane » (rang 0, plus prioritaire et plus long).
|
||||
det_rank = {p: i for i, (p, _, _) in enumerate(_DETECTORS)}
|
||||
spans.sort(key=lambda s: (det_rank.get(s[2], 999), -(s[1] - s[0]), s[0]))
|
||||
occupied: List[Tuple[int, int]] = []
|
||||
accepted: List[Tuple[int, int, str, str]] = []
|
||||
for start, end, etype, value in spans:
|
||||
if all(start >= oe or end <= os for os, oe in occupied):
|
||||
accepted.append((start, end, etype, value))
|
||||
occupied.append((start, end))
|
||||
|
||||
# 3) substitution (de droite à gauche pour préserver les indices)
|
||||
entities: List[Dict] = []
|
||||
out = text
|
||||
for start, end, etype, value in sorted(accepted, key=lambda s: s[0], reverse=True):
|
||||
token = _assign_token(mapping, etype, _normalize(etype, value))
|
||||
out = out[:start] + token + out[end:]
|
||||
entities.append(
|
||||
{"type": etype, "original": value, "token": token, "start": start, "end": end}
|
||||
)
|
||||
entities.reverse()
|
||||
return out, entities
|
||||
|
||||
|
||||
# Clés portant un titre de fenêtre, où qu'elles soient imbriquées dans l'event
|
||||
# (top-level `active_window_title`, `window/to/from.title`, et surtout
|
||||
# `vision_info.window_capture.window_title` — blind spot signalé par Qwen).
|
||||
_TITLE_KEYS = ("title", "window_title", "active_window_title")
|
||||
_PLACEHOLDER_SAISIE = "[SAISIE]"
|
||||
|
||||
|
||||
def _walk_titles(obj, mapping: Dict) -> None:
|
||||
"""Parcourt récursivement l'event et assainit toute valeur de titre de fenêtre."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k in _TITLE_KEYS and isinstance(v, str):
|
||||
obj[k] = anonymize_text(v, mapping=mapping)[0]
|
||||
else:
|
||||
_walk_titles(v, mapping)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_walk_titles(item, mapping)
|
||||
|
||||
|
||||
def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict:
|
||||
"""Assainit un event capturé avant persistance (copie, ne mute pas l'original).
|
||||
|
||||
Principe « Léa apprend l'interface, pas la donnée » (décision Dom 28/06) :
|
||||
- `text_input` : le **contenu tapé** (`text`, `raw_keys`) = donnée de santé →
|
||||
remplacé par `[SAISIE]` (on garde le champ, pas la valeur — option b) ;
|
||||
- **titres de fenêtre** (`active_window_title`, et `title` dans `window`/`to`/
|
||||
`from`) : l'**identité patient** est tokenisée, l'app/écran est gardé
|
||||
(contexte d'apprentissage), via `anonymize_text` + `mapping` partagé (cohérence).
|
||||
"""
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
ev = copy.deepcopy(event)
|
||||
|
||||
# text_input : on ne garde pas le contenu
|
||||
if ev.get("type") == "text_input":
|
||||
for k in ("text", "raw_keys"):
|
||||
if ev.get(k) not in (None, ""):
|
||||
ev[k] = _PLACEHOLDER_SAISIE
|
||||
|
||||
# tous les titres de fenêtre, où qu'ils soient imbriqués
|
||||
# (active_window_title, window/to/from.title, vision_info.window_capture.window_title…)
|
||||
_walk_titles(ev, mapping)
|
||||
|
||||
return ev
|
||||
|
||||
|
||||
def sanitize_log_entries(
|
||||
entries: List[Dict], *, mapping: Optional[Dict] = None
|
||||
) -> List[Dict]:
|
||||
"""Assainit un batch de log-entries reçues d'un client Léa avant persistance.
|
||||
|
||||
Pour chaque entrée, renvoie une **copie** où les champs texte porteurs de PII
|
||||
sont passés par `anonymize_text` :
|
||||
- `message` (str) : assaini par `anonymize_text`.
|
||||
- `logger` (str) : assaini de la même façon (peut porter un chemin patient).
|
||||
- `ts` et `level` : préservés à l'identique, jamais touchés.
|
||||
|
||||
Un `mapping` partagé est utilisé pour **toutes** les entrées du batch afin de
|
||||
garantir la cohérence des tokens (même PII → même token). Si `mapping` est
|
||||
None, un mapping local est créé et partagé entre toutes les entrées du batch.
|
||||
|
||||
Tolère les valeurs absentes, None ou non-str sans lever d'exception.
|
||||
N'utilise que `anonymize_text` — aucune regex supplémentaire.
|
||||
"""
|
||||
if not entries:
|
||||
return []
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
|
||||
result: List[Dict] = []
|
||||
for entry in entries:
|
||||
item = copy.copy(entry) # copie superficielle suffit (valeurs scalaires)
|
||||
for field in ("message", "logger"):
|
||||
v = item.get(field)
|
||||
if isinstance(v, str):
|
||||
item[field] = anonymize_text(v, mapping=mapping)[0]
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
# Clés d'un workflow core portant du texte potentiellement PII : cible OCR
|
||||
# (`by_text`), noms d'écrans/labels dérivés des titres. Le contenu saisi est
|
||||
# déjà neutralisé à la source (sanitize_event → [SAISIE]).
|
||||
_WORKFLOW_TEXT_KEYS = ("by_text", "name", "label")
|
||||
|
||||
|
||||
def _walk_workflow_text(obj, mapping: Dict) -> None:
|
||||
"""Parcourt un workflow core et tokenise la PII des champs texte (cibles, noms)."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k in _WORKFLOW_TEXT_KEYS and isinstance(v, str) and v:
|
||||
obj[k] = anonymize_text(v, mapping=mapping)[0]
|
||||
else:
|
||||
_walk_workflow_text(v, mapping)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_walk_workflow_text(item, mapping)
|
||||
|
||||
|
||||
def sanitize_workflow_dict(workflow_dict: Dict, *, mapping: Optional[Dict] = None) -> Dict:
|
||||
"""Assainit un workflow core (JSON appris) avant import/persistance en DB VWB.
|
||||
|
||||
Tokenise la PII des champs texte (cible OCR `by_text`, noms d'écrans, labels)
|
||||
via `anonymize_text`, en gardant l'interface intacte (« Léa apprend
|
||||
l'interface, pas la donnée »). Copie — l'original n'est pas muté.
|
||||
|
||||
Limite (couche 1) : ne capte que la PII structurée (IPP, NOM clinique…) ;
|
||||
les noms libres relèvent de la couche 2 NER.
|
||||
"""
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
wf = copy.deepcopy(workflow_dict)
|
||||
_walk_workflow_text(wf, mapping)
|
||||
return wf
|
||||
@@ -40,6 +40,8 @@ _ALLOWED_ACTION_TYPES = {
|
||||
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
|
||||
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
|
||||
"extract_table", # OCR serveur + filtre regex → liste structurée (boucle)
|
||||
"extract_dossier", # OCR grille structurée → dossier patient persisté (brique 3)
|
||||
"navigate", # Navigation visuelle → coords login/recherche (brique navigation)
|
||||
"extract_text_scroll", # Marker côté graphe — expansé en sous-actions par _edge_to_normalized_actions
|
||||
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
|
||||
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
|
||||
@@ -53,6 +55,8 @@ _ALLOWED_ACTION_TYPES = {
|
||||
_SERVER_SIDE_ACTION_TYPES = {
|
||||
"extract_text",
|
||||
"extract_table",
|
||||
"extract_dossier",
|
||||
"navigate",
|
||||
"t2a_decision",
|
||||
"llm_generate",
|
||||
"_concat_text_vars",
|
||||
@@ -2216,6 +2220,146 @@ def _handle_extract_table_action(
|
||||
return bool(rows)
|
||||
|
||||
|
||||
def _resolve_screenshot_path(replay_state: Dict[str, Any]) -> Optional[str]:
|
||||
"""Résout le chemin du dernier screenshot (path disque ou base64 → temp).
|
||||
|
||||
Calque la source utilisée par extract_text/extract_table : priorité au
|
||||
``last_screenshot`` (path ou data-URI base64). Retourne None si absent.
|
||||
"""
|
||||
raw_screenshot = replay_state.get("last_screenshot") or ""
|
||||
if not raw_screenshot:
|
||||
return None
|
||||
if raw_screenshot.startswith("data:"):
|
||||
try:
|
||||
import base64 as _b64, tempfile
|
||||
header, b64data = raw_screenshot.split(",", 1)
|
||||
suffix = ".jpg" if "jpeg" in header else ".png"
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp.write(_b64.b64decode(b64data))
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
except Exception as e:
|
||||
logger.warning("extract_dossier: décodage base64 screenshot échoué: %s", e)
|
||||
return None
|
||||
if os.path.isfile(raw_screenshot):
|
||||
return raw_screenshot
|
||||
return None
|
||||
|
||||
|
||||
def _gate_dossier_quality(
|
||||
grid: List[List[Dict[str, Any]]],
|
||||
*,
|
||||
min_confidence: float,
|
||||
expected_cols: Optional[int],
|
||||
) -> str:
|
||||
"""Gate qualité simple → 'complete' ou 'needs_review'.
|
||||
|
||||
'complete' SSI : grille non vide ET confiance médiane ≥ seuil ET (si
|
||||
expected_cols fourni) au moins une ligne avec ce nombre de colonnes.
|
||||
Sinon 'needs_review'. Volontairement conservatrice (default-review).
|
||||
"""
|
||||
confs = [
|
||||
cell.get("confidence")
|
||||
for row in grid for cell in row
|
||||
if isinstance(cell.get("confidence"), (int, float))
|
||||
]
|
||||
if not confs:
|
||||
return "needs_review"
|
||||
confs.sort()
|
||||
median = confs[len(confs) // 2]
|
||||
if median < min_confidence:
|
||||
return "needs_review"
|
||||
if expected_cols is not None:
|
||||
if not any(len(row) == expected_cols for row in grid):
|
||||
return "needs_review"
|
||||
return "complete"
|
||||
|
||||
|
||||
def _handle_extract_dossier_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Traite une action extract_dossier côté serveur (brique 3).
|
||||
|
||||
Lit le dernier screenshot, extrait une grille structurée via
|
||||
``extract_grid_from_image``, applique une gate qualité, puis PERSISTE un
|
||||
« dossier patient extrait » (Job/Table/Field) dans la DB VWB avec preuve
|
||||
(screenshot_ref + screen_bbox + confidences). Le job_id est stocké dans
|
||||
``replay_state["variables"][output_var]``.
|
||||
|
||||
Paramètres reconnus (action.parameters) :
|
||||
output_var : nom de variable runtime (default "extracted_dossier")
|
||||
patient_ref : référence patient EN CLAIR (volontaire) — non tokenisée
|
||||
region : (x, y, w, h) px pour cropper avant OCR (None = plein)
|
||||
min_confidence : seuil de confiance médiane pour 'complete' (default 0.6)
|
||||
expected_cols : nb de colonnes attendu (optionnel) pour la gate
|
||||
|
||||
N'ÉCHOUE JAMAIS le replay : toute erreur → log + needs_review.
|
||||
Retourne True SSI le dossier est persisté avec statut 'complete'.
|
||||
"""
|
||||
params = action.get("parameters") or {}
|
||||
output_var = (params.get("output_var") or params.get("variable_name") or "extracted_dossier").strip()
|
||||
patient_ref = params.get("patient_ref")
|
||||
region = params.get("region") or None
|
||||
try:
|
||||
min_confidence = float(params.get("min_confidence", 0.6))
|
||||
except (TypeError, ValueError):
|
||||
min_confidence = 0.6
|
||||
expected_cols = params.get("expected_cols")
|
||||
if isinstance(expected_cols, str):
|
||||
try:
|
||||
expected_cols = int(expected_cols)
|
||||
except ValueError:
|
||||
expected_cols = None
|
||||
|
||||
job_id = ""
|
||||
status = "needs_review"
|
||||
try:
|
||||
path = _resolve_screenshot_path(replay_state)
|
||||
grid: List[List[Dict[str, Any]]] = []
|
||||
if path:
|
||||
from core.llm import extract_grid_from_image
|
||||
grid = extract_grid_from_image(
|
||||
path, region=tuple(region) if region else None
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"extract_dossier : pas de screenshot pour session %s — needs_review",
|
||||
session_id,
|
||||
)
|
||||
|
||||
status = _gate_dossier_quality(
|
||||
grid, min_confidence=min_confidence, expected_cols=expected_cols
|
||||
)
|
||||
|
||||
from . import vwb_db
|
||||
with vwb_db.vwb_app_context():
|
||||
job_id = vwb_db.persist_extracted_dossier(
|
||||
grid,
|
||||
patient_ref=patient_ref,
|
||||
source_session_id=session_id,
|
||||
screenshot_ref=path,
|
||||
screen_bbox=({"x": region[0], "y": region[1], "width": region[2], "height": region[3]}
|
||||
if region and len(region) == 4 else None),
|
||||
status=status,
|
||||
)
|
||||
except Exception as e:
|
||||
# Ne JAMAIS échouer le replay : on log, on marque needs_review.
|
||||
logger.warning(
|
||||
"extract_dossier : échec persistance (%s) — needs_review, replay %s",
|
||||
e, replay_state.get("replay_id", "?"),
|
||||
)
|
||||
status = "needs_review"
|
||||
|
||||
replay_state.setdefault("variables", {})[output_var] = job_id
|
||||
logger.info(
|
||||
"extract_dossier → variable '%s' job=%s statut=%s replay %s",
|
||||
output_var, job_id or "?", status, replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return status == "complete"
|
||||
|
||||
|
||||
def _handle_t2a_decision_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
|
||||
@@ -3066,6 +3066,8 @@ class StreamProcessor:
|
||||
saved_path = self._persist_workflow(workflow, session_id, machine_id=machine_id)
|
||||
# Stocker le machine_id dans le workflow pour le filtrage
|
||||
workflow._machine_id = machine_id
|
||||
# R1 : import auto en DB VWB (rejouable) — gated RPA_R1_AUTO_IMPORT, non bloquant.
|
||||
self._maybe_import_to_vwb(workflow, session_id, machine_id)
|
||||
|
||||
# Récupérer les métadonnées applicatives de la session
|
||||
session_state = self.session_manager.get_session(session_id)
|
||||
@@ -4444,6 +4446,45 @@ class StreamProcessor:
|
||||
logger.error(f"Erreur sauvegarde workflow {session_id}: {e}")
|
||||
return None
|
||||
|
||||
def _import_workflow_to_vwb(self, workflow, session_id: str, machine_id: str) -> Dict[str, Any]:
|
||||
"""Importer le workflow appris dans la DB VWB rejouable (Maillon A / R1).
|
||||
|
||||
Rend l'appris rejouable sans geste manuel, de façon idempotente (fusion
|
||||
par signature de trajectoire). Suppose un app-context VWB actif fournissant
|
||||
``db.session`` (créé par l'appelant côté worker).
|
||||
"""
|
||||
from .pii_sanitizer import sanitize_workflow_dict
|
||||
from services.learned_workflow_bridge import import_core_workflow_to_db
|
||||
from db.models import db
|
||||
# Assainir la PII (cibles OCR `by_text`, noms) avant dépôt en DB VWB.
|
||||
core_dict = sanitize_workflow_dict(workflow.to_dict())
|
||||
return import_core_workflow_to_db(
|
||||
core_dict,
|
||||
machine_id=machine_id,
|
||||
source_session_id=session_id,
|
||||
db_session=db.session,
|
||||
)
|
||||
|
||||
def _vwb_app_context(self):
|
||||
"""Couplage worker→DB VWB mutualisé (un seul pont, cf. vwb_db).
|
||||
|
||||
Délègue au helper module ``vwb_db.vwb_app_context`` partagé entre R1 et
|
||||
l'extraction métier — pas de duplication de l'app Flask/init_app.
|
||||
"""
|
||||
from .vwb_db import vwb_app_context
|
||||
return vwb_app_context()
|
||||
|
||||
def _maybe_import_to_vwb(self, workflow, session_id: str, machine_id: str) -> None:
|
||||
"""Import auto de l'appris en DB VWB, gated par RPA_R1_AUTO_IMPORT (OFF
|
||||
par défaut) et NON bloquant : un échec ne casse jamais la finalisation."""
|
||||
if os.environ.get("RPA_R1_AUTO_IMPORT", "false").lower() not in ("true", "1", "yes"):
|
||||
return
|
||||
try:
|
||||
with self._vwb_app_context():
|
||||
self._import_workflow_to_vwb(workflow, session_id, machine_id)
|
||||
except Exception as e:
|
||||
logger.warning("[R1] import VWB auto échoué (non bloquant): %s", e)
|
||||
|
||||
def _build_raw_session_fallback(self, session, raw_dict):
|
||||
"""Construire un RawSession manuellement si from_dict échoue."""
|
||||
from core.models.raw_session import RawSession, Event, Screenshot, RawWindowContext
|
||||
|
||||
138
agent_v0/server_v1/update_check.py
Normal file
138
agent_v0/server_v1/update_check.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# agent_v0/server_v1/update_check.py
|
||||
"""Logique PURE de décision de mise à jour du client Léa (DETTE-022 v2).
|
||||
|
||||
But : centraliser, SANS dépendance FastAPI, le cœur testable de la MAJ
|
||||
silencieuse :
|
||||
|
||||
- `parse_version()` (R3) : parse une version semver en tuple d'entiers, pour
|
||||
une comparaison correcte ("1.0.2" < "1.0.10" — le piège lexicographique
|
||||
classique). Tolérant : préfixe « v », espaces, et format invalide → fallback
|
||||
`(0,)` (la plus basse) SANS jamais lever.
|
||||
- `decide_update()` (R2) : compare la version courante à la dernière dispo,
|
||||
choisit l'`update_type` (`code-only` par défaut, ~500 Ko / `full` ~33 Mo
|
||||
rare) et construit la réponse
|
||||
`{update_available, latest_version, update_type, url}`.
|
||||
|
||||
Ce module est volontairement IMPORTABLE seul (aucun import lourd, pas de
|
||||
`api_stream`) pour être testé sans démarrer le serveur (DETTE-013). Le
|
||||
branchement HTTP (endpoint gated) vit dans `api_stream.py`.
|
||||
|
||||
⚠️ Cette brique ne fait QUE décider. Le swap réel des fichiers, l'édition de
|
||||
Lea.bat et le redémarrage sont HORS de ce module (réservé révision humaine).
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# Niveaux de livraison valides (R2). `code-only` par défaut = 99 % des MAJ.
|
||||
VALID_UPDATE_TYPES = ("code-only", "full")
|
||||
DEFAULT_UPDATE_TYPE = "code-only"
|
||||
|
||||
# Fallback de version « la plus basse » pour une chaîne illisible : ainsi une
|
||||
# version valide est toujours > à une version invalide, et une *latest* illisible
|
||||
# ne déclenche jamais de MAJ douteuse.
|
||||
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
|
||||
|
||||
|
||||
def parse_version(v) -> Tuple[int, ...]:
|
||||
"""Parse une version semver en tuple d'entiers (R3).
|
||||
|
||||
"1.0.2" → (1, 0, 2), "1.0.10" → (1, 0, 10), "v1.2.3" → (1, 2, 3).
|
||||
|
||||
Tolérant et SANS exception : préfixe « v/V » et espaces tolérés ; tout
|
||||
format non numérique (vide, None, "abc", "1.x.3") retombe sur `(0,)`.
|
||||
|
||||
Stratégie : `packaging.version` si présent (déjà dans le venv via
|
||||
setuptools/pip), sinon parse manuel. Aucune nouvelle dépendance.
|
||||
"""
|
||||
if not isinstance(v, str):
|
||||
return _FALLBACK_VERSION
|
||||
s = v.strip().lstrip("vV").strip()
|
||||
if not s:
|
||||
return _FALLBACK_VERSION
|
||||
try:
|
||||
from packaging.version import Version
|
||||
|
||||
return tuple(Version(s).release)
|
||||
except Exception:
|
||||
# packaging absent (python-embed minimal) OU version non-PEP440.
|
||||
pass
|
||||
try:
|
||||
return tuple(int(x) for x in s.split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return _FALLBACK_VERSION
|
||||
|
||||
|
||||
def is_newer(candidate: str, baseline: str) -> bool:
|
||||
"""True si `candidate` est strictement plus récent que `baseline` (semver)."""
|
||||
return parse_version(candidate) > parse_version(baseline)
|
||||
|
||||
|
||||
def _normalize_update_type(update_type: Optional[str]) -> str:
|
||||
"""Normalise l'update_type sur un niveau valide (défaut code-only)."""
|
||||
if update_type in VALID_UPDATE_TYPES:
|
||||
return update_type
|
||||
return DEFAULT_UPDATE_TYPE
|
||||
|
||||
|
||||
def build_download_url(
|
||||
machine_id: Optional[str],
|
||||
version: str,
|
||||
update_type: str,
|
||||
) -> str:
|
||||
"""Construit l'URL de téléchargement RELATIVE (R2, 2 niveaux).
|
||||
|
||||
Forme alignée sur les endpoints fleet existants :
|
||||
/api/fleet/download/<machine_id>?type=<update_type>&version=<version>
|
||||
|
||||
On garde une URL relative : le client la résout contre son SERVER_BASE.
|
||||
`machine_id` absent → segment « default » (rétrocompatible).
|
||||
"""
|
||||
mid = (machine_id or "default").strip() or "default"
|
||||
return f"/api/fleet/download/{mid}?type={update_type}&version={version}"
|
||||
|
||||
|
||||
def decide_update(
|
||||
current_version: str,
|
||||
latest_version: str,
|
||||
update_type: Optional[str] = None,
|
||||
machine_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Décision PURE de mise à jour (R2 + R3).
|
||||
|
||||
Compare `current_version` à `latest_version` en semver. Si la dernière est
|
||||
strictement plus récente, construit une réponse d'update ; sinon réponse
|
||||
« à jour ». Aucune exception : versions illisibles → pas de MAJ (prudence).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"update_available": bool,
|
||||
"latest_version": str,
|
||||
"update_type": "code-only" | "full" | None, # None si pas de MAJ
|
||||
"url": str | None, # None si pas de MAJ
|
||||
}
|
||||
"""
|
||||
no_update = {
|
||||
"update_available": False,
|
||||
"latest_version": latest_version,
|
||||
"update_type": None,
|
||||
"url": None,
|
||||
}
|
||||
|
||||
# latest illisible → on ne propose RIEN (pas de MAJ douteuse).
|
||||
if parse_version(latest_version) == _FALLBACK_VERSION:
|
||||
return no_update
|
||||
|
||||
if not is_newer(latest_version, current_version):
|
||||
return no_update
|
||||
|
||||
chosen_type = _normalize_update_type(update_type)
|
||||
return {
|
||||
"update_available": True,
|
||||
"latest_version": latest_version,
|
||||
"update_type": chosen_type,
|
||||
"url": build_download_url(machine_id, latest_version, chosen_type),
|
||||
}
|
||||
139
agent_v0/server_v1/update_policy.py
Normal file
139
agent_v0/server_v1/update_policy.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# agent_v0/server_v1/update_policy.py
|
||||
"""Politique de déploiement CANARY de la MAJ silencieuse Léa (DETTE-022 v2).
|
||||
|
||||
⭐ Brique de SÉCURITÉ centrale ⭐ — 10+ postes cliniques live (Wallerstein).
|
||||
|
||||
Une MAJ ratée peut briquer toute la flotte. La règle non négociable : on ne
|
||||
pousse JAMAIS une nouvelle version sur tous les postes d'un coup. On la déploie
|
||||
d'abord sur UN poste (canary = Émilie `lea-4zbgwxty`), on vérifie, puis on
|
||||
élargit. Ce module résout, PAR MACHINE, la version cible :
|
||||
|
||||
- poste dans la liste canary → `canary_version` (la nouvelle) ;
|
||||
- tous les autres postes → `stable_version` (le floor, inchangé).
|
||||
|
||||
Piloté 100 % par variables d'environnement (config serveur, sans rebuild) :
|
||||
RPA_AGENT_STABLE_VERSION — version servie à toute la flotte (défaut floor).
|
||||
RPA_AGENT_CANARY_VERSION — version servie AUX SEULS postes canary (optionnel).
|
||||
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
|
||||
|
||||
Promotion = quand le canary est validé, on met RPA_AGENT_STABLE_VERSION à la
|
||||
version canary (toute la flotte suit) et on vide RPA_AGENT_CANARY_MACHINES.
|
||||
Rollback canary = on remet RPA_AGENT_CANARY_VERSION à l'ancienne / on vide la
|
||||
liste : le prochain check ne proposera plus la MAJ (le swap réel côté client
|
||||
reste réservé révision humaine — cf. updater.py).
|
||||
|
||||
Module PUR (aucun import FastAPI, aucune IO) → importable et testable seul
|
||||
(DETTE-013). Le branchement HTTP vit dans api_stream.py.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional, Set
|
||||
|
||||
# Réutilise le comparateur semver de la décision (même module serveur, pas de
|
||||
# duplication) : "1.0.2" < "1.0.10" correctement, tolérant aux formats invalides.
|
||||
try: # import relatif quand chargé comme package
|
||||
from .update_check import is_newer
|
||||
except Exception: # chargé par chemin (tests importlib) : import du voisin
|
||||
import importlib.util as _ilu
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_uc_path = _Path(__file__).resolve().parent / "update_check.py"
|
||||
_spec = _ilu.spec_from_file_location("_rpa_update_check_for_policy", _uc_path)
|
||||
_uc = _ilu.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_uc)
|
||||
is_newer = _uc.is_newer
|
||||
|
||||
|
||||
# Séparateurs tolérés dans l'allow-list canary (CSV, espaces, point-virgule).
|
||||
_CANARY_SEPARATORS = (",", ";")
|
||||
|
||||
|
||||
def parse_canary_machines(raw: Optional[str]) -> Set[str]:
|
||||
"""Parse l'allow-list canary en un ensemble de machine_id.
|
||||
|
||||
Tolérant : virgule / point-virgule / espace comme séparateurs, entrées
|
||||
vides ignorées. `None` ou chaîne vide → ensemble vide (aucun canary).
|
||||
"""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return set()
|
||||
normalized = raw
|
||||
for sep in _CANARY_SEPARATORS:
|
||||
normalized = normalized.replace(sep, " ")
|
||||
return {tok for tok in (t.strip() for t in normalized.split()) if tok}
|
||||
|
||||
|
||||
def resolve_target_version(
|
||||
machine_id: Optional[str],
|
||||
stable_version: str,
|
||||
canary_version: Optional[str],
|
||||
canary_machines: Set[str],
|
||||
) -> str:
|
||||
"""Résout la version cible POUR CE POSTE (cœur canary — sécurité).
|
||||
|
||||
Règles (toutes prudentes par défaut) :
|
||||
1. Poste HORS liste canary → `stable_version` (jamais la nouvelle).
|
||||
2. machine_id absent / liste vide / pas de canary_version → `stable_version`.
|
||||
3. Poste DANS la liste canary ET `canary_version` fournie ET STRICTEMENT
|
||||
plus récente que stable → `canary_version`.
|
||||
4. Garde-fou : si `canary_version` <= `stable_version` (config douteuse,
|
||||
ex. downgrade), on sert quand même `stable_version` (jamais de recul).
|
||||
|
||||
Ne lève jamais. Une version illisible retombe naturellement sur le stable
|
||||
via le comparateur semver tolérant.
|
||||
"""
|
||||
# Cas 1/2 : hors canary → stable.
|
||||
if not machine_id or machine_id not in canary_machines:
|
||||
return stable_version
|
||||
if not canary_version:
|
||||
return stable_version
|
||||
|
||||
# Cas 4 : garde-fou anti-recul — le canary doit être STRICTEMENT plus récent.
|
||||
if not is_newer(canary_version, stable_version):
|
||||
return stable_version
|
||||
|
||||
# Cas 3 : poste canary → nouvelle version.
|
||||
return canary_version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lecture de la politique depuis l'environnement (pilotage sans rebuild).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Défaut historique aligné sur AGENT_VERSION client (config.py) et sur le
|
||||
# fallback de _latest_agent_version().
|
||||
_DEFAULT_STABLE_VERSION = "1.0.1"
|
||||
|
||||
|
||||
def stable_version_from_env() -> str:
|
||||
"""Version servie à toute la flotte (floor). Défaut = 1.0.1."""
|
||||
return os.environ.get("RPA_AGENT_STABLE_VERSION", _DEFAULT_STABLE_VERSION)
|
||||
|
||||
|
||||
def canary_version_from_env() -> Optional[str]:
|
||||
"""Version canary (nouvelle), servie aux seuls postes canary. Optionnel."""
|
||||
val = os.environ.get("RPA_AGENT_CANARY_VERSION", "").strip()
|
||||
return val or None
|
||||
|
||||
|
||||
def canary_machines_from_env() -> Set[str]:
|
||||
"""Allow-list canary (machine_id) depuis RPA_AGENT_CANARY_MACHINES."""
|
||||
return parse_canary_machines(os.environ.get("RPA_AGENT_CANARY_MACHINES", ""))
|
||||
|
||||
|
||||
def resolve_target_version_from_env(machine_id: Optional[str]) -> str:
|
||||
"""Raccourci : résout la version cible pour `machine_id` d'après l'env.
|
||||
|
||||
C'est le point d'entrée que l'endpoint serveur appelle. Il isole toute la
|
||||
lecture d'environnement ici (testable en injectant les paramètres via
|
||||
`resolve_target_version`).
|
||||
"""
|
||||
return resolve_target_version(
|
||||
machine_id=machine_id,
|
||||
stable_version=stable_version_from_env(),
|
||||
canary_version=canary_version_from_env(),
|
||||
canary_machines=canary_machines_from_env(),
|
||||
)
|
||||
106
agent_v0/server_v1/vwb_db.py
Normal file
106
agent_v0/server_v1/vwb_db.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Couplage worker → DB VWB (mutualisé) + persistance « dossier patient extrait ».
|
||||
|
||||
Le worker/serveur streaming est un process distinct du backend VWB : il n'a
|
||||
pas d'app Flask en mémoire. Ce module fournit :
|
||||
|
||||
- ``vwb_app_context()`` : un app-context Flask lazy (singleton module) lié au
|
||||
fichier SQLite VWB ``visual_workflow_builder/backend/instance/workflows.db``,
|
||||
avec ``db.init_app`` (db de ``db.models``). Réutilisable par tout module
|
||||
serveur qui doit écrire dans la DB VWB (R1, extraction métier, …).
|
||||
|
||||
- ``persist_extracted_dossier(...)`` : depuis une grille OCR
|
||||
(``List[List[cell]]``), crée ExtractionJob → ExtractedTable → ExtractedField
|
||||
et commit. Suppose un app-context actif (comme le pont R1 existant).
|
||||
|
||||
⚠️ CANAL EXTRACTION = données patient EN CLAIR (volontaire) : aucune
|
||||
tokenisation/assainissement PII ici (cf. note dans db/models.py).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Ajout du backend VWB au sys.path à l'import → rend ``db.models`` importable
|
||||
# (couplage worker→DB VWB mutualisé ; identique au pattern stream_processor).
|
||||
_VWB_BACKEND = Path(__file__).resolve().parents[2] / "visual_workflow_builder" / "backend"
|
||||
if str(_VWB_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(_VWB_BACKEND))
|
||||
|
||||
# App Flask lazy (singleton module) — un seul db.init_app pour tout le process.
|
||||
_vwb_app = None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def vwb_app_context():
|
||||
"""App-context Flask VWB (lazy singleton) sur instance/workflows.db.
|
||||
|
||||
À utiliser via ``with vwb_app_context(): ...`` autour des appels qui
|
||||
nécessitent ``db.session`` (ex. persist_extracted_dossier).
|
||||
"""
|
||||
global _vwb_app
|
||||
if _vwb_app is None:
|
||||
from flask import Flask
|
||||
from db.models import db
|
||||
|
||||
db_path = _VWB_BACKEND / "instance" / "workflows.db"
|
||||
app = Flask("worker_vwb")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db.init_app(app)
|
||||
_vwb_app = app
|
||||
with _vwb_app.app_context():
|
||||
yield
|
||||
|
||||
|
||||
def persist_extracted_dossier(
|
||||
grid: List[List[Dict[str, Any]]],
|
||||
*,
|
||||
patient_ref: Optional[str],
|
||||
source_session_id: Optional[str],
|
||||
screenshot_ref: Optional[str],
|
||||
screen_bbox: Optional[Dict[str, Any]],
|
||||
status: str,
|
||||
) -> str:
|
||||
"""Persiste un « dossier patient extrait » et retourne le job_id.
|
||||
|
||||
Crée 1 ExtractionJob → 1 ExtractedTable → N ExtractedField (une par
|
||||
cellule de la grille), puis commit. Suppose un app-context VWB actif
|
||||
(fourni par ``vwb_app_context()`` ou par l'appelant, comme le pont R1).
|
||||
|
||||
⚠️ ``patient_ref`` et ``cell["text"]`` sont stockés EN CLAIR (volontaire) :
|
||||
le but est de constituer le dossier, pas d'anonymiser.
|
||||
"""
|
||||
from db.models import db, ExtractionJob, ExtractedTable, ExtractedField
|
||||
|
||||
job = ExtractionJob(
|
||||
id=uuid.uuid4().hex,
|
||||
patient_ref=patient_ref,
|
||||
source_session_id=source_session_id,
|
||||
status=status,
|
||||
)
|
||||
db.session.add(job)
|
||||
|
||||
table = ExtractedTable(
|
||||
id=uuid.uuid4().hex,
|
||||
job_id=job.id,
|
||||
screen_bbox=screen_bbox,
|
||||
screenshot_ref=screenshot_ref,
|
||||
)
|
||||
db.session.add(table)
|
||||
|
||||
for row in grid or []:
|
||||
for cell in row or []:
|
||||
db.session.add(ExtractedField(
|
||||
id=uuid.uuid4().hex,
|
||||
table_id=table.id,
|
||||
row=cell.get("row"),
|
||||
col=cell.get("col"),
|
||||
value=cell.get("text"),
|
||||
bbox=cell.get("bbox"),
|
||||
confidence=cell.get("confidence"),
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
return job.id
|
||||
@@ -11,15 +11,59 @@ passer `core.execution.screen_signature.screen_signature(...)` comme valeur de `
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
_FIELD_SEP = "\x1f" # sépare action_type et target dans une étape
|
||||
_STEP_SEP = "\x1e" # sépare les étapes
|
||||
|
||||
# --- Cible stable : anonymisation PII + normalisation déterministes ----------
|
||||
# Verdict QG Qwen (2026-06-25) : regex DÉDIÉES à la signature (PAS `pii_blur`,
|
||||
# qui protège les dates alors qu'ici on les NEUTRALISE), PAS de NER (un hash
|
||||
# d'identité doit être déterministe et identique labo↔DGX, donc indépendant
|
||||
# d'un modèle versionné). Les noms propres sans titre ne sont pas neutralisés
|
||||
# ici (stratégie « (b) » : impact 0 sur l'audit labo ; gate = audit agrégat
|
||||
# `by_text` DGX avant prod, ajouter une regex ciblée si des noms apparaissent).
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
# Ordre d'application : motifs structurés d'abord, identifiant numérique long
|
||||
# en dernier (sinon il mangerait des fragments de date/téléphone).
|
||||
_RE_EMAIL = re.compile(r"\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
|
||||
_RE_DATE = re.compile(r"\b\d{1,4}[/.\-]\d{1,2}[/.\-]\d{1,4}\b")
|
||||
_RE_PHONE = re.compile(r"\b(?:\+?33|0)\s?[1-9](?:[\s.\-]?\d{2}){4}\b")
|
||||
_RE_LONGNUM = re.compile(r"\d{6,}") # IPP / NIR collé / autre identifiant long
|
||||
|
||||
|
||||
def _anonymize_pii(text: str) -> str:
|
||||
"""Neutralise la PII structurée par des tokens stables : deux sessions sur le
|
||||
même champ (patients/dates différents) → même texte cible → même signature."""
|
||||
text = _RE_EMAIL.sub("[email]", text)
|
||||
text = _RE_DATE.sub("[date]", text)
|
||||
text = _RE_PHONE.sub("[tel]", text)
|
||||
text = _RE_LONGNUM.sub("[ipp]", text)
|
||||
return text
|
||||
|
||||
|
||||
def _norm_text(text: str) -> str:
|
||||
"""Normalisation déterministe (même logique que `action_executor._norm_text`,
|
||||
redéfinie ici pour garder ce module léger et sans effet de bord d'import) :
|
||||
minuscules, suppression des accents (NFKD), espaces normalisés."""
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace(" ", " ").strip().lower()
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
return _WS_RE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def _normalize_target(target: str) -> str:
|
||||
"""Cible stable : PII neutralisée PUIS normalisée (casse/accents/espaces)."""
|
||||
return _norm_text(_anonymize_pii(target))
|
||||
|
||||
|
||||
def _normalize_step(step: Mapping[str, Any]) -> str:
|
||||
action_type = str(step.get("action_type", "unknown")).strip().lower()
|
||||
target = str(step.get("target", "")).strip()
|
||||
target = _normalize_target(str(step.get("target", "")))
|
||||
return f"{action_type}{_FIELD_SEP}{target}"
|
||||
|
||||
|
||||
|
||||
279
core/extraction/role_mapper.py
Normal file
279
core/extraction/role_mapper.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""role_mapper — reconstruction de champs ANCRÉS sur l'OCR.
|
||||
|
||||
Principe cardinal (gate validé le 30/06 sur DPI urgences réel) :
|
||||
le VLM ne fournit QUE des ids de tokens OCR (`value_ids`) ; la valeur est
|
||||
reconstruite ici depuis l'OCR. Aucun texte produit par le VLM ne peut entrer
|
||||
dans une valeur → **0 hallucination par construction**.
|
||||
|
||||
Ce module est volontairement PUR (pas d'appel réseau/VLM) : il prend les tokens
|
||||
OCR (issus de `core.llm.ocr_extractor.extract_grid_from_image`) et la réponse
|
||||
déjà désérialisée du VLM, et produit des champs ancrés. L'appel VLM lui-même
|
||||
est orchestré ailleurs (et mockable), pour rester testable hors-ligne.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional, Sequence, Tuple
|
||||
|
||||
BBox = Tuple[int, int, int, int] # (x_min, y_min, x_max, y_max)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrToken:
|
||||
"""Un token OCR indexé par un id stable."""
|
||||
id: int
|
||||
text: str
|
||||
confidence: float = 1.0
|
||||
bbox: Optional[BBox] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MappedField:
|
||||
"""Un champ {rôle → valeur} dont la valeur est 100% issue de l'OCR."""
|
||||
label: str
|
||||
value: str
|
||||
value_ids: List[int]
|
||||
confidence: float
|
||||
bbox: Optional[BBox]
|
||||
anchored: bool
|
||||
invalid_ids: List[int]
|
||||
|
||||
|
||||
def _norm_bbox(bbox) -> Optional[BBox]:
|
||||
"""Normalise une bbox en (x_min, y_min, x_max, y_max).
|
||||
|
||||
Accepte soit 4 points EasyOCR `[[x,y], ...]`, soit un quadruplet déjà plat.
|
||||
"""
|
||||
if bbox is None:
|
||||
return None
|
||||
if len(bbox) == 4 and all(isinstance(v, (int, float)) for v in bbox):
|
||||
return (int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]))
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
return (int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys)))
|
||||
|
||||
|
||||
def tokens_from_grid(grid: Sequence[Sequence[dict]]) -> List[OcrToken]:
|
||||
"""Convertit une grille `extract_grid_from_image` en tokens indexés (id séquentiel).
|
||||
|
||||
L'ordre des ids suit l'ordre de lecture de la grille (lignes top→bottom,
|
||||
colonnes left→right), ce qui donne au VLM un référentiel stable.
|
||||
"""
|
||||
tokens: List[OcrToken] = []
|
||||
tid = 0
|
||||
for row in grid:
|
||||
for cell in row:
|
||||
tokens.append(OcrToken(
|
||||
id=tid,
|
||||
text=cell["text"],
|
||||
confidence=float(cell.get("confidence", 1.0)),
|
||||
bbox=_norm_bbox(cell.get("bbox")),
|
||||
))
|
||||
tid += 1
|
||||
return tokens
|
||||
|
||||
|
||||
def _enclosing_bbox(bboxes: Sequence[Optional[BBox]]) -> Optional[BBox]:
|
||||
present = [b for b in bboxes if b is not None]
|
||||
if not present:
|
||||
return None
|
||||
return (
|
||||
min(b[0] for b in present),
|
||||
min(b[1] for b in present),
|
||||
max(b[2] for b in present),
|
||||
max(b[3] for b in present),
|
||||
)
|
||||
|
||||
|
||||
def reconstruct_fields(
|
||||
tokens: Sequence[OcrToken],
|
||||
vlm_fields: Sequence[dict],
|
||||
) -> List[MappedField]:
|
||||
"""Reconstruit les champs à partir des tokens OCR et des `value_ids` du VLM.
|
||||
|
||||
Pour chaque champ VLM `{label, value_ids:[...]}` :
|
||||
- déduplique les ids en préservant l'ordre de lecture donné par le VLM ;
|
||||
- filtre les ids hors OCR (listés dans `invalid_ids`) ;
|
||||
- reconstruit la valeur par concaténation des `text` des tokens valides ;
|
||||
- confidence = min des tokens ancrés (le plus prudent), bbox = englobante.
|
||||
|
||||
Tout champ `value`/texte fourni par le VLM est IGNORÉ : seule la liste
|
||||
d'ids fait foi (anti-hallucination).
|
||||
"""
|
||||
by_id = {t.id: t for t in tokens}
|
||||
out: List[MappedField] = []
|
||||
for vf in vlm_fields:
|
||||
label = vf.get("label", "")
|
||||
seen: List[int] = []
|
||||
for i in (vf.get("value_ids") or []):
|
||||
if i not in seen:
|
||||
seen.append(i)
|
||||
valid = [i for i in seen if i in by_id]
|
||||
invalid = [i for i in seen if i not in by_id]
|
||||
toks = [by_id[i] for i in valid]
|
||||
out.append(MappedField(
|
||||
label=label,
|
||||
value=" ".join(t.text for t in toks),
|
||||
value_ids=valid,
|
||||
confidence=min((t.confidence for t in toks), default=0.0),
|
||||
bbox=_enclosing_bbox([t.bbox for t in toks]),
|
||||
anchored=bool(valid),
|
||||
invalid_ids=invalid,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
# --- Orchestration VLM (client injectable pour rester testable hors-ligne) ---
|
||||
|
||||
# Un client VLM est un callable (image_path, prompt) -> texte de réponse.
|
||||
VlmClient = Callable[[str, str], str]
|
||||
|
||||
|
||||
def build_role_prompt(
|
||||
tokens: Sequence[OcrToken],
|
||||
roles: Optional[Sequence[str]] = None,
|
||||
) -> str:
|
||||
"""Construit le prompt d'attribution de rôles (ancrage strict par ids).
|
||||
|
||||
Mode *guidé* si `roles` est fourni (rôles attendus de l'écran), sinon *libre*
|
||||
(le VLM nomme lui-même les champs). Dans les deux cas le VLM ne renvoie que
|
||||
des `value_ids` — jamais de texte recopié.
|
||||
"""
|
||||
ocr_list = [{"id": t.id, "text": t.text} for t in tokens]
|
||||
if roles:
|
||||
roles_line = (
|
||||
"Rôles attendus sur cet écran (associe chacun s'il est présent) : "
|
||||
+ ", ".join(roles) + ".\n"
|
||||
)
|
||||
else:
|
||||
roles_line = (
|
||||
"Identifie librement les champs présents — le 'label' est le rôle du champ.\n"
|
||||
)
|
||||
return (
|
||||
"Tu reçois une capture d'écran d'un dossier patient et la liste des tokens "
|
||||
"détectés par OCR (chaque token : id, text).\n"
|
||||
+ roles_line +
|
||||
"Pour chaque champ, désigne les tokens OCR qui composent sa VALEUR.\n"
|
||||
"RÈGLES STRICTES :\n"
|
||||
"- Tu ne recopies AUCUN texte. Tu renvoies seulement 'value_ids' : la liste "
|
||||
"des id de tokens OCR (dans l'ordre de lecture) qui forment la valeur.\n"
|
||||
"- 'label' = le rôle du champ. N'invente aucun champ.\n"
|
||||
"- Réponds UNIQUEMENT en JSON PLAT :\n"
|
||||
'{"ecran":"<type en 3 mots>","champs":[{"label":"...","value_ids":[<int>,...]}]}\n\n'
|
||||
"Tokens OCR :\n" + json.dumps(ocr_list, ensure_ascii=False)
|
||||
)
|
||||
|
||||
|
||||
def parse_vlm_json(text: str) -> dict:
|
||||
"""Extrait le 1er objet JSON d'une réponse VLM (tolère les fences ```json).
|
||||
|
||||
Robuste : renvoie `{}` si la réponse n'est pas du JSON exploitable (pas de
|
||||
crash en batch).
|
||||
"""
|
||||
if not text:
|
||||
return {}
|
||||
s = text.strip()
|
||||
if "```" in s:
|
||||
parts = s.split("```")
|
||||
if len(parts) >= 2:
|
||||
s = parts[1]
|
||||
if s.lstrip().lower().startswith("json"):
|
||||
s = s.lstrip()[4:]
|
||||
a, b = s.find("{"), s.rfind("}")
|
||||
if a < 0 or b <= a:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(s[a:b + 1])
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _norm_label(label: str) -> str:
|
||||
"""Normalise un label pour comparaison : minuscules + strip espaces."""
|
||||
return label.strip().lower()
|
||||
|
||||
|
||||
def assess_quality(
|
||||
fields: Sequence[MappedField],
|
||||
required_roles: Optional[Sequence[str]] = None,
|
||||
min_confidence: float = 0.6,
|
||||
) -> str:
|
||||
"""Évalue la qualité d'extraction d'un dossier à partir des champs reconstruits.
|
||||
|
||||
Renvoie l'un des 4 statuts (par priorité décroissante) :
|
||||
- "failed" : aucun champ, OU aucun champ ancré.
|
||||
- "needs_review" : au moins un rôle requis absent ou non ancré.
|
||||
- "partial" : rôles requis ok mais confidence insuffisante OU champs non ancrés.
|
||||
- "complete" : tout ancré, toutes confidences >= min_confidence, aucun non ancré.
|
||||
|
||||
Le matching required_role ↔ field.label est insensible à la casse et aux espaces.
|
||||
"""
|
||||
# --- failed : aucun champ du tout, ou aucun ancré ---
|
||||
anchored = [f for f in fields if f.anchored]
|
||||
if not fields or not anchored:
|
||||
return "failed"
|
||||
|
||||
# --- needs_review : rôle requis absent ou non ancré ---
|
||||
if required_roles:
|
||||
anchored_labels = {_norm_label(f.label) for f in anchored}
|
||||
for role in required_roles:
|
||||
if _norm_label(role) not in anchored_labels:
|
||||
return "needs_review"
|
||||
|
||||
# --- partial : confidence basse sur un champ ancré OU champs non ancrés ---
|
||||
has_low_confidence = any(f.confidence < min_confidence for f in anchored)
|
||||
has_unanchored = any(not f.anchored for f in fields)
|
||||
if has_low_confidence or has_unanchored:
|
||||
return "partial"
|
||||
|
||||
# --- complete ---
|
||||
return "complete"
|
||||
|
||||
|
||||
def map_roles(
|
||||
image_path: str,
|
||||
tokens: Sequence[OcrToken],
|
||||
vlm_client: VlmClient,
|
||||
roles: Optional[Sequence[str]] = None,
|
||||
) -> List[MappedField]:
|
||||
"""Orchestre l'attribution de rôles : prompt → VLM → parse → reconstruction ancrée.
|
||||
|
||||
`vlm_client` est injecté (testable hors-ligne). Le résultat est toujours
|
||||
ancré sur l'OCR via `reconstruct_fields`.
|
||||
"""
|
||||
prompt = build_role_prompt(tokens, roles)
|
||||
raw = vlm_client(image_path, prompt)
|
||||
data = parse_vlm_json(raw)
|
||||
vlm_fields = data.get("champs", []) if isinstance(data, dict) else []
|
||||
return reconstruct_fields(tokens, vlm_fields)
|
||||
|
||||
|
||||
def extract_dossier_from_image(
|
||||
image_path: str,
|
||||
vlm_client: VlmClient,
|
||||
roles: Optional[Sequence[str]] = None,
|
||||
ocr_fn: Optional[Callable[[str], Sequence[Sequence[dict]]]] = None,
|
||||
min_confidence: float = 0.6,
|
||||
required_roles: Optional[Sequence[str]] = None,
|
||||
) -> dict:
|
||||
"""Orchestre l'extraction d'un dossier depuis une capture : OCR → rôles → qualité.
|
||||
|
||||
Enchaîne `ocr_fn` (grille OCR) → `tokens_from_grid` → `map_roles` (VLM, ancrage
|
||||
strict) → `assess_quality`. C'est la brique que le handler runtime
|
||||
`_handle_extract_dossier_action` appellera, avec le vrai OCR et le vrai client
|
||||
vLLM. `ocr_fn` et `vlm_client` sont INJECTABLES (testable hors-ligne).
|
||||
|
||||
`ocr_fn` par défaut = `core.llm.ocr_extractor.extract_grid_from_image` (import
|
||||
LAZY : le module reste pur quand l'OCR est injecté en test).
|
||||
|
||||
Returns:
|
||||
{fields: List[MappedField], status: str, n_tokens: int}
|
||||
"""
|
||||
if ocr_fn is None:
|
||||
from core.llm.ocr_extractor import extract_grid_from_image as ocr_fn
|
||||
grid = ocr_fn(image_path)
|
||||
tokens = tokens_from_grid(grid)
|
||||
fields = map_roles(image_path, tokens, vlm_client, roles)
|
||||
status = assess_quality(fields, required_roles=required_roles, min_confidence=min_confidence)
|
||||
return {"fields": fields, "status": status, "n_tokens": len(tokens)}
|
||||
86
core/extraction/vlm_client.py
Normal file
86
core/extraction/vlm_client.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Client vLLM serveur : (image_path, prompt) -> texte de réponse.
|
||||
|
||||
Petit client réutilisable pour la lecture d'écran (extraction de dossier). Le
|
||||
grounder (`resolve_engine`) fait déjà un POST vers vLLM:8001 mais en INLINE, non
|
||||
exposé ; on factorise ici un client propre, configurable et testable.
|
||||
|
||||
- Image downscalée (largeur max) avant envoi : la fenêtre vLLM est limitée
|
||||
(`max_model_len`), un écran plein déborde sinon (vu 30/06 : 6193+2000 > 8192).
|
||||
- `thinking` désactivé (vérifié : think=on -> sortie vide/lente sur ce modèle).
|
||||
- `post_fn` injectable -> testable sans vLLM réel.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Callable, Optional
|
||||
|
||||
VlmClient = Callable[[str, str], str]
|
||||
|
||||
_DEFAULT_PORT = os.environ.get("VLLM_PORT", "8001")
|
||||
DEFAULT_URL = f"http://localhost:{_DEFAULT_PORT}/v1/chat/completions"
|
||||
DEFAULT_MODEL = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
|
||||
|
||||
|
||||
def img_data_url(image_path: str, max_w: int = 1280) -> str:
|
||||
"""Encode l'image en data-URL PNG base64, downscalée à `max_w` si plus large."""
|
||||
from PIL import Image
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
if img.width > max_w:
|
||||
h = int(img.height * max_w / img.width)
|
||||
img = img.resize((max_w, h), Image.LANCZOS)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def build_chat_body(
|
||||
image_path: str,
|
||||
prompt: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
max_tokens: int = 1500,
|
||||
max_w: int = 1280,
|
||||
) -> dict:
|
||||
"""Construit le body chat/completions (image + prompt, thinking off)."""
|
||||
return {
|
||||
"model": model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": img_data_url(image_path, max_w)}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}],
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
}
|
||||
|
||||
|
||||
def make_vllm_client(
|
||||
url: str = DEFAULT_URL,
|
||||
model: str = DEFAULT_MODEL,
|
||||
max_tokens: int = 1500,
|
||||
max_w: int = 1280,
|
||||
timeout: float = 120,
|
||||
post_fn: Optional[Callable] = None,
|
||||
) -> VlmClient:
|
||||
"""Construit un client `(image_path, prompt) -> texte`, branché sur vLLM.
|
||||
|
||||
`post_fn` (signature `requests.post`) est injectable pour les tests.
|
||||
Lève `RuntimeError` si le serveur ne répond pas 200 (message technique, sans PII).
|
||||
"""
|
||||
def client(image_path: str, prompt: str) -> str:
|
||||
body = build_chat_body(image_path, prompt, model=model, max_tokens=max_tokens, max_w=max_w)
|
||||
poster = post_fn
|
||||
if poster is None:
|
||||
import requests
|
||||
poster = requests.post
|
||||
r = poster(url, json=body, headers={}, timeout=timeout)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"vLLM {r.status_code}: {str(getattr(r, 'text', ''))[:300]}")
|
||||
return r.json()["choices"][0]["message"]["content"]
|
||||
return client
|
||||
@@ -8,6 +8,7 @@ from .t2a_decision import (
|
||||
)
|
||||
from .ocr_extractor import (
|
||||
extract_digits_tesseract_from_image,
|
||||
extract_grid_from_image,
|
||||
extract_table_from_image,
|
||||
extract_text_from_image,
|
||||
)
|
||||
@@ -19,5 +20,6 @@ __all__ = [
|
||||
"build_dpi_enriched",
|
||||
"extract_text_from_image",
|
||||
"extract_table_from_image",
|
||||
"extract_grid_from_image",
|
||||
"extract_digits_tesseract_from_image",
|
||||
]
|
||||
|
||||
@@ -243,3 +243,107 @@ def extract_table_from_image(
|
||||
except Exception as e:
|
||||
logger.warning("extract_table échoué sur %s : %s", image_path, e)
|
||||
return []
|
||||
|
||||
|
||||
def _cluster_1d(centers: List[float], tol: float) -> List[Tuple[float, int]]:
|
||||
"""Regroupe des positions 1D par proximité (centres triés, gap > tol = nouveau cluster).
|
||||
|
||||
Retourne, pour chaque centre d'entrée (ordre d'origine), un couple
|
||||
(centre_du_cluster, index_du_cluster), les clusters étant indexés dans
|
||||
l'ordre croissant. Permet de mapper lignes (y) et colonnes (x).
|
||||
"""
|
||||
order = sorted(range(len(centers)), key=lambda i: centers[i])
|
||||
cluster_of = [0] * len(centers)
|
||||
cluster_centers: List[List[float]] = []
|
||||
prev = None
|
||||
idx = -1
|
||||
for i in order:
|
||||
c = centers[i]
|
||||
if prev is None or (c - prev) > tol:
|
||||
idx += 1
|
||||
cluster_centers.append([])
|
||||
cluster_centers[idx].append(c)
|
||||
cluster_of[i] = idx
|
||||
prev = c
|
||||
means = [sum(g) / len(g) for g in cluster_centers]
|
||||
return [(means[cluster_of[i]], cluster_of[i]) for i in range(len(centers))]
|
||||
|
||||
|
||||
def extract_grid_from_image(
|
||||
image_path: str,
|
||||
region: Optional[Tuple[int, int, int, int]] = None,
|
||||
row_tol: float = 12.0,
|
||||
col_tol: float = 25.0,
|
||||
) -> List[List[dict]]:
|
||||
"""Extrait un tableau STRUCTURÉ (lignes ET colonnes) via OCR EasyOCR.
|
||||
|
||||
Contrairement à `extract_table_from_image` (liste plate triée par y, x jeté),
|
||||
on conserve la coordonnée x pour reconstruire une grille. Clustering :
|
||||
lignes par proximité du centre y, colonnes par proximité du centre x.
|
||||
|
||||
Args:
|
||||
image_path: chemin du PNG sur disque.
|
||||
region: (x, y, w, h) pour cropper avant OCR. None = image entière.
|
||||
row_tol: écart vertical max (px) entre 2 tokens d'une même ligne.
|
||||
col_tol: écart horizontal max (px) entre 2 tokens d'une même colonne.
|
||||
|
||||
Returns:
|
||||
Grille `List[List[cell]]`, lignes top→bottom, colonnes left→right.
|
||||
`cell = {"text", "bbox", "confidence", "row", "col"}`.
|
||||
En cas d'erreur ou d'absence de tokens, retourne [].
|
||||
"""
|
||||
path = Path(image_path)
|
||||
if not path.exists():
|
||||
logger.warning("extract_grid: fichier introuvable %s", image_path)
|
||||
return []
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
img = Image.open(path)
|
||||
if region:
|
||||
x, y, w, h = region
|
||||
img = img.crop((x, y, x + w, y + h))
|
||||
|
||||
reader = _get_reader()
|
||||
results = reader.readtext(np.array(img), detail=1, paragraph=False)
|
||||
|
||||
toks = []
|
||||
for bbox, text, conf in results:
|
||||
t = str(text).strip()
|
||||
if not t:
|
||||
continue
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
toks.append({
|
||||
"text": t,
|
||||
"bbox": bbox,
|
||||
"confidence": conf,
|
||||
"xc": sum(xs) / len(xs),
|
||||
"yc": sum(ys) / len(ys),
|
||||
})
|
||||
if not toks:
|
||||
return []
|
||||
|
||||
rows_cl = _cluster_1d([tk["yc"] for tk in toks], row_tol)
|
||||
cols_cl = _cluster_1d([tk["xc"] for tk in toks], col_tol)
|
||||
for tk, (_yc, r), (_xc, c) in zip(toks, rows_cl, cols_cl):
|
||||
tk["row"], tk["col"] = r, c
|
||||
|
||||
n_rows = max(tk["row"] for tk in toks) + 1
|
||||
grid: List[List[dict]] = [[] for _ in range(n_rows)]
|
||||
for tk in toks:
|
||||
grid[tk["row"]].append({
|
||||
"text": tk["text"],
|
||||
"bbox": tk["bbox"],
|
||||
"confidence": tk["confidence"],
|
||||
"row": tk["row"],
|
||||
"col": tk["col"],
|
||||
})
|
||||
for row in grid:
|
||||
row.sort(key=lambda cell: cell["col"])
|
||||
return grid
|
||||
except Exception as e:
|
||||
logger.warning("extract_grid échoué sur %s : %s", image_path, e)
|
||||
return []
|
||||
|
||||
@@ -1250,12 +1250,16 @@ class Workflow:
|
||||
}
|
||||
if self.chain_config:
|
||||
result["chain_config"] = self.chain_config.to_dict() if hasattr(self.chain_config, 'to_dict') else self.chain_config
|
||||
# machine_id : attribut d'instance posé au runtime (pas un champ dataclass)
|
||||
machine_id = getattr(self, "_machine_id", None)
|
||||
if machine_id:
|
||||
result["machine_id"] = machine_id
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Workflow':
|
||||
"""Désérialiser depuis JSON"""
|
||||
return cls(
|
||||
wf = cls(
|
||||
workflow_id=data["workflow_id"],
|
||||
name=data.get("name", data["workflow_id"]),
|
||||
description=data.get("description", ""),
|
||||
@@ -1277,7 +1281,13 @@ class Workflow:
|
||||
references=data.get("references", []),
|
||||
chain_config=data.get("chain_config")
|
||||
)
|
||||
|
||||
# Reposer machine_id (attribut d'instance) : priorité au champ explicite,
|
||||
# sinon depuis metadata['machine_id'] (rétrocompat des workflows déjà sur disque)
|
||||
machine_id = data.get("machine_id") or (wf.metadata or {}).get("machine_id")
|
||||
if machine_id:
|
||||
wf._machine_id = machine_id
|
||||
return wf
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Sérialiser en JSON string"""
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
119
core/navigation/__init__.py
Normal file
119
core/navigation/__init__.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Navigation brique — login visuel, recherche dossiers, vérification écran.
|
||||
|
||||
Modules :
|
||||
- visual_verifier : verify_before / verify_after chaque action (vision = validateur, OCR-ancré)
|
||||
- grounding : résolution visuelle d'éléments UI (OCR-anchor first, VLM fallback, coords cache)
|
||||
- visual_login : login form resolution + verification (DPI urgences default config)
|
||||
- action_resolver : pont navigation → runtime (coords normalisés, OCR/VLM adapters)
|
||||
|
||||
Pattern d'injection : VlmClient + OcrClient + OcrDetailedClient injectables
|
||||
"""
|
||||
|
||||
from .visual_verifier import verify_screen_match, ScreenMatchResult
|
||||
from .action_resolver import navigate_login, NavigateResult
|
||||
|
||||
__all__ = [
|
||||
"verify_screen_match",
|
||||
"ScreenMatchResult",
|
||||
"navigate_login",
|
||||
"NavigateResult",
|
||||
"_handle_navigate_action",
|
||||
]
|
||||
|
||||
# Handler pour replay_engine — importé par api_stream.py
|
||||
def _handle_navigate_action(
|
||||
action: dict,
|
||||
replay_state: dict,
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Handler serveur pour action navigate (branchement replay_engine).
|
||||
|
||||
Thin wrapper : résout coords du login form et les stocke dans
|
||||
replay_state["variables"] pour les actions type/click suivantes.
|
||||
|
||||
N'échoue jamais le replay — toute erreur → log + needs_review.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger("navigation._handle_navigate_action")
|
||||
|
||||
params = action.get("parameters") or {}
|
||||
navigate_action = params.get("action", "login")
|
||||
|
||||
# Noms des variables output (configurable)
|
||||
login_var = (params.get("login_coords_var") or "navigate_login_coords").strip()
|
||||
password_var = (params.get("password_coords_var") or "navigate_password_coords").strip()
|
||||
submit_var = (params.get("submit_coords_var") or "navigate_submit_coords").strip()
|
||||
|
||||
variables = replay_state.setdefault("variables", {})
|
||||
|
||||
try:
|
||||
screenshot_path = ""
|
||||
# Résoudre screenshot depuis replay_state
|
||||
if "last_screenshot_path" in replay_state:
|
||||
screenshot_path = replay_state["last_screenshot_path"]
|
||||
elif "last_heartbeat" in replay_state:
|
||||
hb = replay_state["last_heartbeat"]
|
||||
screenshot_path = hb.get("screenshot_path", "") if isinstance(hb, dict) else ""
|
||||
|
||||
if not screenshot_path:
|
||||
logger.warning("navigate: no screenshot for session %s", session_id)
|
||||
variables[login_var] = {"error": "no_screenshot"}
|
||||
return False
|
||||
|
||||
# Dimensions écran (fallback 1920×1080)
|
||||
screen_width = replay_state.get("screen_width", 1920)
|
||||
screen_height = replay_state.get("screen_height", 1080)
|
||||
|
||||
# OCR/VLM clients — lazy import pour éviter circular dependency
|
||||
from core.llm import extract_grid_from_image
|
||||
from core.extraction.vlm_client import make_vllm_client
|
||||
from core.navigation.action_resolver import make_ocr_detailed_from_grid
|
||||
|
||||
ocr_detailed = make_ocr_detailed_from_grid(extract_grid_from_image)
|
||||
vlm_client = make_vllm_client()
|
||||
|
||||
# Config login
|
||||
from core.navigation.visual_login import LoginFormConfig, dpi_urgences_login_config
|
||||
config = dpi_urgences_login_config()
|
||||
if "login_field" in params:
|
||||
config = LoginFormConfig(
|
||||
login_field=params.get("login_field", config.login_field),
|
||||
password_field=params.get("password_field", config.password_field),
|
||||
submit_button=params.get("submit_button", config.submit_button),
|
||||
success_elements=params.get("success_elements", config.success_elements),
|
||||
context=params.get("context", config.context),
|
||||
)
|
||||
|
||||
# Orchestration navigate
|
||||
from core.navigation.action_resolver import navigate_login
|
||||
result = navigate_login(
|
||||
screenshot_path, config=config,
|
||||
ocr_client=ocr_detailed, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
)
|
||||
|
||||
# Stocker coords dans variables (format dict pour substitution)
|
||||
if result.login_coords:
|
||||
variables[login_var] = result.login_coords.to_dict()
|
||||
if result.password_coords:
|
||||
variables[password_var] = result.password_coords.to_dict()
|
||||
if result.submit_coords:
|
||||
variables[submit_var] = result.submit_coords.to_dict()
|
||||
|
||||
variables["navigate_result"] = {
|
||||
"all_resolved": result.all_resolved,
|
||||
"method": result.login_coords.method if result.login_coords else "",
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
if not result.all_resolved:
|
||||
logger.warning("navigate: incomplete — %s", result.error)
|
||||
return False
|
||||
|
||||
logger.info("navigate: login form resolved OK (method=%s)", result.login_coords.method if result.login_coords else "?")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("navigate: exception (%s) — needs_review", e)
|
||||
variables["navigate_result"] = {"all_resolved": False, "error": str(e)}
|
||||
return False
|
||||
205
core/navigation/action_resolver.py
Normal file
205
core/navigation/action_resolver.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Action resolver — pont entre modules navigation et runtime replay.
|
||||
|
||||
Orchestre verify → ground → store coords pour le handler replay_engine.
|
||||
Convertit coords pixels → normalisé (x_pct/y_pct) pour le client Agent V1.
|
||||
|
||||
Architecture :
|
||||
- handler replay_engine = thin wrapper (appelle action_resolver)
|
||||
- action_resolver = bridge (adapte OCR/VLM runtime → interfaces navigation)
|
||||
- modules navigation = pure functions (ne connaissent pas le runtime)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.navigation.grounding import (
|
||||
BBox,
|
||||
CoordsCache,
|
||||
GroundedElement,
|
||||
OcrDetailedClient,
|
||||
OcrTokenInfo,
|
||||
ground_element,
|
||||
)
|
||||
from core.navigation.visual_login import (
|
||||
LoginFormConfig,
|
||||
LoginResolution,
|
||||
dpi_urgences_login_config,
|
||||
resolve_login_form,
|
||||
verify_login_visible,
|
||||
verify_login_success,
|
||||
)
|
||||
from core.navigation.visual_verifier import (
|
||||
OcrClient,
|
||||
ScreenMatchResult,
|
||||
VlmClient,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Dataclasses ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class NavigateCoords:
|
||||
"""Normalized coords for a grounded element — format Agent V1 client."""
|
||||
|
||||
x_pct: float # center x normalized [0-1]
|
||||
y_pct: float # center y normalized [0-1]
|
||||
bbox_pct: Optional[Tuple[float, float, float, float]] = None # (x1, y1, x2, y2) normalized
|
||||
method: str = "" # grounding method used
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = {"x_pct": self.x_pct, "y_pct": self.y_pct, "method": self.method}
|
||||
if self.bbox_pct:
|
||||
d["bbox_pct"] = list(self.bbox_pct)
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class NavigateResult:
|
||||
"""Result of a navigate action — coords for each resolved field."""
|
||||
|
||||
login_coords: Optional[NavigateCoords] = None
|
||||
password_coords: Optional[NavigateCoords] = None
|
||||
submit_coords: Optional[NavigateCoords] = None
|
||||
all_resolved: bool = False
|
||||
pre_verify: Optional[ScreenMatchResult] = None
|
||||
post_verify: Optional[ScreenMatchResult] = None # set later by verify_after
|
||||
error: str = ""
|
||||
|
||||
|
||||
# ── Coordinate conversion ────────────────────────────────────────────
|
||||
|
||||
|
||||
def grounded_to_coords(
|
||||
element: GroundedElement,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
) -> NavigateCoords:
|
||||
"""Convert GroundedElement (pixels) to NavigateCoords (normalized pct)."""
|
||||
x_pct = element.center[0] / screen_width if screen_width else 0
|
||||
y_pct = element.center[1] / screen_height if screen_height else 0
|
||||
x1_pct = element.bbox[0] / screen_width if screen_width else 0
|
||||
y1_pct = element.bbox[1] / screen_height if screen_height else 0
|
||||
x2_pct = element.bbox[2] / screen_width if screen_width else 0
|
||||
y2_pct = element.bbox[3] / screen_height if screen_height else 0
|
||||
return NavigateCoords(
|
||||
x_pct=x_pct,
|
||||
y_pct=y_pct,
|
||||
bbox_pct=(x1_pct, y1_pct, x2_pct, y2_pct),
|
||||
method=element.method,
|
||||
)
|
||||
|
||||
|
||||
# ── OCR adapter ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_ocr_detailed_from_grid(
|
||||
grid_fn: Callable[[str], List[List[Dict[str, Any]]]],
|
||||
) -> OcrDetailedClient:
|
||||
"""Adapt extract_grid_from_image → OcrDetailedClient (List[OcrTokenInfo]).
|
||||
|
||||
Converts the grid format (list of rows of cells with bbox) into
|
||||
flat OcrTokenInfo list with normalized LTRB bbox.
|
||||
"""
|
||||
from core.extraction.role_mapper import tokens_from_grid
|
||||
|
||||
def client(image_path: str) -> List[OcrTokenInfo]:
|
||||
grid = grid_fn(image_path)
|
||||
ocr_tokens = tokens_from_grid(grid)
|
||||
return [
|
||||
OcrTokenInfo(
|
||||
text=t.text,
|
||||
bbox=t.bbox,
|
||||
confidence=t.confidence,
|
||||
)
|
||||
for t in ocr_tokens
|
||||
]
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def make_ocr_simple_from_detailed(
|
||||
ocr_detailed: OcrDetailedClient,
|
||||
) -> OcrClient:
|
||||
"""Derive text-only OcrClient from OcrDetailedClient."""
|
||||
def client(image_path: str) -> List[str]:
|
||||
return [t.text for t in ocr_detailed(image_path)]
|
||||
return client
|
||||
|
||||
|
||||
# ── Navigate login orchestration ─────────────────────────────────────
|
||||
|
||||
|
||||
def navigate_login(
|
||||
screenshot_path: str,
|
||||
config: Optional[LoginFormConfig] = None,
|
||||
ocr_client: Optional[OcrDetailedClient] = None,
|
||||
vlm_client: Optional[VlmClient] = None,
|
||||
screen_width: int = 1920,
|
||||
screen_height: int = 1080,
|
||||
coords_cache: Optional[CoordsCache] = None,
|
||||
skip_pre_verify: bool = False,
|
||||
) -> NavigateResult:
|
||||
"""Orchestrate login navigation: verify → ground → convert coords.
|
||||
|
||||
Returns NavigateResult with normalized coords for each field.
|
||||
The handler stores these in replay_state variables for subsequent
|
||||
type/click actions.
|
||||
"""
|
||||
if config is None:
|
||||
config = dpi_urgences_login_config()
|
||||
|
||||
if ocr_client is None or vlm_client is None:
|
||||
return NavigateResult(
|
||||
all_resolved=False,
|
||||
error="ocr_client and vlm_client required",
|
||||
)
|
||||
|
||||
ocr_simple = make_ocr_simple_from_detailed(ocr_client)
|
||||
|
||||
# Step 1: Pre-verification (optional)
|
||||
pre_verify = None
|
||||
if not skip_pre_verify:
|
||||
pre_verify = verify_login_visible(
|
||||
screenshot_path, config, ocr_simple, vlm_client,
|
||||
)
|
||||
if not pre_verify.match:
|
||||
logger.warning("navigate_login: pre-verify failed — %s", pre_verify.describe())
|
||||
return NavigateResult(
|
||||
all_resolved=False,
|
||||
pre_verify=pre_verify,
|
||||
error=f"pre-verify failed: {pre_verify.describe()}",
|
||||
)
|
||||
|
||||
# Step 2: Ground all fields
|
||||
resolution = resolve_login_form(
|
||||
screenshot_path, config, ocr_client, vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache,
|
||||
)
|
||||
|
||||
if not resolution.all_resolved:
|
||||
logger.warning("navigate_login: incomplete resolution — %s", resolution.describe())
|
||||
return NavigateResult(
|
||||
all_resolved=False,
|
||||
pre_verify=pre_verify,
|
||||
error=f"incomplete resolution: {resolution.describe()}",
|
||||
)
|
||||
|
||||
# Step 3: Convert to normalized coords
|
||||
login_coords = grounded_to_coords(resolution.login_field, screen_width, screen_height) if resolution.login_field else None
|
||||
password_coords = grounded_to_coords(resolution.password_field, screen_width, screen_height) if resolution.password_field else None
|
||||
submit_coords = grounded_to_coords(resolution.submit_button, screen_width, screen_height) if resolution.submit_button else None
|
||||
|
||||
return NavigateResult(
|
||||
login_coords=login_coords,
|
||||
password_coords=password_coords,
|
||||
submit_coords=submit_coords,
|
||||
all_resolved=True,
|
||||
pre_verify=pre_verify,
|
||||
)
|
||||
375
core/navigation/grounding.py
Normal file
375
core/navigation/grounding.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""Grounding — résolution visuelle d'éléments UI → coords (bbox + center).
|
||||
|
||||
Architecture OCR-ancrée (alignée avec visual_verifier) :
|
||||
- STRATÉGIE 1 : OCR-anchor — si le texte cible est trouvé par OCR,
|
||||
utiliser le bbox du token OCR (déterministe, zero hallucination).
|
||||
- STRATÉGIE 2 : VLM grounder — si OCR ne trouve pas le texte,
|
||||
le VLM localise l'élément visuellement (fallback, risque contrôlé).
|
||||
- CACHE coords : mémorise les coords résolues, validées par vision avant usage.
|
||||
Si cached coords fail → re-résolution visuelle.
|
||||
|
||||
Coords = cache local validé par vue (Dom/Claude recadrage 01/07).
|
||||
Vision = source de vérité, coords = shortcut validé.
|
||||
|
||||
BBox format interne : LTRB (x1, y1, x2, y2) pixels absolus —
|
||||
cohérent avec SomElement, OcrToken, DetectedUIElement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.navigation.visual_verifier import (
|
||||
fuzzy_match,
|
||||
normalize_text,
|
||||
OcrClient,
|
||||
VlmClient,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# BBox format: LTRB pixels (x1, y1, x2, y2)
|
||||
BBox = Tuple[int, int, int, int]
|
||||
|
||||
|
||||
# ── Dataclasses ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrTokenInfo:
|
||||
"""OCR token with bounding box — for grounding (richer than text-only)."""
|
||||
|
||||
text: str
|
||||
bbox: Optional[BBox] = None # (x1, y1, x2, y2) LTRB pixels
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
# Type alias — injectable OCR client returning tokens with bbox
|
||||
# More detailed than visual_verifier's OcrClient (which returns List[str])
|
||||
OcrDetailedClient = Callable[[str], List[OcrTokenInfo]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundedElement:
|
||||
"""A UI element grounded on screen with coordinates."""
|
||||
|
||||
role: str
|
||||
text: str
|
||||
bbox: BBox # (x1, y1, x2, y2) LTRB pixels
|
||||
center: Tuple[int, int] # (cx, cy) — click target
|
||||
confidence: float
|
||||
method: str # "ocr_anchor" or "vlm_grounder" or "cache"
|
||||
source_ocr_text: str = "" # actual OCR text that matched (for fuzzy)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoordsCacheEntry:
|
||||
"""Cached coordinates for a UI element."""
|
||||
|
||||
element_key: str # "role:text"
|
||||
bbox: BBox
|
||||
center: Tuple[int, int]
|
||||
method: str # how it was originally resolved
|
||||
validation_count: int = 0
|
||||
|
||||
|
||||
class CoordsCache:
|
||||
"""In-memory cache of grounded coordinates.
|
||||
|
||||
Entries are validated by vision before use (verify_after).
|
||||
If cached coords fail verification → invalidate + re-resolve.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: Dict[str, CoordsCacheEntry] = {}
|
||||
|
||||
def get(self, element_key: str) -> Optional[CoordsCacheEntry]:
|
||||
return self._entries.get(element_key)
|
||||
|
||||
def put(
|
||||
self,
|
||||
element_key: str,
|
||||
bbox: BBox,
|
||||
center: Tuple[int, int],
|
||||
method: str,
|
||||
) -> None:
|
||||
entry = self._entries.get(element_key)
|
||||
if entry:
|
||||
entry.bbox = bbox
|
||||
entry.center = center
|
||||
entry.method = method
|
||||
entry.validation_count += 1
|
||||
else:
|
||||
self._entries[element_key] = CoordsCacheEntry(
|
||||
element_key=element_key,
|
||||
bbox=bbox,
|
||||
center=center,
|
||||
method=method,
|
||||
validation_count=1,
|
||||
)
|
||||
|
||||
def invalidate(self, element_key: str) -> None:
|
||||
self._entries.pop(element_key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
|
||||
def keys(self) -> List[str]:
|
||||
return list(self._entries.keys())
|
||||
|
||||
|
||||
# ── Helper functions ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def bbox_center(bbox: BBox) -> Tuple[int, int]:
|
||||
"""Compute center point from LTRB bbox."""
|
||||
x1, y1, x2, y2 = bbox
|
||||
return ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
|
||||
|
||||
def make_element_key(role: str, text: str) -> str:
|
||||
"""Create a stable cache key from role + text."""
|
||||
return f"{role}:{normalize_text(text)}"
|
||||
|
||||
|
||||
# ── OCR-anchored grounding (deterministic) ───────────────────────────
|
||||
|
||||
|
||||
def ocr_anchor_ground(
|
||||
ocr_tokens: List[OcrTokenInfo],
|
||||
target: Dict[str, Any],
|
||||
fuzzy_threshold: float = 0.8,
|
||||
) -> Optional[GroundedElement]:
|
||||
"""Ground an element using OCR tokens with bbox (deterministic).
|
||||
|
||||
Finds the target text in OCR tokens via fuzzy match.
|
||||
Returns GroundedElement with bbox from the matching OCR token.
|
||||
"""
|
||||
target_text = target.get("text", "")
|
||||
target_role = target.get("role", "?")
|
||||
|
||||
if not target_text:
|
||||
return None
|
||||
|
||||
for token in ocr_tokens:
|
||||
if fuzzy_match(target_text, token.text, threshold=fuzzy_threshold):
|
||||
if token.bbox is None:
|
||||
continue # token found but no bbox → can't ground
|
||||
|
||||
return GroundedElement(
|
||||
role=target_role,
|
||||
text=target_text,
|
||||
bbox=token.bbox,
|
||||
center=bbox_center(token.bbox),
|
||||
confidence=token.confidence,
|
||||
method="ocr_anchor",
|
||||
source_ocr_text=token.text,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── VLM grounder (fallback) ─────────────────────────────────────────
|
||||
|
||||
|
||||
def build_grounder_prompt(
|
||||
target: Dict[str, Any],
|
||||
context: str = "",
|
||||
) -> str:
|
||||
"""Build VLM prompt for locating a UI element on screen.
|
||||
|
||||
Asks for bounding box in normalized coordinates [0-1].
|
||||
"""
|
||||
role = target.get("role", "?")
|
||||
text = target.get("text", "")
|
||||
extra = target.get("extra", "")
|
||||
|
||||
prompt = (
|
||||
"You are a UI element locator. Find the specified element on this "
|
||||
"screenshot and return its bounding box.\n"
|
||||
)
|
||||
if context:
|
||||
prompt += f"Context: {context}\n"
|
||||
prompt += f"Target element: {role} with text \"{text}\""
|
||||
if extra:
|
||||
prompt += f" ({extra})"
|
||||
prompt += (
|
||||
"\n\nRespond in JSON format:\n"
|
||||
"{\"found\": true/false, "
|
||||
"\"bbox\": [x1_norm, y1_norm, x2_norm, y2_norm], "
|
||||
"\"confidence\": 0.0-1.0, "
|
||||
"\"description\": \"...\"}\n"
|
||||
"bbox coordinates are normalized [0.0-1.0] relative to image dimensions "
|
||||
"(x1=left, y1=top, x2=right, y2=bottom). "
|
||||
"Only return found=true if you can clearly locate the element."
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_grounder_response(
|
||||
vlm_text: str,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
target: Dict[str, Any],
|
||||
) -> Optional[GroundedElement]:
|
||||
"""Parse VLM grounder response into GroundedElement.
|
||||
|
||||
Converts normalized bbox [0-1] to absolute pixels.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(vlm_text)
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r"\{[\s\S]*\}", vlm_text)
|
||||
if json_match:
|
||||
try:
|
||||
data = json.loads(json_match.group())
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("grounding: VLM response not parseable as JSON")
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if not data.get("found", False):
|
||||
return None
|
||||
|
||||
bbox_norm = data.get("bbox", [])
|
||||
if not isinstance(bbox_norm, list) or len(bbox_norm) != 4:
|
||||
logger.warning("grounding: invalid bbox format from VLM")
|
||||
return None
|
||||
|
||||
# Convert normalized [0-1] to absolute pixels
|
||||
try:
|
||||
x1 = int(float(bbox_norm[0]) * screen_width)
|
||||
y1 = int(float(bbox_norm[1]) * screen_height)
|
||||
x2 = int(float(bbox_norm[2]) * screen_width)
|
||||
y2 = int(float(bbox_norm[3]) * screen_height)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("grounding: bbox values not numeric")
|
||||
return None
|
||||
|
||||
# Clamp to screen bounds
|
||||
x1 = max(0, min(x1, screen_width))
|
||||
y1 = max(0, min(y1, screen_height))
|
||||
x2 = max(x1, min(x2, screen_width))
|
||||
y2 = max(y1, min(y2, screen_height))
|
||||
|
||||
confidence = data.get("confidence", 0.5)
|
||||
if isinstance(confidence, str):
|
||||
try:
|
||||
confidence = float(confidence)
|
||||
except ValueError:
|
||||
confidence = 0.5
|
||||
|
||||
bbox_abs: BBox = (x1, y1, x2, y2)
|
||||
|
||||
return GroundedElement(
|
||||
role=target.get("role", "?"),
|
||||
text=target.get("text", ""),
|
||||
bbox=bbox_abs,
|
||||
center=bbox_center(bbox_abs),
|
||||
confidence=confidence,
|
||||
method="vlm_grounder",
|
||||
)
|
||||
|
||||
|
||||
# ── Core grounding function (composition) ───────────────────────────
|
||||
|
||||
|
||||
def ground_element(
|
||||
screenshot_path: str,
|
||||
target: Dict[str, Any],
|
||||
ocr_client: OcrDetailedClient,
|
||||
vlm_client: VlmClient,
|
||||
screen_width: int = 1920,
|
||||
screen_height: int = 1080,
|
||||
coords_cache: Optional[CoordsCache] = None,
|
||||
context: str = "",
|
||||
fuzzy_threshold: float = 0.8,
|
||||
) -> Optional[GroundedElement]:
|
||||
"""Ground a UI element on screen — OCR-anchor first, VLM fallback.
|
||||
|
||||
Resolution strategy:
|
||||
1. Cache: if cached coords exist → return cached (validated separately)
|
||||
2. OCR-anchor: deterministic, zero hallucination
|
||||
3. VLM grounder: fallback when OCR can't find the text
|
||||
|
||||
Args:
|
||||
screenshot_path: path to screenshot image
|
||||
target: {"role": "bouton", "text": "Connexion"} — element to find
|
||||
ocr_client: injectable OCR client returning List[OcrTokenInfo]
|
||||
vlm_client: injectable VLM client (image_path, prompt) -> text
|
||||
screen_width/height: screen dimensions for pixel conversion
|
||||
coords_cache: optional CoordsCache for memoization
|
||||
context: optional context (e.g. "page login DPI")
|
||||
fuzzy_threshold: fuzzy match threshold for OCR anchoring
|
||||
|
||||
Returns:
|
||||
GroundedElement with bbox + center, or None if not found
|
||||
"""
|
||||
target_text = target.get("text", "")
|
||||
target_role = target.get("role", "?")
|
||||
element_key = make_element_key(target_role, target_text)
|
||||
|
||||
# Step 0: Check cache
|
||||
if coords_cache:
|
||||
cached = coords_cache.get(element_key)
|
||||
if cached:
|
||||
cached.validation_count += 1
|
||||
logger.info("grounding: using cached coords for %s", element_key)
|
||||
return GroundedElement(
|
||||
role=target_role,
|
||||
text=target_text,
|
||||
bbox=cached.bbox,
|
||||
center=cached.center,
|
||||
confidence=1.0, # cached = previously validated
|
||||
method="cache",
|
||||
)
|
||||
|
||||
# Step 1: OCR-anchor (deterministic)
|
||||
try:
|
||||
ocr_tokens = ocr_client(screenshot_path)
|
||||
except Exception as e:
|
||||
logger.warning("grounding: OCR call failed (%s)", e)
|
||||
ocr_tokens = []
|
||||
|
||||
ocr_result = ocr_anchor_ground(ocr_tokens, target, fuzzy_threshold)
|
||||
|
||||
if ocr_result:
|
||||
if coords_cache:
|
||||
coords_cache.put(element_key, ocr_result.bbox, ocr_result.center, "ocr_anchor")
|
||||
logger.info(
|
||||
"grounding: OCR-anchor found '%s' (matched OCR='%s', conf=%.2f)",
|
||||
target_text, ocr_result.source_ocr_text, ocr_result.confidence,
|
||||
)
|
||||
return ocr_result
|
||||
|
||||
# Step 2: VLM grounder (fallback)
|
||||
if not target_text:
|
||||
logger.warning("grounding: no text for target, VLM grounder needs text")
|
||||
return None
|
||||
|
||||
prompt = build_grounder_prompt(target, context)
|
||||
|
||||
try:
|
||||
vlm_text = vlm_client(screenshot_path, prompt)
|
||||
except Exception as e:
|
||||
logger.warning("grounding: VLM grounder call failed (%s)", e)
|
||||
return None
|
||||
|
||||
vlm_result = parse_grounder_response(vlm_text, screen_width, screen_height, target)
|
||||
|
||||
if vlm_result:
|
||||
if coords_cache:
|
||||
coords_cache.put(element_key, vlm_result.bbox, vlm_result.center, "vlm_grounder")
|
||||
logger.info(
|
||||
"grounding: VLM grounder found '%s' (conf=%.2f)",
|
||||
target_text, vlm_result.confidence,
|
||||
)
|
||||
return vlm_result
|
||||
|
||||
logger.warning("grounding: element '%s' not found by OCR or VLM", target_text)
|
||||
return None
|
||||
227
core/navigation/visual_login.py
Normal file
227
core/navigation/visual_login.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Visual login — résolution + vérification du formulaire de login par grounding.
|
||||
|
||||
Architecture (alignée visual_verifier + grounding) :
|
||||
- verify_before : formulaire login visible (champs + bouton présents)
|
||||
- resolve_login_form : ground chaque champ (login, password, bouton) → coords
|
||||
- verify_after : dashboard/accueil visible (post-login)
|
||||
- Chaque étape encadrée par vision (DETTE-023 couvert)
|
||||
|
||||
Coords = cache local validé par vue (Dom/Claude recadrage).
|
||||
Le runtime exécute les actions (type/click) — ce module résout + valide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.navigation.grounding import (
|
||||
BBox,
|
||||
CoordsCache,
|
||||
GroundedElement,
|
||||
OcrDetailedClient,
|
||||
OcrTokenInfo,
|
||||
ground_element,
|
||||
)
|
||||
from core.navigation.visual_verifier import (
|
||||
OcrClient,
|
||||
ScreenMatchResult,
|
||||
VlmClient,
|
||||
verify_before,
|
||||
verify_after,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Dataclasses ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginFormConfig:
|
||||
"""Configuration for a login form — what to look for."""
|
||||
|
||||
login_field: Dict[str, Any] # {"role": "champ", "text": "Login"}
|
||||
password_field: Dict[str, Any] # {"role": "champ", "text": "Mot de passe"}
|
||||
submit_button: Dict[str, Any] # {"role": "bouton", "text": "Connexion"}
|
||||
success_elements: List[Dict[str, Any]] = field(default_factory=list)
|
||||
context: str = "" # e.g. "DPI urgences"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginResolution:
|
||||
"""Result of login form resolution — grounded coords for each field."""
|
||||
|
||||
login_field: Optional[GroundedElement] = None
|
||||
password_field: Optional[GroundedElement] = None
|
||||
submit_button: Optional[GroundedElement] = None
|
||||
all_resolved: bool = False
|
||||
method: str = "" # "ocr_anchor", "vlm_grounder", "mixed", "cache"
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = []
|
||||
if self.login_field:
|
||||
parts.append(f"login@{self.login_field.center} ({self.login_field.method})")
|
||||
else:
|
||||
parts.append("login: NOT FOUND")
|
||||
if self.password_field:
|
||||
parts.append(f"password@{self.password_field.center} ({self.password_field.method})")
|
||||
else:
|
||||
parts.append("password: NOT FOUND")
|
||||
if self.submit_button:
|
||||
parts.append(f"button@{self.submit_button.center} ({self.submit_button.method})")
|
||||
else:
|
||||
parts.append("button: NOT FOUND")
|
||||
status = "OK" if self.all_resolved else "INCOMPLETE"
|
||||
return f"Login resolution [{status}]: " + ", ".join(parts)
|
||||
|
||||
|
||||
# ── Default configs ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def dpi_urgences_login_config() -> LoginFormConfig:
|
||||
"""Default config for DPI urgences login form."""
|
||||
return LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login", "extra": "champ identifiant"},
|
||||
password_field={"role": "champ", "text": "Mot de passe", "extra": "champ password"},
|
||||
submit_button={"role": "bouton", "text": "Connexion", "extra": "bouton submit"},
|
||||
success_elements=[
|
||||
{"role": "page", "text": "Accueil"},
|
||||
{"role": "page", "text": "Dashboard"},
|
||||
],
|
||||
context="DPI urgences — page login",
|
||||
)
|
||||
|
||||
|
||||
# ── Helper ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ocr_detailed_to_simple(ocr_detailed: OcrDetailedClient) -> OcrClient:
|
||||
"""Convert OcrDetailedClient (text+bbox) to OcrClient (text-only) for verification."""
|
||||
def client(image_path: str) -> List[str]:
|
||||
return [t.text for t in ocr_detailed(image_path)]
|
||||
return client
|
||||
|
||||
|
||||
# ── Core functions ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def verify_login_visible(
|
||||
screenshot_path: str,
|
||||
config: LoginFormConfig,
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify login form is visible on screen (pre-condition).
|
||||
|
||||
Checks that login field, password field, and submit button are present.
|
||||
Uses OCR-anchored verification (deterministic presence, VLM role).
|
||||
"""
|
||||
expected = [
|
||||
config.login_field,
|
||||
config.password_field,
|
||||
config.submit_button,
|
||||
]
|
||||
return verify_before(
|
||||
screenshot_path, expected, ocr_client, vlm_client,
|
||||
context=config.context,
|
||||
)
|
||||
|
||||
|
||||
def verify_login_success(
|
||||
screenshot_path: str,
|
||||
config: LoginFormConfig,
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify dashboard/accueil visible after login (post-condition).
|
||||
|
||||
Higher threshold (verify_after = 0.8) — false positive = Léa proceeds wrong.
|
||||
"""
|
||||
if not config.success_elements:
|
||||
# No success criteria defined → can't verify
|
||||
return ScreenMatchResult(
|
||||
match=False,
|
||||
confidence=0.0,
|
||||
reason="no success_elements defined in config",
|
||||
)
|
||||
return verify_after(
|
||||
screenshot_path, config.success_elements, ocr_client, vlm_client,
|
||||
context=f"POST-LOGIN: {config.context}",
|
||||
)
|
||||
|
||||
|
||||
def resolve_login_form(
|
||||
screenshot_path: str,
|
||||
config: LoginFormConfig,
|
||||
ocr_client: OcrDetailedClient,
|
||||
vlm_client: VlmClient,
|
||||
screen_width: int = 1920,
|
||||
screen_height: int = 1080,
|
||||
coords_cache: Optional[CoordsCache] = None,
|
||||
) -> LoginResolution:
|
||||
"""Ground all login form elements → coords for runtime action.
|
||||
|
||||
Resolution strategy per element:
|
||||
1. Cache hit → return cached coords (validated separately)
|
||||
2. OCR-anchor → deterministic bbox from OCR token
|
||||
3. VLM grounder → fallback visual grounding
|
||||
|
||||
Returns LoginResolution with grounded coords for each field.
|
||||
Runtime uses these coords to type/click.
|
||||
"""
|
||||
login_el = ground_element(
|
||||
screenshot_path, config.login_field,
|
||||
ocr_client=ocr_client, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache, context=config.context,
|
||||
)
|
||||
|
||||
password_el = ground_element(
|
||||
screenshot_path, config.password_field,
|
||||
ocr_client=ocr_client, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache, context=config.context,
|
||||
)
|
||||
|
||||
button_el = ground_element(
|
||||
screenshot_path, config.submit_button,
|
||||
ocr_client=ocr_client, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache, context=config.context,
|
||||
)
|
||||
|
||||
all_resolved = login_el is not None and password_el is not None and button_el is not None
|
||||
|
||||
# Determine overall method
|
||||
methods = []
|
||||
if login_el:
|
||||
methods.append(login_el.method)
|
||||
if password_el:
|
||||
methods.append(password_el.method)
|
||||
if button_el:
|
||||
methods.append(button_el.method)
|
||||
|
||||
unique_methods = set(methods)
|
||||
if len(unique_methods) == 1:
|
||||
method = unique_methods.pop()
|
||||
elif len(unique_methods) > 1:
|
||||
method = "mixed"
|
||||
else:
|
||||
method = ""
|
||||
|
||||
resolution = LoginResolution(
|
||||
login_field=login_el,
|
||||
password_field=password_el,
|
||||
submit_button=button_el,
|
||||
all_resolved=all_resolved,
|
||||
method=method,
|
||||
)
|
||||
|
||||
if all_resolved:
|
||||
logger.info("resolve_login_form: %s", resolution.describe())
|
||||
else:
|
||||
logger.warning("resolve_login_form: incomplete — %s", resolution.describe())
|
||||
|
||||
return resolution
|
||||
408
core/navigation/visual_verifier.py
Normal file
408
core/navigation/visual_verifier.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""Visual verifier — verify_before / verify_after avec ancrage OCR.
|
||||
|
||||
Architecture OCR-ancrée (challenge Claude 01/07, gate-vert 30/06) :
|
||||
- PRESENCE = tokens OCR (déterministe, pas d'hallucination possible)
|
||||
- RÔLE = VLM confirmation (semantic, ancré sur tokens OCR trouvés)
|
||||
- VLM ne décide JAMAIS de la présence d'un élément
|
||||
- Faux positif impossible par construction ; faux négatif = retry acceptable
|
||||
|
||||
Pattern d'injection : OcrClient + VlmClient injectables (tests sans réseau).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type aliases — injectable callables for offline testing
|
||||
VlmClient = Callable[[str, str], str] # (image_path, prompt) -> text
|
||||
OcrClient = Callable[[str], List[str]] # (image_path) -> list of OCR text strings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenMatchResult:
|
||||
"""Result of a screen verification check."""
|
||||
|
||||
match: bool
|
||||
confidence: float = 0.0
|
||||
reason: str = ""
|
||||
observed_elements: List[Dict[str, Any]] = field(default_factory=list)
|
||||
expected_elements: List[Dict[str, Any]] = field(default_factory=list)
|
||||
mismatches: List[str] = field(default_factory=list)
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.match:
|
||||
return f"Screen match OK (conf={self.confidence:.2f})"
|
||||
parts = [f"Screen mismatch (conf={self.confidence:.2f})"]
|
||||
if self.mismatches:
|
||||
parts.append("missing: " + ", ".join(self.mismatches))
|
||||
if self.reason:
|
||||
parts.append(self.reason)
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# ── Text normalization (pure functions) ────────────────────────────────
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize text for fuzzy matching: lowercase, strip accents, collapse whitespace."""
|
||||
text = text.lower().strip()
|
||||
# Strip accents: é→e, è→e, ê→e, à→a, etc.
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
# Collapse whitespace
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text
|
||||
|
||||
|
||||
def fuzzy_match(expected: str, observed: str, threshold: float = 0.8) -> bool:
|
||||
"""Check if observed text fuzzy-matches expected text.
|
||||
|
||||
Three strategies (any wins):
|
||||
1. Exact match after normalization
|
||||
2. Substring containment (either direction)
|
||||
3. SequenceMatcher ratio >= threshold
|
||||
"""
|
||||
norm_expected = normalize_text(expected)
|
||||
norm_observed = normalize_text(observed)
|
||||
|
||||
if norm_expected == norm_observed:
|
||||
return True
|
||||
|
||||
if norm_expected in norm_observed or norm_observed in norm_expected:
|
||||
return True
|
||||
|
||||
ratio = SequenceMatcher(None, norm_expected, norm_observed).ratio()
|
||||
return ratio >= threshold
|
||||
|
||||
|
||||
# ── OCR presence check (deterministic, no VLM) ──────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrPresenceResult:
|
||||
"""Result of OCR-based presence check."""
|
||||
|
||||
found_texts: Dict[str, str] = field(default_factory=dict)
|
||||
missing: List[str] = field(default_factory=list)
|
||||
all_found: bool = False
|
||||
|
||||
@property
|
||||
def presence_ratio(self) -> float:
|
||||
if not self.found_texts:
|
||||
return 1.0
|
||||
found_count = sum(1 for v in self.found_texts.values() if v != "")
|
||||
return found_count / len(self.found_texts)
|
||||
|
||||
|
||||
def ocr_presence_check(
|
||||
ocr_tokens: List[str],
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
fuzzy_threshold: float = 0.8,
|
||||
) -> OcrPresenceResult:
|
||||
"""Check presence of expected texts against OCR tokens (deterministic).
|
||||
|
||||
Pure function — no VLM call, zero hallucination risk.
|
||||
"""
|
||||
found_texts: Dict[str, str] = {}
|
||||
missing: List[str] = []
|
||||
|
||||
for el in expected_elements:
|
||||
expected_text = el.get("text", "")
|
||||
if not expected_text:
|
||||
found_texts[""] = ""
|
||||
continue
|
||||
|
||||
matched_ocr = ""
|
||||
for token in ocr_tokens:
|
||||
if fuzzy_match(expected_text, token, threshold=fuzzy_threshold):
|
||||
matched_ocr = token
|
||||
break
|
||||
|
||||
if matched_ocr:
|
||||
found_texts[expected_text] = matched_ocr
|
||||
else:
|
||||
found_texts[expected_text] = ""
|
||||
missing.append(f"{el.get('role', '?')}: {expected_text}")
|
||||
|
||||
all_found = len(missing) == 0
|
||||
return OcrPresenceResult(
|
||||
found_texts=found_texts,
|
||||
missing=missing,
|
||||
all_found=all_found,
|
||||
)
|
||||
|
||||
|
||||
# ── VLM role confirmation (semantic, anchored on found OCR texts) ────
|
||||
|
||||
|
||||
def build_role_confirm_prompt(
|
||||
found_elements: List[Dict[str, Any]],
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
context: str = "",
|
||||
) -> str:
|
||||
"""Build VLM prompt for role confirmation of OCR-found elements.
|
||||
|
||||
VLM receives found texts and confirms their ROLE only — never presence.
|
||||
"""
|
||||
found_lines = []
|
||||
for i, el in enumerate(found_elements):
|
||||
matched_ocr = el.get("matched_ocr", "")
|
||||
expected_role = el.get("expected_role", "?")
|
||||
line = f"{i+1}. Text \"{matched_ocr}\" — expected role: {expected_role}"
|
||||
found_lines.append(line)
|
||||
|
||||
found_block = "\n".join(found_lines)
|
||||
|
||||
prompt = (
|
||||
"You are a screen role validator. OCR has confirmed these texts are "
|
||||
"present on the screen. Your job is ONLY to confirm their ROLE — "
|
||||
"do NOT re-declare whether they are present.\n"
|
||||
)
|
||||
if context:
|
||||
prompt += f"Context: {context}\n"
|
||||
prompt += (
|
||||
f"Found texts with expected roles:\n{found_block}\n\n"
|
||||
"Respond in JSON format:\n"
|
||||
"{\"confirmed\": [{\"index\": 1, \"role_confirmed\": true/false, "
|
||||
"\"actual_role\": \"...\", \"confidence\": 0.0-1.0}], "
|
||||
"\"overall_confidence\": 0.0-1.0}\n"
|
||||
"Only confirm role_confirmed=true if the text clearly plays the "
|
||||
"expected role (e.g., a button, not just a label with the same text)."
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_role_confirm_response(vlm_text: str) -> Dict[str, Any]:
|
||||
"""Parse VLM role confirmation JSON response."""
|
||||
try:
|
||||
data = json.loads(vlm_text)
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r"\{[\s\S]*\}", vlm_text)
|
||||
if json_match:
|
||||
try:
|
||||
data = json.loads(json_match.group())
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("role_confirm: VLM response not parseable as JSON")
|
||||
return {"confirmed": [], "overall_confidence": 0.0}
|
||||
else:
|
||||
return {"confirmed": [], "overall_confidence": 0.0}
|
||||
|
||||
confirmed = data.get("confirmed", [])
|
||||
overall_conf = data.get("overall_confidence", 0.0)
|
||||
if isinstance(overall_conf, str):
|
||||
try:
|
||||
overall_conf = float(overall_conf)
|
||||
except ValueError:
|
||||
overall_conf = 0.0
|
||||
|
||||
return {
|
||||
"confirmed": confirmed,
|
||||
"overall_confidence": float(overall_conf),
|
||||
}
|
||||
|
||||
|
||||
# ── Core verification (OCR-anchored composition) ────────────────────
|
||||
|
||||
|
||||
def verify_screen_match(
|
||||
screenshot_path: str,
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
context: str = "",
|
||||
min_confidence: float = 0.7,
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify screen state with OCR-anchored presence + VLM role confirmation.
|
||||
|
||||
Step 1: OCR screenshot → tokens → deterministic presence check
|
||||
Step 2: VLM confirms role of found elements (not presence!)
|
||||
|
||||
Eliminates VLM self-report hallucination for presence checks.
|
||||
"""
|
||||
if not expected_elements:
|
||||
return ScreenMatchResult(
|
||||
match=True,
|
||||
confidence=1.0,
|
||||
reason="no expected elements to verify",
|
||||
)
|
||||
|
||||
# Step 1: OCR presence check (deterministic)
|
||||
try:
|
||||
ocr_tokens = ocr_client(screenshot_path)
|
||||
except Exception as e:
|
||||
logger.warning("verify_screen_match: OCR call failed (%s)", e)
|
||||
return ScreenMatchResult(
|
||||
match=False,
|
||||
confidence=0.0,
|
||||
reason=f"OCR error: {e}",
|
||||
expected_elements=expected_elements,
|
||||
)
|
||||
|
||||
presence = ocr_presence_check(ocr_tokens, expected_elements)
|
||||
|
||||
if not presence.all_found:
|
||||
observed = []
|
||||
for el in expected_elements:
|
||||
text = el.get("text", "")
|
||||
matched = presence.found_texts.get(text, "")
|
||||
observed.append({
|
||||
"role": el.get("role", "?"),
|
||||
"expected_text": text,
|
||||
"matched_ocr": matched,
|
||||
"found": matched != "",
|
||||
})
|
||||
return ScreenMatchResult(
|
||||
match=False,
|
||||
confidence=presence.presence_ratio,
|
||||
reason="OCR presence check: some texts not found",
|
||||
observed_elements=observed,
|
||||
expected_elements=expected_elements,
|
||||
mismatches=presence.missing,
|
||||
)
|
||||
|
||||
# Step 2: VLM role confirmation (only for found elements)
|
||||
found_elements = []
|
||||
for el in expected_elements:
|
||||
text = el.get("text", "")
|
||||
matched_ocr = presence.found_texts.get(text, "")
|
||||
if text and matched_ocr:
|
||||
found_elements.append({
|
||||
"text": text,
|
||||
"expected_role": el.get("role", "?"),
|
||||
"matched_ocr": matched_ocr,
|
||||
})
|
||||
|
||||
if not found_elements:
|
||||
# All elements had no text → presence trivially OK
|
||||
return ScreenMatchResult(
|
||||
match=True,
|
||||
confidence=1.0,
|
||||
reason="no text-based elements to verify",
|
||||
expected_elements=expected_elements,
|
||||
)
|
||||
|
||||
prompt = build_role_confirm_prompt(found_elements, expected_elements, context)
|
||||
|
||||
try:
|
||||
vlm_text = vlm_client(screenshot_path, prompt)
|
||||
except Exception as e:
|
||||
logger.warning("verify_screen_match: VLM role confirm failed (%s)", e)
|
||||
observed = []
|
||||
for el in expected_elements:
|
||||
text = el.get("text", "")
|
||||
observed.append({
|
||||
"role": el.get("role", "?"),
|
||||
"expected_text": text,
|
||||
"matched_ocr": presence.found_texts.get(text, ""),
|
||||
"found": True,
|
||||
"role_confirmed": False,
|
||||
"role_confidence": 0.0,
|
||||
})
|
||||
return ScreenMatchResult(
|
||||
match=True,
|
||||
confidence=0.5,
|
||||
reason=f"OCR presence OK, VLM role confirm failed: {e}",
|
||||
observed_elements=observed,
|
||||
expected_elements=expected_elements,
|
||||
)
|
||||
|
||||
parsed = parse_role_confirm_response(vlm_text)
|
||||
overall_conf = parsed.get("overall_confidence", 0.0)
|
||||
confirmed = parsed.get("confirmed", [])
|
||||
|
||||
observed = []
|
||||
role_mismatches = []
|
||||
for i, el in enumerate(expected_elements):
|
||||
text = el.get("text", "")
|
||||
expected_role = el.get("role", "?")
|
||||
matched_ocr = presence.found_texts.get(text, "")
|
||||
|
||||
role_entry = None
|
||||
for c in confirmed:
|
||||
if c.get("index") == i + 1:
|
||||
role_entry = c
|
||||
break
|
||||
|
||||
role_confirmed = False
|
||||
actual_role = ""
|
||||
role_confidence = 0.0
|
||||
|
||||
if role_entry:
|
||||
role_confirmed = role_entry.get("role_confirmed", False)
|
||||
actual_role = role_entry.get("actual_role", "")
|
||||
role_confidence = role_entry.get("confidence", 0.0)
|
||||
if isinstance(role_confidence, str):
|
||||
try:
|
||||
role_confidence = float(role_confidence)
|
||||
except ValueError:
|
||||
role_confidence = 0.0
|
||||
|
||||
observed.append({
|
||||
"role": expected_role,
|
||||
"expected_text": text,
|
||||
"matched_ocr": matched_ocr,
|
||||
"found": True,
|
||||
"role_confirmed": role_confirmed,
|
||||
"actual_role": actual_role,
|
||||
"role_confidence": role_confidence,
|
||||
})
|
||||
|
||||
if not role_confirmed or role_confidence < min_confidence:
|
||||
role_mismatches.append(
|
||||
f"{expected_role}: {text} (actual={actual_role}, conf={role_confidence:.2f})"
|
||||
)
|
||||
|
||||
is_match = len(role_mismatches) == 0 and overall_conf >= min_confidence
|
||||
|
||||
return ScreenMatchResult(
|
||||
match=is_match,
|
||||
confidence=overall_conf,
|
||||
reason=f"OCR presence: {presence.presence_ratio:.0%}, VLM role: {overall_conf:.2f}",
|
||||
observed_elements=observed,
|
||||
expected_elements=expected_elements,
|
||||
mismatches=presence.missing + role_mismatches,
|
||||
)
|
||||
|
||||
|
||||
def verify_before(
|
||||
screenshot_path: str,
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
context: str = "",
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify screen state BEFORE an action (OCR-anchored).
|
||||
|
||||
Checks pre-conditions: expected texts present + roles correct.
|
||||
min_confidence=0.7 — some tolerance for pre-action verification.
|
||||
"""
|
||||
return verify_screen_match(
|
||||
screenshot_path, expected_elements, ocr_client, vlm_client,
|
||||
context=f"PRE-ACTION: {context}", min_confidence=0.7,
|
||||
)
|
||||
|
||||
|
||||
def verify_after(
|
||||
screenshot_path: str,
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
context: str = "",
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify screen state AFTER an action (OCR-anchored).
|
||||
|
||||
Checks post-conditions with higher threshold (0.8).
|
||||
False positive = Léa proceeds on wrong assumption → stricter gate.
|
||||
"""
|
||||
return verify_screen_match(
|
||||
screenshot_path, expected_elements, ocr_client, vlm_client,
|
||||
context=f"POST-ACTION: {context}", min_confidence=0.8,
|
||||
)
|
||||
@@ -208,7 +208,11 @@ REQUIRED=(
|
||||
"Lea/python-embed/Lib/site-packages/mss"
|
||||
"Lea/python-embed/Lib/site-packages/win32"
|
||||
"Lea/python-embed/Lib/site-packages/socketio"
|
||||
)
|
||||
"Lea/python-embed/Lib/site-packages/httpx"
|
||||
"Lea/python-embed/Lib/site-packages/httpcore"
|
||||
"Lea/python-embed/Lib/site-packages/h11"
|
||||
"Lea/python-embed/Lib/site-packages/anyio"
|
||||
"Lea/python-embed/Lib/site-packages/typing_extensions.py"
|
||||
MISSING=()
|
||||
for f in "${REQUIRED[@]}"; do
|
||||
[[ -e "$ASSEMBLY_DIR/$f" ]] || MISSING+=("$f")
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
; ============================================================
|
||||
|
||||
#define MyAppName "Lea"
|
||||
#define MyAppVersion "1.0.1"
|
||||
#define MyAppVersion "1.0.2"
|
||||
#define MyAppPublisher "AIVANOV"
|
||||
#define MyAppURL "https://lea.labs.laurinebazin.design"
|
||||
#define MyAppExeName "Lea.bat"
|
||||
@@ -182,6 +182,7 @@ var
|
||||
TokenPage: TInputQueryWizardPage;
|
||||
MachineIdValue: string;
|
||||
ConfigFilePath: string;
|
||||
ExistingMachineId: string;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Helper : ajoute des guillemets autour d'une chaine
|
||||
@@ -267,6 +268,72 @@ end;
|
||||
// --------------------------------------------------------------------
|
||||
procedure LoadConfigFromCommandLine(); forward;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// UPGRADE — trouve le dossier d'une install Lea existante (config.txt present)
|
||||
// --------------------------------------------------------------------
|
||||
function FindExistingInstallDir(): string;
|
||||
var
|
||||
Candidates: array[0..1] of string;
|
||||
I: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
Candidates[0] := ExpandConstant('{localappdata}\Programs\Lea');
|
||||
Candidates[1] := ExpandConstant('{autopf}\Lea');
|
||||
for I := 0 to 1 do
|
||||
begin
|
||||
if FileExists(Candidates[I] + '\config.txt') then
|
||||
begin
|
||||
Result := Candidates[I];
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// UPGRADE — lit le config.txt existant : pre-remplit le wizard avec la
|
||||
// VRAIE conf du poste (serveur/token/user) et MEMORISE le machine_id pour
|
||||
// le PRESERVER (ne pas regenerer une nouvelle identite fleet).
|
||||
// --------------------------------------------------------------------
|
||||
procedure LoadExistingConfig();
|
||||
var
|
||||
Dir, ConfPath: string;
|
||||
Lines: TArrayOfString;
|
||||
I, EqPos: Integer;
|
||||
Line, Key, Value: string;
|
||||
begin
|
||||
ExistingMachineId := '';
|
||||
Dir := FindExistingInstallDir();
|
||||
if Dir = '' then Exit; // install neuve -> comportement par defaut
|
||||
|
||||
ConfPath := Dir + '\config.txt';
|
||||
if LoadStringsFromFile(ConfPath, Lines) then
|
||||
begin
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
begin
|
||||
Line := Trim(Lines[I]);
|
||||
if (Length(Line) = 0) or (Line[1] = '#') then Continue;
|
||||
EqPos := Pos('=', Line);
|
||||
if EqPos = 0 then Continue;
|
||||
Key := Trim(Copy(Line, 1, EqPos - 1));
|
||||
Value := Trim(Copy(Line, EqPos + 1, Length(Line)));
|
||||
|
||||
if Key = 'RPA_SERVER_URL' then TokenPage.Values[0] := Value
|
||||
else if Key = 'RPA_API_TOKEN' then TokenPage.Values[1] := Value
|
||||
else if Key = 'RPA_USER_NAME' then EnrollmentPage.Values[0] := Value
|
||||
else if Key = 'RPA_USER_EMAIL' then EnrollmentPage.Values[1] := Value
|
||||
else if Key = 'RPA_USER_ID' then EnrollmentPage.Values[2] := Value
|
||||
else if Key = 'RPA_MACHINE_ID' then ExistingMachineId := Value;
|
||||
end;
|
||||
end;
|
||||
|
||||
// Fallback : machine_id.txt si absent du config.txt
|
||||
if (ExistingMachineId = '') and FileExists(Dir + '\machine_id.txt') then
|
||||
begin
|
||||
if LoadStringsFromFile(Dir + '\machine_id.txt', Lines) and (GetArrayLength(Lines) > 0) then
|
||||
ExistingMachineId := Trim(Lines[0]);
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Initialisation : cree les pages custom d'enrollment
|
||||
// --------------------------------------------------------------------
|
||||
@@ -301,7 +368,11 @@ begin
|
||||
TokenPage.Values[0] := SERVER_URL_DEFAULT;
|
||||
TokenPage.Values[1] := DEFAULT_TOKEN;
|
||||
|
||||
// Si un fichier /CONFIG= est passe en ligne de commande, pre-remplir
|
||||
// UPGRADE : si une install existe, pre-remplir avec SA config (pas les
|
||||
// defauts) et memoriser son machine_id pour le preserver.
|
||||
LoadExistingConfig();
|
||||
|
||||
// Si un fichier /CONFIG= est passe en ligne de commande, pre-remplir (prioritaire)
|
||||
LoadConfigFromCommandLine();
|
||||
end;
|
||||
|
||||
@@ -508,6 +579,54 @@ begin
|
||||
DeleteFile(PsFile);
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// UPGRADE — AVANT la copie des fichiers : tuer une Lea en cours (via le
|
||||
// PID du lock) pour liberer les DLL de python-embed. Evite une install
|
||||
// partielle / "reboot required". Ne tue QUE le PID du lock (jamais tous
|
||||
// les pythonw du poste).
|
||||
// --------------------------------------------------------------------
|
||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||
var
|
||||
AppDir, LockPath, BackupDir, SessionsDir: string;
|
||||
Lines: TArrayOfString;
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
AppDir := ExpandConstant('{app}');
|
||||
|
||||
// 1) Tuer une Lea en cours (via le PID du lock) pour liberer les DLL
|
||||
// python-embed. Ne tue QUE ce PID, jamais tous les pythonw du poste.
|
||||
LockPath := AppDir + '\lea_agent.lock';
|
||||
if FileExists(LockPath) then
|
||||
begin
|
||||
if LoadStringsFromFile(LockPath, Lines) and (GetArrayLength(Lines) > 0) then
|
||||
Exec('taskkill.exe', '/F /PID ' + Trim(Lines[0]), '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
DeleteFile(LockPath);
|
||||
Sleep(1500);
|
||||
end;
|
||||
|
||||
// UPGRADE uniquement (install existante detectee via config.txt).
|
||||
if FileExists(AppDir + '\config.txt') then
|
||||
begin
|
||||
// 2) BACKUP (rollback) : copie code+config vers <app>_backup, HORS
|
||||
// python-embed / sessions / logs (leger, rapide). Filet si la nouvelle
|
||||
// version deconne : Julien restaure ce dossier.
|
||||
BackupDir := AppDir + '_backup';
|
||||
Exec(ExpandConstant('{cmd}'),
|
||||
'/c rmdir /s /q "' + BackupDir + '" 2>nul & robocopy "' + AppDir + '" "' + BackupDir +
|
||||
'" /E /XD python-embed sessions logs __pycache__ /XF *.pyc /R:1 /W:1 /NFL /NDL /NJH /NJS /NP >nul 2>&1',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
|
||||
// 3) PURGE des captures accumulees (donnees d'apprentissage internes, non
|
||||
// exploitables cote clinique) : libere le disque. Le fix capture JPEG
|
||||
// evite que la saturation reprenne. Les logs (compliance 180j) restent.
|
||||
SessionsDir := AppDir + '\agent_v1\sessions';
|
||||
if DirExists(SessionsDir) then
|
||||
Exec(ExpandConstant('{cmd}'),
|
||||
'/c rmdir /s /q "' + SessionsDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Hook : actions apres copie des fichiers (ssPostInstall)
|
||||
// --------------------------------------------------------------------
|
||||
@@ -515,8 +634,11 @@ procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssInstall then
|
||||
begin
|
||||
// Genere le machine_id AVANT la copie des fichiers
|
||||
MachineIdValue := GenerateMachineId();
|
||||
// UPGRADE : preserver l'identite existante ; sinon en generer une neuve.
|
||||
if ExistingMachineId <> '' then
|
||||
MachineIdValue := ExistingMachineId
|
||||
else
|
||||
MachineIdValue := GenerateMachineId();
|
||||
end;
|
||||
|
||||
if CurStep = ssPostInstall then
|
||||
|
||||
@@ -81,16 +81,29 @@ cd deploy/installer
|
||||
wget https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip
|
||||
mkdir python-3.12-embed
|
||||
unzip python-3.12.8-embed-amd64.zip -d python-3.12-embed/
|
||||
|
||||
# IMPORTANT : l'embed doit contenir TOUTES les dependances HORS LIGNE.
|
||||
# Le runtime client ne fait AUCUN pip/reseau (POC clinique). On installe donc
|
||||
# les dependances une fois dans l'embed, puis on le commit/reutilise tel quel :
|
||||
python312._pth # decommenter 'import site'
|
||||
python -m pip install --target python-3.12-embed/Lib/site-packages \
|
||||
-r ../lea_package/requirements_agent.txt
|
||||
# => doit inclure httpx (+ httpcore, h11) pour l'orchestrateur Lea (POST /api/learn/start).
|
||||
```
|
||||
|
||||
Le staging copie automatiquement ce dossier si present. Le composant
|
||||
"pythonembed" devient alors selectionnable dans l'installeur.
|
||||
|
||||
Le script `configure_embed.ps1` :
|
||||
Le script `configure_embed.ps1` (execute a l'installation, sur le poste) :
|
||||
1. Patche `python312._pth` pour activer `import site`
|
||||
2. Installe `pip` via `get-pip.py`
|
||||
3. Installe `requirements_agent.txt`
|
||||
4. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe`
|
||||
2. VERIFIE que les dependances sont deja embarquees (offline, aucun pip/reseau) —
|
||||
`socketio, tkinter, mss, pynput, pystray, plyer, requests, httpx, PIL, win32api` ;
|
||||
si une dependance manque, l'installation echoue explicitement.
|
||||
3. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe`
|
||||
|
||||
> Note : `build_installer.sh` et `build_package_full.sh` valident aussi la presence
|
||||
> des paquets (dont `httpx`, `httpcore`, `h11`) dans `Lib/site-packages/` avant de
|
||||
> produire le paquet — un embed incomplet interrompt le build cote Linux.
|
||||
|
||||
## Installation silencieuse (deploiement de masse)
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ REQUIRED_EMBED=(
|
||||
"Lib/site-packages/pystray" "Lib/site-packages/plyer"
|
||||
"Lib/site-packages/requests" "Lib/site-packages/PIL"
|
||||
"Lib/site-packages/win32"
|
||||
"Lib/site-packages/httpx" "Lib/site-packages/httpcore" "Lib/site-packages/h11"
|
||||
"Lib/site-packages/anyio" "Lib/site-packages/typing_extensions.py"
|
||||
)
|
||||
MISSING_EMBED=()
|
||||
for f in "${REQUIRED_EMBED[@]}"; do
|
||||
|
||||
@@ -25,3 +25,5 @@ USER_ID=
|
||||
# Connexion serveur (remplacer les valeurs CONFIGURE_ME avant utilisation)
|
||||
SERVER_URL=CONFIGURE_ME
|
||||
API_TOKEN=CONFIGURE_ME
|
||||
|
||||
AGENT_VERSION=1.0.2
|
||||
|
||||
@@ -44,7 +44,7 @@ if ($PthFile) {
|
||||
# L'embed DOIT contenir toutes les dependances runtime.
|
||||
# AUCUN pip, AUCUN reseau : si une dependance manque -> echec explicite.
|
||||
# ---------------------------------------------------------------
|
||||
$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','PIL','win32api')
|
||||
$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','httpx','PIL','win32api')
|
||||
$Missing = @()
|
||||
foreach ($m in $RequiredModules) {
|
||||
& $PythonExe -c "import $m" 2>$null
|
||||
@@ -76,6 +76,29 @@ if exist "lea_agent.lock" (
|
||||
timeout /t 2 >nul
|
||||
)
|
||||
|
||||
:: MAJ SILENCIEUSE — swap atomique + rollback (renames uniquement)
|
||||
if exist "PENDING_BOOT" (
|
||||
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
|
||||
if exist "agent_v1_prev" (
|
||||
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
|
||||
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
|
||||
move "agent_v1_prev" "agent_v1" >nul 2>&1
|
||||
)
|
||||
del /f /q "PENDING_BOOT" >nul 2>&1
|
||||
) else if exist "UPDATE_READY" (
|
||||
if exist "agent_v1_new" (
|
||||
echo [MAJ] Application de la mise a jour...
|
||||
if exist "agent_v1" (
|
||||
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
|
||||
move "agent_v1" "agent_v1_prev" >nul 2>&1
|
||||
)
|
||||
move "agent_v1_new" "agent_v1" >nul 2>&1
|
||||
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
|
||||
) else (
|
||||
del /f /q "UPDATE_READY" >nul 2>&1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "config.txt" (
|
||||
for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do (
|
||||
if not "%%a"=="" if not "%%b"=="" set "%%a=%%b"
|
||||
|
||||
@@ -20,6 +20,35 @@ if exist "lea_agent.lock" (
|
||||
timeout /t 2 >nul
|
||||
)
|
||||
|
||||
:: ---------------------------------------------------------------
|
||||
:: MAJ SILENCIEUSE — swap atomique + rollback (hors-process)
|
||||
:: L'ancienne instance est fermee ci-dessus : agent_v1\ est libre.
|
||||
:: Renames uniquement (quasi-atomiques), jamais d'ecrasement fichier par fichier.
|
||||
:: ---------------------------------------------------------------
|
||||
if exist "PENDING_BOOT" (
|
||||
:: Le boot precedent n'a JAMAIS confirme (crash) -> ROLLBACK version precedente
|
||||
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
|
||||
if exist "agent_v1_prev" (
|
||||
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
|
||||
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
|
||||
move "agent_v1_prev" "agent_v1" >nul 2>&1
|
||||
)
|
||||
del /f /q "PENDING_BOOT" >nul 2>&1
|
||||
) else if exist "UPDATE_READY" (
|
||||
:: Une MAJ est armee (agent_v1_new pret) -> SWAP
|
||||
if exist "agent_v1_new" (
|
||||
echo [MAJ] Application de la mise a jour...
|
||||
if exist "agent_v1" (
|
||||
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
|
||||
move "agent_v1" "agent_v1_prev" >nul 2>&1
|
||||
)
|
||||
move "agent_v1_new" "agent_v1" >nul 2>&1
|
||||
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
|
||||
) else (
|
||||
del /f /q "UPDATE_READY" >nul 2>&1
|
||||
)
|
||||
)
|
||||
|
||||
:: ---------------------------------------------------------------
|
||||
:: Verifier que l'installation a ete faite
|
||||
:: ---------------------------------------------------------------
|
||||
|
||||
@@ -36,5 +36,15 @@ RPA_MACHINE_ID=CONFIGURE_ME
|
||||
RPA_USER_LABEL=CONFIGURE_ME
|
||||
|
||||
# --- Parametres avances (ne pas modifier sauf indication) ---
|
||||
RPA_AGENT_VERSION=1.0.2
|
||||
RPA_BLUR_SENSITIVE=false
|
||||
RPA_LOG_RETENTION_DAYS=180
|
||||
|
||||
# --- MAJ silencieuse (DETTE-022 v2) — DESACTIVEE par defaut ---
|
||||
# Deploiement CANARY : on active d'ABORD ce flag sur le SEUL poste pilote
|
||||
# (Emilie), on verifie, puis on elargit. Le poste interroge le serveur et
|
||||
# telecharge la MAJ en staging ; le remplacement reel des fichiers reste manuel
|
||||
# / supervise (reserve revision humaine). Decommenter pour activer ce poste :
|
||||
# RPA_AUTO_UPDATE_ENABLED=true
|
||||
# Intervalle d'interrogation serveur en secondes (defaut 3600 = 1h) :
|
||||
# RPA_AUTO_UPDATE_INTERVAL_S=3600
|
||||
|
||||
@@ -5,6 +5,7 @@ mss>=9.0.1 # Capture d'ecran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris
|
||||
Pillow>=10.0.0 # Traitement image (crops, compression)
|
||||
requests>=2.31.0 # Communication serveur
|
||||
httpx>=0.27 # Client HTTP orchestrateur Lea (POST /api/learn/start) - brique conversationnelle
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
pystray>=0.19.5 # Icone systray
|
||||
plyer>=2.1.0 # Notifications toast natives
|
||||
|
||||
106
docs/AUDIT_CODE_MORT_2026-07-02.md
Normal file
106
docs/AUDIT_CODE_MORT_2026-07-02.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Audit Code Mort — Classification A/B/C — 2026-07-02
|
||||
|
||||
**Auteur**: Qwen (vérifié par grep/glob/commandes réelles)
|
||||
**Date**: 2026-07-02
|
||||
**Méthode**: Parallel agent exploration + grep verification + graphify cross-check
|
||||
|
||||
---
|
||||
|
||||
## Méthodologie
|
||||
|
||||
- **A (WIRED/ACTIF)** : Code importé et appelé dans le runtime de production
|
||||
- **B (ORPHAN/PROJECTION)** : Code avec lazy import ou projection future, pas appelé actuellement mais structuré pour activation
|
||||
- **C (MORT/CONFIRMÉ)** : Code zero imports, zero callers, zero runtime activation — candidat suppression
|
||||
|
||||
**Règle**: C-MORT nécessite GO Dom avant suppression. B-ORPHELIN conserve. A-WIRED documenté.
|
||||
|
||||
---
|
||||
|
||||
## C-MORT Confirmé (8 items, ~843 lignes)
|
||||
|
||||
| # | Fichier/Zone | Lignes | Preuve C-MORT | Risque suppression |
|
||||
|---|-------------|--------|---------------|-------------------|
|
||||
| C1 | `agent_v0/deploy_windows.py` | ~244 | Comment "OBSOLETE avril 2026" + zero imports | LOW — standalone script |
|
||||
| C2 | `core/config.py`: 7 deprecated config classes | ~160 | Zero prod imports, mirrorent SystemConfig | LOW — mais vérifier .env references |
|
||||
| C3 | `core/detection/owl_detector.py`: 4 methods | ~90 | Zero callers dans prod | LOW — vérifier examples/ |
|
||||
| C4 | `core/detection/ollama_client.py`: 5 old methods | ~150 | Remplacés par classify_element_complete() | LOW — vérifier examples/ |
|
||||
| C5 | `ollama_client.py:check_ollama_available()` standalone | ~15 | 8/9 callers in examples/, 1 in VWB (duplicat D2) | LOW — VWB a sa propre copie |
|
||||
| C6 | `agent_chat/app.py`: 2 Flask 410 endpoints | ~14 | Endpoints déprecated, retour 410 Gone | LOW — API contract check |
|
||||
| C7 | `core/grounding/smart_resize.py` (77 lines) | 77 | Zero prod callers, DETTE-007 triple impl | LOW — 2 autres impls existent |
|
||||
| C8 | PP-OCRv5 (paddleocr+paddlepaddle venv) | ~deps | 0 .py imports across entire project | LOW — venv deps uninstall |
|
||||
|
||||
**Total C-MORT**: ~843 lignes code + venv deps
|
||||
|
||||
---
|
||||
|
||||
## B-ORPHELIN (5 items, ~537 lignes)
|
||||
|
||||
| # | Fichier/Zone | Lignes | Preuve B | Action |
|
||||
|---|-------------|--------|----------|--------|
|
||||
| B1 | VWB ui_detection_service OmniParser path | ~70 | HARD-DISABILÉ `_omniparser_available = False # DÉSACTIVÉ` | Conserver, documenter activation condition |
|
||||
| B2 | `fusion_engine.py:_fuse_concat_projection()` | ~15 | Stub, prévu pour future fusion modes | Conserver, marque PROJECTION |
|
||||
| B3 | `omniparser_adapter.py` | ~429 | BRANCHABLE DORMANT, try/except import | Conserver, documenter activation condition |
|
||||
| B4 | `CorrectionStatus.DEPRECATED` enum value | ~3 | Enum value, pas supprimable sans break | Conserver, marque DEPRECATED |
|
||||
| B5 | `catalog_routes_v2_vlm.py:check_ollama_available()` | ~20 | Duplicat de ollama_client.py (D2) | DÉCISION Dom : unifier ou garder 2 impls |
|
||||
|
||||
---
|
||||
|
||||
## Duplicats Identifiés (4)
|
||||
|
||||
| # | Item | Impl 1 | Impl 2 | Statut |
|
||||
|---|------|--------|--------|--------|
|
||||
| D1 | smart_resize | smart_resize.py (C7) | ui_detection_service.py resize | C-MORT vs WIRED |
|
||||
| D2 | check_ollama_available | ollama_client.py (C5) | catalog_routes_v2_vlm.py (B5) | C-MORT vs B-ORPHELIN |
|
||||
| D3 | ground_element | seeclick_adapter.py (B→provenance?) | ollama_client.py old method | B vs C4 |
|
||||
| D4 | 7 deprecated config classes | core/config.py (C2) | SystemConfig (WIRED) | C-MORT vs A-WIRED |
|
||||
|
||||
---
|
||||
|
||||
## Classification Updates (C→A upgrades confirmés)
|
||||
|
||||
| Item | Prior Status | Current Status | Preuve upgrade |
|
||||
|------|-------------|---------------|---------------|
|
||||
| autonomous_planner.py | C | **A** | Migrated to agent_chat/, wired by app.py |
|
||||
| seeclick_adapter.py | C | **B** | Lazy re-export, `_seeclick_available` never consulted mais impl ground_element indépendante |
|
||||
| grounding/server.py | C | **A** | HTTP service port 8200, standalone Flask |
|
||||
| get_grounding_profile() | C | **A** | Wired via ollama_client.py:303-304 lazy import |
|
||||
|
||||
---
|
||||
|
||||
## OmniParser — Classification 7 Zones
|
||||
|
||||
| # | Zone | Statut | Activation | Fallback |
|
||||
|---|------|---------|-----------|----------|
|
||||
| 1 | SoM engine (som_engine.py) | **A-WIRED** | YOLO weights direct | docTR OCR |
|
||||
| 2 | resolve_engine (_get_omniparser) | **B-DORMANT** | Lazy Optional[bool] | None → skipped |
|
||||
| 3 | phase25_analyzer (_OmniParserSafeWrapper) | **B-DORMANT** | Lazy import + healthcheck | docTR-only |
|
||||
| 4 | api_stream healthcheck | **A-WIRED** | Always 200 omniparser_available:bool | degraded:true |
|
||||
| 5 | omniparser_adapter.py | **B-DORMANT** | Import phase25 & resolve | empty list |
|
||||
| 6 | VWB ui_detection_service.py | **B-HARD-DISABILÉ** | `_omniparser_available = False # DÉSACTIVÉ` | ui-detr-1 only |
|
||||
| 7 | VWB catalog_routes_v2_vlm.py | **B-DORMANT** | try/except, flips True si installé | VLM fallback |
|
||||
|
||||
---
|
||||
|
||||
## QG-Gated Lots (proposé, nécessite GO Dom)
|
||||
|
||||
### Lot 1 — C-MORT Low Risk (suppression directe après GO Dom)
|
||||
- C1 deploy_windows.py
|
||||
- C7 smart_resize.py
|
||||
- C6 agent_chat 410 endpoints
|
||||
- C8 PP-OCRv5 venv deps uninstall
|
||||
|
||||
### Lot 2 — C-MORT Medium Risk (vérification examples/ avant suppression)
|
||||
- C2 7 deprecated config classes (vérifier .env)
|
||||
- C3 owl_detector 4 methods (vérifier examples/)
|
||||
- C4 ollama_client 5 old methods (vérifier examples/)
|
||||
- C5 check_ollama_available standalone (vérifier VWB duplicat)
|
||||
|
||||
### Lot 3 — Duplicats Unification (décision Dom)
|
||||
- D1 smart_resize: unifier ou garder 2 impls
|
||||
- D2 check_ollama_available: unifier VWB vs core
|
||||
- D3 ground_element: unifier seeclick vs ollama
|
||||
- D4 config classes: supprimer deprecated vs garder compat
|
||||
|
||||
---
|
||||
|
||||
**Prochaine étape**: Dom review → GO/NOGO par lot → exécution séquentielle avec tests verification après chaque lot.
|
||||
193
docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md
Normal file
193
docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# DESIGN — MAJ silencieuse du client Léa + déploiement CANARY (DETTE-022 v2)
|
||||
|
||||
Date : 2026-07-01
|
||||
Branche : `feat/push-log-dgx`
|
||||
Statut : **premier draft fonctionnel — GATED OFF partout, aucun swap réel, revue supervisée Dom requise avant toute activation**
|
||||
|
||||
> ⚠️ RIEN N'A ÉTÉ DÉPLOYÉ. Aucun SSH poste, aucune action fleet. Ce document +
|
||||
> le code de la branche sont un livrable de conception/implémentation pour revue.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problème
|
||||
|
||||
Pousser des correctifs au client Léa sur ~19 postes cliniques live (Wallerstein)
|
||||
**sans** patch manuel DSI et **sans** déranger les TIM en plein travail. Contrainte
|
||||
absolue : une MAJ ratée peut **briquer toute la flotte**. Le mécanisme doit donc
|
||||
être **conservateur** : canary lent + rollback béton plutôt que rapide et risqué.
|
||||
|
||||
## 2. État de départ (stub commit `813b33b47`) — ce qui existait déjà
|
||||
|
||||
Le noyau était plus avancé qu'un simple squelette. Déjà présent et **testé (vert)** :
|
||||
|
||||
| Brique | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Décision serveur PURE | `agent_v0/server_v1/update_check.py` | `parse_version`/`is_newer` (semver correct : `1.0.2 < 1.0.10`), `decide_update()`, `build_download_url()` |
|
||||
| Endpoint serveur gated | `agent_v0/server_v1/api_stream.py:7843+` | `GET /api/v1/agents/update/check` — **503 si `RPA_AUTO_UPDATE_SERVER_ENABLED` OFF**, Bearer requis |
|
||||
| Noyau client PUR | `agent_v0/agent_v1/network/updater.py` | `auto_update_enabled()` (flag `RPA_AUTO_UPDATE_ENABLED`, défaut OFF), `should_update()` (double garde anti-downgrade), `download_update()` (staging + SHA256, ne touche jamais les fichiers vivants) |
|
||||
| **Stubs dangereux (no-op)** | `updater.py:246+` | `apply_update()` / `write_boot_ok_marker()` — **réservés révision humaine** (swap fichiers, édition `Lea.bat`, restart) |
|
||||
| Version agent | `agent_v0/agent_v1/config.py:30` | `AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1")` (amorcé `105ade959`) |
|
||||
| Tests | `tests/unit/test_update_check_server.py`, `tests/unit/test_agent_v1_updater.py`, `tests/integration/test_update_check_endpoint.py` | R2/R3 verts |
|
||||
|
||||
### Ce qui MANQUAIT (comblé par ce draft)
|
||||
|
||||
1. **Aucune logique canary** : `decide_update` recevait `machine_id` mais l'ignorait pour choisir la version. La version cible était une seule var globale `RPA_AGENT_LATEST_VERSION` → une MAJ partait sur **toute** la flotte d'un coup. **C'est le trou de sécurité n°1.**
|
||||
2. **Le noyau client n'était pas wiré** : `updater.py` n'était appelé nulle part. `main.py` ne l'importait pas. Aucun caller HTTP de `/agents/update/check`.
|
||||
3. **Pas d'orchestrateur** reliant check → décide → download (staging) côté client.
|
||||
|
||||
## 3. Fleet / versioning existant (réutilisé, pas réinventé)
|
||||
|
||||
- Registre SQLite `enrolled_agents` (`agent_v0/server_v1/agent_registry.py:105`) : colonne `version` + `last_seen_at` par `machine_id`. Le dashboard Fleet (`web_dashboard/templates/index.html:2247`) affiche déjà la version par poste.
|
||||
- **Limite connue** : `version` n'est écrite qu'à l'`enroll` (installateur), pas rafraîchie par le heartbeat runtime. Le serveur connaît donc la version *installée*, pas forcément la *version vive*. → **inventaire de version = amélioration future** (voir §8), non bloquante pour le canary (le canary est piloté par une allow-list de `machine_id`, pas par l'inventaire).
|
||||
|
||||
## 4. Design retenu (et pourquoi)
|
||||
|
||||
Aligné sur l'état de l'art self-update desktop 2025 (canary / blue-green / A-B swap + watchdog rollback + intégrité + version) — sources en fin de doc.
|
||||
|
||||
### 4.1 CANARY côté serveur — la keystone de sécurité (IMPLÉMENTÉ)
|
||||
|
||||
Nouveau module PUR `agent_v0/server_v1/update_policy.py`. Il résout la version cible
|
||||
**PAR MACHINE** :
|
||||
|
||||
- poste dans l'allow-list canary → `canary_version` (la nouvelle) ;
|
||||
- tous les autres postes → `stable_version` (le floor, inchangé).
|
||||
|
||||
Piloté 100 % par **variables d'environnement serveur** (aucun rebuild, aucune
|
||||
DSI) :
|
||||
|
||||
```
|
||||
RPA_AGENT_STABLE_VERSION # version servie à TOUTE la flotte (défaut 1.0.1)
|
||||
RPA_AGENT_CANARY_VERSION # version servie AUX SEULS postes canary (optionnel)
|
||||
RPA_AGENT_CANARY_MACHINES # allow-list CSV des machine_id canary
|
||||
```
|
||||
|
||||
Garde-fous du résolveur (tous prudents par défaut) :
|
||||
- machine_id absent / liste vide / pas de `canary_version` → **stable** ;
|
||||
- `canary_version` doit être **strictement plus récente** que `stable` (sinon on sert stable — jamais de recul) ;
|
||||
- ne lève jamais ; version illisible → retombe sur stable via le comparateur semver tolérant.
|
||||
|
||||
Wiring : `_latest_agent_version(machine_id)` dans `api_stream.py` appelle
|
||||
`resolve_target_version_from_env(machine_id)`. **Rétrocompat** : si l'ancienne
|
||||
`RPA_AGENT_LATEST_VERSION` est positionnée, elle prime (pas de régression d'un
|
||||
déploiement existant).
|
||||
|
||||
**Effet** : la 1.0.2 ne peut PAS fuiter hors de la liste canary. Blast radius =
|
||||
la liste. On démarre la liste = `lea-4zbgwxty` (Émilie) seul.
|
||||
|
||||
**Promotion** = quand le canary est validé : `RPA_AGENT_STABLE_VERSION=<canary>`
|
||||
+ vider `RPA_AGENT_CANARY_MACHINES` → toute la flotte suit.
|
||||
**Rollback canary** = vider `RPA_AGENT_CANARY_MACHINES` / remettre l'ancienne
|
||||
`RPA_AGENT_CANARY_VERSION` → le prochain check ne propose plus rien.
|
||||
|
||||
### 4.2 Orchestrateur client (IMPLÉMENTÉ, GATED, sans swap)
|
||||
|
||||
`updater.run_update_cycle(local_version, machine_id, staging_dir, checker?, downloader?)` :
|
||||
|
||||
1. **GATE** `auto_update_enabled()` (`RPA_AUTO_UPDATE_ENABLED`, défaut OFF) — si OFF, ne fait **strictement rien**, aucun appel réseau ;
|
||||
2. `checker(...)` → réponse serveur (défaut = `_default_update_checker` : GET vers l'endpoint gated, Bearer, 503→None, jamais d'exception) ;
|
||||
3. `should_update(...)` → plan (double garde semver anti-downgrade) ;
|
||||
4. `download_update(...)` → ZIP en **staging** + vérif **SHA256** (fichiers vivants jamais touchés) ;
|
||||
5. `apply_update(staged)` = **stub no-op** → résultat `applied: False`. **Le swap réel n'est PAS fait par du code d'agent.**
|
||||
|
||||
Statuts retournés (diagnostic/log) : `disabled | check_failed | up_to_date | download_failed | staged`. Best-effort total : aucune exception ne remonte (ne casse jamais Léa).
|
||||
|
||||
### 4.3 Wiring runtime (IMPLÉMENTÉ, GATED)
|
||||
|
||||
`main.py` : thread daemon `_auto_update_loop`, démarré **uniquement si**
|
||||
`AUTO_UPDATE_ENABLED`, à côté des boucles permanentes existantes (même pattern
|
||||
que le log shipper). Sécurité « **au bon moment** » : on ne stage PAS pendant un
|
||||
enregistrement (`self.session_id`) ou un replay actif (`self._replay_active`) —
|
||||
pas de perturbation du travail TIM. Intervalle `RPA_AUTO_UPDATE_INTERVAL_S`
|
||||
(défaut **3600 s / 1 h** : une MAJ n'est jamais urgente).
|
||||
|
||||
### 4.4 Intégrité + version
|
||||
|
||||
- **Intégrité** : SHA256 vérifié dans `download_update` (déjà présent) ; mismatch → rejet + staging propre.
|
||||
- **Version** : `AGENT_VERSION` envoyée à chaque check (`current_version`) ; le serveur choisit la cible par machine.
|
||||
- **Signature (à ajouter, §8)** : SHA256 seul protège de la corruption, pas de l'usurpation. Recommandation : signer le manifeste (le SHA256 vient d'un canal authentifié — l'endpoint Bearer — donc chaîne acceptable pour le POC ; signature détachée = durcissement futur).
|
||||
|
||||
### 4.5 Swap atomique + rollback (SPEC — réservé révision humaine, PAS codé par agent)
|
||||
|
||||
Le swap réel reste dans les stubs `apply_update` / `write_boot_ok_marker` et
|
||||
dans `Lea.bat`. **Un agent ne doit pas écrire de code qui écrase des binaires
|
||||
vivants ni relance un process.** Spec cible pour la revue humaine :
|
||||
|
||||
- **A-B / staging** : le ZIP est extrait dans `Lea_next\`. Au **prochain démarrage**, `Lea.bat` (hors-process) : backup `Lea\`→`Lea_prev\`, swap `Lea_next\`→`Lea\`, lance la nouvelle version.
|
||||
- **Watchdog rollback** : la nouvelle version doit écrire un marker `boot_ok_<version>` **après** ~60 s de heartbeat DGX sain + session OK. Si `Lea.bat` ne trouve pas le marker au démarrage suivant (crash au boot), il restaure `Lea_prev\` automatiquement. Cible « rollback latency » < 90 s (état de l'art).
|
||||
- **Cas edge** (documenté dans les stubs) : DGX down ≠ Léa N+1 buguée — le health-check doit distinguer les deux pour éviter un faux rollback.
|
||||
|
||||
## 5. Fichiers touchés (cette branche)
|
||||
|
||||
**Ajouts**
|
||||
- `agent_v0/server_v1/update_policy.py` — canary PUR (résolveur par machine + lecture env).
|
||||
- `tests/unit/test_update_policy_canary.py` — TDD canary (résolveur + env).
|
||||
|
||||
**Modifs**
|
||||
- `agent_v0/server_v1/api_stream.py` — `_latest_agent_version(machine_id)` canary-aware (rétrocompat legacy) + docstring endpoint.
|
||||
- `agent_v0/agent_v1/network/updater.py` — `_default_update_checker()` + `run_update_cycle()` (orchestrateur gated, sans swap).
|
||||
- `agent_v0/agent_v1/config.py` — `AUTO_UPDATE_INTERVAL_S`, `AUTO_UPDATE_STAGING_DIR`.
|
||||
- `agent_v0/agent_v1/main.py` — thread `_auto_update_loop` gated + import config.
|
||||
- `tests/unit/test_agent_v1_updater.py` — TDD `run_update_cycle` (gate off, up-to-date, staged, sha mismatch, checker raise).
|
||||
- `tests/integration/test_update_check_endpoint.py` — TDD canary HTTP (poste canary vs hors-canary).
|
||||
- `deploy/lea_package/config.txt` — flags client MAJ documentés (commentés, OFF).
|
||||
|
||||
**Intacts (réservés révision humaine)** : `updater.apply_update`, `updater.write_boot_ok_marker`, `Lea.bat`.
|
||||
|
||||
## 6. Matrice des flags (tout OFF par défaut)
|
||||
|
||||
| Flag | Côté | Défaut | Effet |
|
||||
|---|---|---|---|
|
||||
| `RPA_AUTO_UPDATE_SERVER_ENABLED` | serveur | OFF (503) | active l'endpoint de décision |
|
||||
| `RPA_AGENT_STABLE_VERSION` | serveur | `1.0.1` | version floor de toute la flotte |
|
||||
| `RPA_AGENT_CANARY_VERSION` | serveur | — | nouvelle version, postes canary seulement |
|
||||
| `RPA_AGENT_CANARY_MACHINES` | serveur | — | allow-list CSV canary |
|
||||
| `RPA_AGENT_LATEST_VERSION` (legacy) | serveur | — | si set, prime sur le canary (rétrocompat) |
|
||||
| `RPA_AUTO_UPDATE_ENABLED` | client | OFF | active la boucle de check + staging |
|
||||
| `RPA_AUTO_UPDATE_INTERVAL_S` | client | `3600` | intervalle de check |
|
||||
|
||||
## 7. Plan de déploiement CANARY (étapes + critères GO / ROLLBACK)
|
||||
|
||||
> Prérequis avant TOUTE étape : la mécanique de **swap réel** (§4.5) doit avoir
|
||||
> été implémentée et revue par un humain. Tant qu'elle est en stub, ce plan ne
|
||||
> fait que **stager** un ZIP (aucun poste ne change réellement de version) — ce
|
||||
> qui est déjà utile pour valider la chaîne check/download/intégrité à vide.
|
||||
|
||||
**Étape 0 — Serveur seul (aucun poste touché)**
|
||||
- Action : `RPA_AUTO_UPDATE_SERVER_ENABLED=true`, `RPA_AGENT_STABLE_VERSION=1.0.1`, PAS de canary encore.
|
||||
- GO si : `GET /agents/update/check` répond 200 pour un `machine_id` quelconque avec `update_available:false`. Aucun poste n'a la MAJ activée côté client.
|
||||
- ROLLBACK : repasser le flag serveur OFF.
|
||||
|
||||
**Étape 1 — Canary Émilie, staging seul**
|
||||
- Action serveur : `RPA_AGENT_CANARY_VERSION=<nouvelle>`, `RPA_AGENT_CANARY_MACHINES=lea-4zbgwxty`.
|
||||
- Action poste Émilie (config.txt) : `RPA_AUTO_UPDATE_ENABLED=true`.
|
||||
- GO si : dans les logs d'Émilie (remontés par le push-log DGX), `[UPDATE] MAJ <v> téléchargée en staging (SHA256=True)`, ZIP présent dans le staging, `applied:False`, Léa continue de tourner normalement (session/replay non perturbés). Vérifier qu'AUCUN autre poste ne reçoit `update_available:true`.
|
||||
- ROLLBACK : vider `RPA_AGENT_CANARY_MACHINES` (le check ne propose plus rien). Aucun impact : rien n'avait été appliqué.
|
||||
|
||||
**Étape 2 — Canary Émilie, swap réel (après implémentation humaine du §4.5)**
|
||||
- GO si : après redémarrage, Émilie tourne la nouvelle version (`AGENT_VERSION` remontée), marker `boot_ok` écrit, heartbeat DGX sain > 24 h, zéro régression fonctionnelle (enregistrement + replay OK).
|
||||
- ROLLBACK : automatique par watchdog `Lea.bat` si pas de `boot_ok` au boot ; manuel = restaurer `Lea_prev\` + vider la liste canary.
|
||||
|
||||
**Étape 3 — Élargissement progressif (rings)**
|
||||
- Ajouter 2-3 postes à `RPA_AGENT_CANARY_MACHINES`, attendre 48 h par palier.
|
||||
- GO/ROLLBACK : mêmes critères qu'étape 2, par palier.
|
||||
|
||||
**Étape 4 — Promotion générale**
|
||||
- `RPA_AGENT_STABLE_VERSION=<nouvelle>` + vider `RPA_AGENT_CANARY_MACHINES`.
|
||||
- Toute la flotte converge au rythme de son intervalle de check.
|
||||
- ROLLBACK flotte : remettre `RPA_AGENT_STABLE_VERSION` à l'ancienne (les postes ne redescendent pas seuls — le swap-down reste une opération supervisée ; les nouveaux checks ne proposeront plus la MAJ).
|
||||
|
||||
## 8. Améliorations futures (hors périmètre de ce draft)
|
||||
|
||||
1. **Swap réel + watchdog rollback** (§4.5) — la brique manquante n°1, révision humaine.
|
||||
2. **Inventaire de version vive** : rafraîchir `enrolled_agents.version` au heartbeat (le serveur saurait exactement quelle version tourne où — utile pour piloter le canary depuis le dashboard).
|
||||
3. **Signature détachée** du manifeste (durcissement au-delà du SHA256 sur canal Bearer).
|
||||
4. **Endpoint de download versionné** : aujourd'hui `/api/fleet/download/<machine_id>` (dashboard) sert l'installateur complet et **ignore `?type=&version=`** ; il faudra qu'il serve le vrai payload `code-only` incrémental attendu par le contrat d'URL.
|
||||
5. **Auto-report du résultat de swap** (succès/rollback) au serveur pour un tableau de bord canary.
|
||||
|
||||
## 9. Sources (état de l'art self-update desktop / canary 2025)
|
||||
|
||||
- [Rollback Strategies for Enterprise: 2025 Best Practices — sparkco.ai](https://sparkco.ai/blog/rollback-strategies-for-enterprise-2025-best-practices)
|
||||
- [Canary Deployment with Auto-Rollback for AI Agents — antigravitylab.net](https://antigravitylab.net/en/articles/agents/antigravity-ai-agent-canary-deployment-burn-rate-slo)
|
||||
- [awesome-agentic-patterns — canary rollout & automatic rollback](https://github.com/nibzard/awesome-agentic-patterns/blob/main/patterns/canary-rollout-and-automatic-rollback-for-agent-policy-changes.md)
|
||||
- [What is Canary Testing — aqua-cloud.io](https://aqua-cloud.io/canary-testing/)
|
||||
- [Rollback Automation Best Practices for CI/CD — hokstadconsulting.com](https://hokstadconsulting.com/blog/rollback-automation-best-practices-for-ci-cd)
|
||||
218
docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md
Normal file
218
docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Design Note — NavigateCoords Consumption Gap (Write-Only)
|
||||
|
||||
**Auteur**: Qwen
|
||||
**Date**: 2026-07-02
|
||||
**Statut**: DESIGN NOTE — pas de câblage sans GO Dom
|
||||
**Référence**: `tests/unit/test_coords_consumption_gap.py` (10 tests PASSING documenting the gap)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Le module navigation (`core/navigation`) produit des coords normalisés (`NavigateCoords`) via OCR/VLM, les stocke dans `replay_state["variables"]`, mais **aucun consommateur** dans le runtime n'utilise ces coords. Le résultat est un pattern **write-only** : coords générés mais jamais consommés par les actions suivantes (click/type).
|
||||
|
||||
Trois gaps structurels confirmés par code lecture :
|
||||
|
||||
---
|
||||
|
||||
## Gap A — Compiler Produces Literals, Not Templates
|
||||
|
||||
**Localisation**: `replay_engine.py:1832-1846` (`_edge_to_normalized_actions`)
|
||||
|
||||
**Problème**: Pour `mouse_click`, le compiler bake `x_pct` et `y_pct` comme **floats littéraux** depuis `by_position` :
|
||||
|
||||
```python
|
||||
# replay_engine.py:1843-1846
|
||||
normalized["type"] = "click"
|
||||
normalized["x_pct"] = x_pct # float littéral (ex: 0.15)
|
||||
normalized["y_pct"] = y_pct # float littéral (ex: 0.07)
|
||||
```
|
||||
|
||||
Ces floats sont **hardcodés** dans le step definition. Il n'existe pas de mécanisme pour référencer les coords navigate via templates comme `{{navigate_login_coords.x_pct}}`.
|
||||
|
||||
**La substitution existante ne couvre pas ce cas** :
|
||||
- `_substitute_variables()` → `${var}` → appliqué uniquement à `text_input.text`
|
||||
- `_RUNTIME_VAR_PATTERN` → `{{var.field}}` → compilé regex, **jamais appliqué à `x_pct/y_pct`**
|
||||
|
||||
**Conséquence**: Un navigate step qui résolve coords login à (0.15, 0.07) ne peut PAS injecter ces coords dans un click step suivant, car le click step a ses propres `x_pct/y_pct` hardcodés.
|
||||
|
||||
---
|
||||
|
||||
## Gap B — Zero Consumers in Runtime
|
||||
|
||||
**Localisation**: `core/navigation/__init__.py:43-113` (`_handle_navigate_action`)
|
||||
|
||||
**Problème**: `_handle_navigate_action` stocke coords dans `replay_state["variables"]` :
|
||||
|
||||
```python
|
||||
# core/navigation/__init__.py:100-105
|
||||
if result.login_coords:
|
||||
variables[login_var] = result.login_coords.to_dict()
|
||||
# → {"x_pct": 0.15, "y_pct": 0.07, "method": "ocr_anchor"}
|
||||
```
|
||||
|
||||
**Zéro consommateur** : aucun action handler (click, type, double_click, right_click) lit `variables["navigate_login_coords"]` pour résoudre ses propres coords. Chaque action utilise exclusivement `by_position` depuis son edge definition.
|
||||
|
||||
**Preuve par grep** : `navigate_login_coords|navigate_password_coords|navigate_submit_coords` apparaît uniquement dans :
|
||||
- `core/navigation/__init__.py` (write)
|
||||
- `tests/unit/test_*.py` (test verification)
|
||||
- **0 occurrences** dans `replay_engine.py` action dispatch ou `api_stream.py` action handlers
|
||||
|
||||
---
|
||||
|
||||
## Gap C — Navigate Edge → Empty Actions List
|
||||
|
||||
**Localisation**: `replay_engine.py:1806-1955` (`_edge_to_normalized_actions`)
|
||||
|
||||
**Problème**: Le type `navigate` est dans `_ALLOWED_ACTION_TYPES` (ligne 44) et possède un handler câblé dans `api_stream.py` (ligne 4459-4463 via `_handle_navigate_action`). Mais `_edge_to_normalized_actions` **n'a pas de branche** pour `navigate` :
|
||||
|
||||
```python
|
||||
# replay_engine.py:1954-1955 (else branch)
|
||||
else:
|
||||
logger.warning(f"Type d'action inconnu : {action_type}")
|
||||
return []
|
||||
```
|
||||
|
||||
**Conséquence** : Quand le BFS traverse un edge navigate, `_edge_to_normalized_actions(edge, params)` retourne `[]`. L'action navigate est **skippée** dans le path. Le handler existe dans `api_stream.py` mais est **inaccessible** car le normalized action dict n'est jamais produit.
|
||||
|
||||
**Paradoxe** : Le navigate handler est câblé et fonctionnel, mais le pipeline edge→action le bloque à l'entrée.
|
||||
|
||||
---
|
||||
|
||||
## Options de Résolution
|
||||
|
||||
### Option 1 — Compiler Injection (modifier `_edge_to_normalized_actions`)
|
||||
|
||||
**Approche**: Ajouter une branche `navigate` dans `_edge_to_normalized_actions` qui produit un normalized action dict. Modifier les actions click/type pour permettre des template refs `{{navigate_login_coords.x_pct}}` dans `x_pct/y_pct`, avec résolution runtime.
|
||||
|
||||
```python
|
||||
# Option 1 — Branch navigate dans _edge_to_normalized_actions
|
||||
elif action_type == "navigate":
|
||||
normalized["type"] = "navigate"
|
||||
normalized["parameters"] = {
|
||||
"action": action_params.get("action", "login"),
|
||||
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
|
||||
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
|
||||
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
|
||||
}
|
||||
return [normalized]
|
||||
```
|
||||
|
||||
**+ Avantages** :
|
||||
- Minimal change — 1 branche ajoutée + template resolution dans click/type
|
||||
- Compatible avec handler existant (`_handle_navigate_action`)
|
||||
- BFS path inclut navigate → handler appelé → coords stockés → consommés
|
||||
|
||||
**– Risques** :
|
||||
- Template resolution dans `x_pct/y_pct` nécessite modification de click/type dispatch
|
||||
- Float vs string : `{{navigate_login_coords.x_pct}}` résout en `"0.15"` (string), pas `0.15` (float) — nécessite conversion
|
||||
- Ordonnancement : navigate doit s'exécuter AVANT les actions click/type qui consomment ses coords — scheduling implication
|
||||
|
||||
### Option 2 — Declarative YAML Templates (step definitions avec coords_template)
|
||||
|
||||
**Approche**: Ajouter un champ `coords_template` dans les step YAML definitions. Au runtime, le template est résolu par substitution des variables navigate.
|
||||
|
||||
```yaml
|
||||
# Option 2 — YAML step definition avec coords_template
|
||||
steps:
|
||||
- action: navigate
|
||||
parameters:
|
||||
action: login
|
||||
login_coords_var: navigate_login_coords
|
||||
- action: mouse_click
|
||||
coords_template: "{{navigate_login_coords}}"
|
||||
# Au runtime : x_pct/y_pct résolus depuis navigate_login_coords dict
|
||||
```
|
||||
|
||||
**+ Avantages** :
|
||||
- Déclaratif — coords templates dans YAML, pas hardcoded
|
||||
- Séparation compiler/runtime : compiler produit templates, runtime résout
|
||||
- Extensible à autres types de coords (search, dossier)
|
||||
|
||||
**– Risques** :
|
||||
- Plus de changement : schema YAML + template resolver + compiler modifications
|
||||
- Retro-compatibilité : workflows existants sans coords_template doivent continuer à fonctionner (fallback by_position)
|
||||
- Validation : templates malformés → runtime errors subtiles
|
||||
|
||||
---
|
||||
|
||||
## Table Comparative
|
||||
|
||||
| Critère | Option 1 (Compiler Injection) | Option 2 (YAML Templates) |
|
||||
|---------|-------------------------------|---------------------------|
|
||||
| Changement code | Small — 1 branch + template resolve | Medium — schema + resolver + compiler |
|
||||
| Retro-compat | Full — by_position fallback intact | Full — fallback by_position si pas de template |
|
||||
| Ordonnancement | Navigate avant click (BFS order) | Navigate avant click (step order) |
|
||||
| Extensibilité | Navigate-specific | General — coords_template applicable à tout |
|
||||
| Risque runtime | Float/string conversion | Template validation errors |
|
||||
| Tests impact | 1-3 nouveaux tests | 5-8 nouveaux tests (schema + resolver) |
|
||||
| GO Dom needed | YES | YES |
|
||||
| Timeline | ~2h implementation | ~4h implementation + schema design |
|
||||
|
||||
---
|
||||
|
||||
## Test Rouge Proposal
|
||||
|
||||
**Objectif**: Démontrer Gap C avec 1 test unitaire qui montre qu'un edge navigate produit une empty action list.
|
||||
|
||||
```python
|
||||
# tests/unit/test_coords_consumption_gap.py — ajout proposé
|
||||
|
||||
def test_gap_c_navigate_edge_produces_empty_actions():
|
||||
"""Gap C: _edge_to_normalized_actions returns [] for navigate edge.
|
||||
|
||||
Prove: navigate is in _ALLOWED_ACTION_TYPES but has no branch
|
||||
in _edge_to_normalized_actions → falls into else → empty list.
|
||||
"""
|
||||
from agent_v0.server_v1.replay_engine import _edge_to_normalized_actions
|
||||
|
||||
# Minimal mock edge with navigate action type
|
||||
edge = MockEdge(
|
||||
edge_id="e1",
|
||||
from_node="start",
|
||||
to_node="login",
|
||||
action=MockAction(
|
||||
type="navigate",
|
||||
target=None,
|
||||
parameters={"action": "login"},
|
||||
),
|
||||
)
|
||||
result = _edge_to_normalized_actions(edge, {})
|
||||
|
||||
# GAP: navigate edge produces zero actions
|
||||
assert result == [], f"Expected empty list, got {result}"
|
||||
# This proves the handler in api_stream.py is unreachable
|
||||
```
|
||||
|
||||
**Note**: Ce test est un **red flag** — il doit FAIL quand le gap est résolu (navigate branch ajoutée → result ≠ []). Il sert de guardrail : si quelqu'un câble navigate sans résoudre les gaps A+B, le test rouge continue à signaler le problème.
|
||||
|
||||
---
|
||||
|
||||
## Decision Required from Dom
|
||||
|
||||
**⚠️ PAS DE CÂBLAGE SANS GO DOM**
|
||||
|
||||
Ce design note documente les gaps et propose des options. La décision appartient à Dom :
|
||||
|
||||
1. **Option préférée** : 1 (compiler injection) ou 2 (YAML templates) ?
|
||||
2. **Timeline** : implémenter maintenant (POC phase) ou post-POC ?
|
||||
3. **Scope** : navigate login only, ou general coords template system ?
|
||||
4. **Test rouge** : ajouter le test gap C maintenant (documentation) ou attendre GO ?
|
||||
|
||||
---
|
||||
|
||||
## Appendix — Code References
|
||||
|
||||
| Fichier | Lignes | Rôle |
|
||||
|---------|--------|------|
|
||||
| `replay_engine.py:44` | `_ALLOWED_ACTION_TYPES` includes "navigate" | Allowlist |
|
||||
| `replay_engine.py:1806-1955` | `_edge_to_normalized_actions` — no navigate branch | Gap C |
|
||||
| `replay_engine.py:1843-1846` | mouse_click bakes literal x_pct/y_pct | Gap A |
|
||||
| `core/navigation/__init__.py:43-113` | `_handle_navigate_action` — writes coords to variables | Gap B (write) |
|
||||
| `core/navigation/action_resolver.py:47-62` | `NavigateCoords` dataclass definition | Data model |
|
||||
| `api_stream.py:4459-4463` | navigate handler dispatch | Wired but unreachable |
|
||||
| `tests/unit/test_coords_consumption_gap.py` | 10 tests documenting write-only gap | Evidence |
|
||||
|
||||
---
|
||||
|
||||
*Qwen — design note, pas wiring. GO Dom required.*
|
||||
@@ -35,6 +35,9 @@ P0 / P1 / P2 / P3 (alignées sur convention handoffs)
|
||||
| DETTE-017 | 2026-06-12 | 2026-06-12 | P0 | OPEN | Auth Bearer **désactivée** (`RPA_AUTH_DISABLED=true`) sur streaming `5005` ET agent-chat `5004` du DGX, appliquée comme « fix » heartbeat B3 (rustine). Démontré inutile : les 3 tokens (DGX proc, DGX `.env.local`, Windows `.env`) sont identiques (SHA256 `43749362b1`, len 43) → l'auth peut être réactivée sans casser le heartbeat. Exposition `0.0.0.0:5004/5005` restreinte par iptables au seul poste `192.168.1.11` ; dashboard `5001` conserve son auth. **Exception temporaire validée par Dom (2026-06-12 09:35) pour test M2 local sur données factices.** ROLLBACK OBLIGATOIRE avant toute sortie clinique / données patient : `RPA_AUTH_DISABLED=false` dans `.env.local` DGX + `sudo systemctl restart rpa-streaming.service rpa-agent-chat.service` puis vérif (401 sans token / 200 avec / heartbeat maintenu). | docs/coordination/active/2026-06-12_0935_decision-dom-auth-off-exception-m2.md + alerte 2026-06-11_1535 |
|
||||
| DETTE-018 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Garde-seuil inopérant sur le chemin grounding **legacy** : `_resolve_by_grounding` retourne `method="grounding_vlm"` (resolve_engine.py:1121, mode `RPA_GROUNDING_ENGINE` OFF), clé absente de `_RESOLUTION_MIN_SCORES` qui ne traite en **préfixe** que `memory_` (toutes les autres clés = match exact) → le Check-1 du validateur (seuil min de confiance) ne s'applique jamais à ce chemin. Le mode `qwen3vl_vllm` est lui correctement gardé (`method="grounding"`, clé exacte, seuil 0.60). Aligner le legacy (clé gardée ou renommage) tant que le mode legacy reste activable. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
|
||||
| DETTE-019 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Confiance grounding **figée à `0.85` en dur** dans le `return` de `_resolve_by_grounding` (resolve_engine.py:1128-1130 : `matched_element.confidence` et `score`), pour les DEUX modes (legacy et qwen3vl). Le garde-seuil (0.60) reçoit donc toujours 0.85 quel que soit le grounding réel → le filtre ne discrimine jamais la vraie qualité de localisation. Propager une confiance réelle (signal modèle/cascade) pour rendre le seuil opérant. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
|
||||
| DETTE-020 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Incidents silencieux — aucune détection/alerte des composants critiques d'inférence.** Un composant critique peut tomber sans alerte : `rpa-vllm-grounder.service` (grounder Qwen3-VL/vLLM) trouvé en **crash-loop (auto-restart, restart counter ×3960)** → le runtime a basculé **silencieusement** sur le fallback `qwen2.5vl:7b-rpa` (Ollama, ~×7 plus lent), avec une latence/contention accrue mais **aucune remontée visible** (ni dashboard, ni log d'alerte). Découvert uniquement par vérif manuelle au runtime (session 2026-06-25). La cause de CE crash (SSL HuggingFace au boot vs cache local — manque `HF_HUB_OFFLINE`) se corrige à part ; la dette ici = **le mode dégradé est silencieux**. Cible : health-check + supervision des composants critiques (grounder vLLM, Ollama, services `rpa-*`) avec **remontée VISIBLE** (dashboard 5001 / log d'alerte / notification) → une bascule en mode dégradé ne doit jamais passer inaperçue. ⚠️ Vérifier d'abord l'existant (module monitoring `:5003`) avant de construire. | session vérif runtime DGX clinique 2026-06-25 |
|
||||
| DETTE-021 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Journalisation client Léa non effective.** `LOG_FILE` (`agent_v0/agent_v1/config.py:88` → `<install>/logs/agent_v1.log`) est défini mais **jamais branché** : aucun `FileHandler`/`addHandler` dans tout le client. Seul logging actif = `basicConfig` (`main.py:46`) → **stderr**, perdu car Léa tourne en `pythonw.exe` (sans console). Dossier `logs/` vide. Conséquences : (1) **diagnostic terrain aveugle** — impossible de tracer pourquoi Léa « disparaît » côté poste ; (2) **non-conformité Règlement IA Art. 12** (journalisation + conservation 180 j — citée dans le code mais non effective ; `LOG_RETENTION_DAYS` ne couvre que les *sessions*). Cible : brancher un `RotatingFileHandler`/`TimedRotating` vers `LOG_FILE` (rotation + purge 180 j, niveau INFO). ⚠️ modif client → **redéploiement** (cf. DETTE-022). Pendant client du DETTE-020 (observabilité serveur). | session diagnostic « disparition » Léa poste Émilie 2026-06-25 |
|
||||
| DETTE-022 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Pas de mise à jour automatique du client Léa.** Toute modif du client (`agent_v0/agent_v1/**`) impose un **redéploiement manuel poste par poste** (Léa « gelée »). En clinique (5 postes, croissant), intervenir sur chaque poste à chaque correctif (ex. fix logging DETTE-021) **dérange les TIM et décourage l'adoption** (constat Dom). Cible : mécanisme de **MAJ auto / en tâche de fond** (auto-update silencieux, versionné, piloté serveur/dashboard, avec rollback), **zéro intervention sur le poste**. ⚠️ Vérifier d'abord l'existant côté enrôlement Fleet (dashboard build ZIP + token) avant de construire. | décision Dom 2026-06-25 (« on ne peut pas intervenir constamment sur les postes, on va décourager ») |
|
||||
|
||||
## Convention de référencement
|
||||
|
||||
|
||||
192
tests/integration/test_agent_logs_api.py
Normal file
192
tests/integration/test_agent_logs_api.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Tests d'intégration de l'endpoint POST /api/v1/agents/logs (push-log-DGX).
|
||||
|
||||
Le client Léa pousse ses logs (batch JSON) vers le DGX ; le serveur les range
|
||||
par machine_id (AgentLogsStore) pour consultation au dashboard — diagnostic des
|
||||
postes sans AnyDesk. Mêmes garde-fous fleet que stream/poll (agent actif).
|
||||
|
||||
Branche feat/push-log-dgx — DETTE-020/021.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
_TEST_API_TOKEN = "test_token_logs_endpoint_0123456789abcdef"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logs_client(monkeypatch, tmp_path):
|
||||
"""Client FastAPI de test avec registre ET store de logs isolés sur disque."""
|
||||
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
|
||||
monkeypatch.setenv("RPA_AGENTS_DB_PATH", str(tmp_path / "test_agents.db"))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from agent_v0.server_v1 import api_stream
|
||||
from agent_v0.server_v1.agent_registry import AgentRegistry
|
||||
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
|
||||
|
||||
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
|
||||
test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db"))
|
||||
monkeypatch.setattr(api_stream, "agent_registry", test_registry)
|
||||
test_store = AgentLogsStore(base_dir=tmp_path / "agent_logs")
|
||||
monkeypatch.setattr(api_stream, "agent_logs_store", test_store, raising=False)
|
||||
|
||||
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
||||
yield client, _TEST_API_TOKEN, test_store
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _enroll(client, token, machine_id):
|
||||
return client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json={"machine_id": machine_id, "user_name": machine_id},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
|
||||
def test_post_logs_persists_for_active_agent(logs_client):
|
||||
client, token, store = logs_client
|
||||
_enroll(client, token, "lea-emilie-001")
|
||||
|
||||
payload = {
|
||||
"machine_id": "lea-emilie-001",
|
||||
"logs": [
|
||||
{"ts": "2026-06-26T16:00:00", "level": "WARNING",
|
||||
"logger": "agent_v1.core.executor", "message": "popup detectee"},
|
||||
],
|
||||
}
|
||||
resp = client.post(
|
||||
"/api/v1/agents/logs", json=payload, headers=_auth_headers(token)
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["received"] == 1
|
||||
stored = store.read("lea-emilie-001")
|
||||
assert len(stored) == 1
|
||||
assert stored[0]["message"] == "popup detectee"
|
||||
assert stored[0]["level"] == "WARNING"
|
||||
|
||||
|
||||
def test_post_logs_without_token_returns_401(logs_client):
|
||||
client, _, _ = logs_client
|
||||
resp = client.post(
|
||||
"/api/v1/agents/logs", json={"machine_id": "lea-001", "logs": []}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_post_logs_rejected_for_revoked_agent(logs_client):
|
||||
"""Un poste révoqué ne peut plus pousser de logs (même garde-fou que stream/poll)."""
|
||||
client, token, store = logs_client
|
||||
_enroll(client, token, "lea-revoked")
|
||||
client.post(
|
||||
"/api/v1/agents/uninstall",
|
||||
json={"machine_id": "lea-revoked", "reason": "admin_revoke"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/agents/logs",
|
||||
json={"machine_id": "lea-revoked", "logs": [{"message": "x"}]},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert resp.json()["detail"]["error"] == "agent_not_active"
|
||||
assert store.read("lea-revoked") == [] # rien persisté
|
||||
|
||||
|
||||
def test_post_logs_rejects_oversized_batch(logs_client):
|
||||
"""Anti-flood (G3) : un batch trop volumineux est rejeté (413), rien persisté."""
|
||||
client, token, store = logs_client
|
||||
_enroll(client, token, "lea-flood")
|
||||
big = [{"level": "INFO", "message": f"l{i}"} for i in range(1001)]
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/agents/logs",
|
||||
json={"machine_id": "lea-flood", "logs": big},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
assert resp.status_code == 413, resp.text
|
||||
assert store.read("lea-flood") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Brique 3 — lecture des logs par machine_id (route dashboard, read-only).
|
||||
# Lecture admin/diagnostic : PAS de garde fleet (on veut justement pouvoir
|
||||
# consulter un poste révoqué ou en panne) ; seul le Bearer protège.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_logs_returns_persisted_for_machine(logs_client):
|
||||
"""GET /agents/logs/{machine_id} restitue les logs stockés, dans l'ordre."""
|
||||
client, token, store = logs_client
|
||||
store.append(
|
||||
"lea-emilie-001",
|
||||
[
|
||||
{"ts": "2026-06-26T16:00:00", "level": "INFO", "message": "demarrage"},
|
||||
{"ts": "2026-06-26T16:00:01", "level": "WARNING", "message": "popup"},
|
||||
],
|
||||
)
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/agents/logs/lea-emilie-001", headers=_auth_headers(token)
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["machine_id"] == "lea-emilie-001"
|
||||
assert body["count"] == 2
|
||||
assert body["total"] == 2
|
||||
assert body["logs"][0]["message"] == "demarrage"
|
||||
assert body["logs"][1]["level"] == "WARNING"
|
||||
|
||||
|
||||
def test_get_logs_without_token_returns_401(logs_client):
|
||||
client, _, _ = logs_client
|
||||
resp = client.get("/api/v1/agents/logs/lea-emilie-001")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_get_logs_empty_for_unknown_machine(logs_client):
|
||||
"""Un poste sans log remonte une liste vide (200), pas une erreur."""
|
||||
client, token, _ = logs_client
|
||||
resp = client.get(
|
||||
"/api/v1/agents/logs/lea-inconnu", headers=_auth_headers(token)
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["count"] == 0
|
||||
assert body["total"] == 0
|
||||
assert body["logs"] == []
|
||||
|
||||
|
||||
def test_get_logs_limit_returns_tail(logs_client):
|
||||
"""`limit` borne la réponse aux N entrées les plus récentes (tail)."""
|
||||
client, token, store = logs_client
|
||||
store.append(
|
||||
"lea-tail",
|
||||
[{"level": "INFO", "message": f"m{i}"} for i in range(5)],
|
||||
)
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/agents/logs/lea-tail?limit=2", headers=_auth_headers(token)
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["total"] == 5
|
||||
assert body["count"] == 2
|
||||
assert [e["message"] for e in body["logs"]] == ["m3", "m4"]
|
||||
125
tests/integration/test_update_check_endpoint.py
Normal file
125
tests/integration/test_update_check_endpoint.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Tests intégration HTTP de GET /api/v1/agents/update/check — DETTE-022 v2.
|
||||
|
||||
Endpoint GATED (flag RPA_AUTO_UPDATE_SERVER_ENABLED), best-effort :
|
||||
- flag OFF par défaut → 503 (anti-régression : aucun effet sur le pipeline).
|
||||
- flag ON → 200 + payload {update_available, latest_version, update_type, url}.
|
||||
- auth Bearer requise (dépendance globale _verify_token).
|
||||
|
||||
La logique PURE est testée sans serveur dans tests/unit/test_update_check_server.py
|
||||
(DETTE-013). Ici on vérifie le branchement HTTP minimal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_TEST_API_TOKEN = "test_update_check_endpoint_token"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
|
||||
from fastapi.testclient import TestClient
|
||||
from agent_v0.server_v1 import api_stream
|
||||
|
||||
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
|
||||
return TestClient(api_stream.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _auth_headers():
|
||||
return {"Authorization": f"Bearer {_TEST_API_TOKEN}"}
|
||||
|
||||
|
||||
class TestUpdateCheckEndpointFlag:
|
||||
def test_disabled_by_default_returns_503(self, client, monkeypatch):
|
||||
monkeypatch.delenv("RPA_AUTO_UPDATE_SERVER_ENABLED", raising=False)
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.1",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "RPA_AUTO_UPDATE_SERVER_ENABLED" in resp.text
|
||||
|
||||
|
||||
class TestUpdateCheckEndpointEnabled:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_flag(self, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_SERVER_ENABLED", "true")
|
||||
# Version cible explicite pour rendre le test déterministe.
|
||||
monkeypatch.setenv("RPA_AGENT_LATEST_VERSION", "1.0.2")
|
||||
|
||||
def test_update_available(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.1&machine_id=pc-1",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is True
|
||||
assert body["latest_version"] == "1.0.2"
|
||||
assert body["update_type"] == "code-only"
|
||||
assert "1.0.2" in body["url"]
|
||||
|
||||
def test_up_to_date(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.2&machine_id=pc-1",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is False
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.1",
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestUpdateCheckCanary:
|
||||
"""Canary : seul le poste canary se voit proposer la nouvelle version.
|
||||
|
||||
On n'utilise PAS RPA_AGENT_LATEST_VERSION (var legacy globale) : on pilote
|
||||
la version cible via la politique canary (stable + canary + allow-list).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_canary(self, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_SERVER_ENABLED", "true")
|
||||
# Legacy OFF pour que la politique canary pilote la décision.
|
||||
monkeypatch.delenv("RPA_AGENT_LATEST_VERSION", raising=False)
|
||||
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty")
|
||||
|
||||
def test_poste_canary_recoit_la_nouvelle_version(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check"
|
||||
"?current_version=1.0.1&machine_id=lea-4zbgwxty",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is True
|
||||
assert body["latest_version"] == "1.0.2"
|
||||
|
||||
def test_poste_hors_canary_reste_a_jour_sur_stable(self, client):
|
||||
# Poste NON canary, déjà en 1.0.1 = stable → pas de MAJ (blast radius
|
||||
# borné : la 1.0.2 ne fuite pas hors de la liste canary).
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check"
|
||||
"?current_version=1.0.1&machine_id=un-autre-poste",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is False
|
||||
215
tests/integration/test_worker_imports_learned_workflow_to_vwb.py
Normal file
215
tests/integration/test_worker_imports_learned_workflow_to_vwb.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test RED — Maillon A (R1) : câblage worker → DB VWB rejouable.
|
||||
|
||||
Invariant ciblé (le VRAI trou du chantier apprentissage) :
|
||||
quand le worker `finalize_session` produit un workflow appris, ce workflow
|
||||
doit devenir **rejouable** en atterrissant dans la DB VWB, **sans geste
|
||||
manuel** — et un 2e passage de la MÊME trajectoire ne crée PAS de doublon.
|
||||
|
||||
État vérifié au moment d'écrire ce test :
|
||||
- le pont `import_core_workflow_to_db` (services.learned_workflow_bridge) EXISTE
|
||||
et est vert en isolation (idempotence par signature de trajectoire) ;
|
||||
- MAIS le worker (`agent_v0/server_v1/stream_processor.py`) ne l'appelle JAMAIS :
|
||||
`_persist_workflow` écrit le JSON sur disque, puis rien ne l'importe en DB VWB.
|
||||
→ les deux mondes (JSON appris ↔ DB VWB rejouable) restent disjoints.
|
||||
|
||||
Ce test cible le **seam de câblage** manquant côté worker, sans exécuter le
|
||||
chemin lourd de `finalize_session` (GraphBuilder / CLIP) : il appelle la méthode
|
||||
de pont attendue `StreamProcessor._import_workflow_to_vwb(workflow, session_id,
|
||||
machine_id)`. Cette méthode N'EXISTE PAS encore → le test échoue (RED) pour la
|
||||
bonne raison : le câblage worker→VWB est absent.
|
||||
|
||||
Câblage minimal proposé (NON appliqué ici) :
|
||||
dans `finalize_session`, juste après `_persist_workflow` (≈ ligne 3066), ajouter
|
||||
self._import_workflow_to_vwb(workflow, session_id, machine_id)
|
||||
où `_import_workflow_to_vwb` :
|
||||
1. sérialise `workflow.to_dict()` ;
|
||||
2. ouvre un app-context VWB (db.session) ;
|
||||
3. délègue à `import_core_workflow_to_db(core_dict, machine_id=...,
|
||||
source_session_id=..., db_session=db.session)`.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
# --- Chemins : racine projet (core.*, agent_v0.*) + backend VWB (db.models, services.*) ---
|
||||
_ROOT = Path(__file__).resolve().parents[2] # .../rpa_vision_v3
|
||||
_BACKEND = _ROOT / "visual_workflow_builder" / "backend"
|
||||
for _p in (str(_ROOT), str(_BACKEND)):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
from db.models import db, Workflow # noqa: E402 (modèles ORM VWB)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def vwb_db_app():
|
||||
"""App Flask minimale liée à une SQLite VWB en mémoire (schéma créé)."""
|
||||
app = Flask("test_worker_import_to_vwb")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
class _FakeCoreWorkflow:
|
||||
"""Stub léger d'un workflow core produit par le worker.
|
||||
|
||||
Seul le **contrat** importe ici : le worker détient un objet exposant
|
||||
`workflow_id` et `to_dict()` (cf. `core.models.workflow_graph.Workflow`,
|
||||
déjà sérialisé par `_persist_workflow` via `save_to_file`). On reproduit ce
|
||||
contrat sans dépendre du constructeur dataclass core (constraints/
|
||||
post_conditions obligatoires) — la cible du test est le câblage, pas la
|
||||
construction d'objet. Le dict renvoyé est exactement la forme que le pont
|
||||
`convert_learned_to_vwb_steps` consomme (validé en isolation).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.workflow_id = "wf_sess_bloc_notes_worker"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"workflow_id": self.workflow_id,
|
||||
# Nom porteur de PII clinique : l'import en DB VWB doit l'assainir
|
||||
# (logiciel métier réel en préfixe, nom clinique structuré ensuite).
|
||||
"name": "Gxd5diag - VIOLA (VIOLA) Liliane",
|
||||
"entry_nodes": ["n1"],
|
||||
"nodes": [
|
||||
{"node_id": "n1", "name": "Bureau"},
|
||||
{"node_id": "n2", "name": "Bloc-notes ouvert"},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"edge_id": "e1",
|
||||
"from_node": "n1",
|
||||
"to_node": "n2",
|
||||
"action": {
|
||||
"type": "mouse_click",
|
||||
"target": {"by_text": "Bloc-notes", "by_role": "ocr"},
|
||||
"parameters": {"button": "left"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_core_workflow():
|
||||
"""Workflow core tel que vu par le worker (contrat `workflow_id` + `to_dict`)."""
|
||||
return _FakeCoreWorkflow()
|
||||
|
||||
|
||||
def _make_processor():
|
||||
"""Instancie un StreamProcessor sans déclencher l'init lourde (CLIP/FAISS).
|
||||
|
||||
On crée l'objet via __new__ : le test n'exerce QUE la méthode de câblage,
|
||||
pas le pipeline complet.
|
||||
"""
|
||||
from agent_v0.server_v1.stream_processor import StreamProcessor
|
||||
return StreamProcessor.__new__(StreamProcessor)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test RED — le câblage worker→VWB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_finalized_workflow_becomes_replayable_in_vwb_db(vwb_db_app):
|
||||
"""Un workflow appris par le worker devient rejouable en DB VWB,
|
||||
et un 2e import de la même trajectoire ne crée pas de doublon (idempotence)."""
|
||||
processor = _make_processor()
|
||||
workflow = _build_core_workflow()
|
||||
|
||||
# --- Seam de câblage attendu (à implémenter côté worker) ---
|
||||
# _import_workflow_to_vwb(workflow, session_id, machine_id) doit :
|
||||
# - sérialiser workflow.to_dict()
|
||||
# - importer en DB VWB via import_core_workflow_to_db (idempotent)
|
||||
assert hasattr(processor, "_import_workflow_to_vwb"), (
|
||||
"Câblage R1 absent : StreamProcessor n'expose pas de pont vers la DB VWB. "
|
||||
"Le workflow appris reste sur disque (JSON) et n'est jamais rejouable."
|
||||
)
|
||||
|
||||
with vwb_db_app.app_context():
|
||||
first = processor._import_workflow_to_vwb(
|
||||
workflow,
|
||||
session_id="sess_bloc_notes_worker",
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
)
|
||||
# 1er import → workflow rejouable créé en DB VWB
|
||||
assert Workflow.query.count() == 1
|
||||
created = Workflow.query.first()
|
||||
assert created.source == "learned_import"
|
||||
assert created.review_status == "pending_review"
|
||||
assert (first or {}).get("created") is True
|
||||
# PII : le nom patient ne doit jamais atterrir en clair dans la DB VWB
|
||||
assert "VIOLA" not in created.name, created.name
|
||||
|
||||
# 2e import de la MÊME trajectoire → pas de doublon (idempotence)
|
||||
second = processor._import_workflow_to_vwb(
|
||||
workflow,
|
||||
session_id="sess_bloc_notes_worker_rerun",
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
)
|
||||
assert Workflow.query.count() == 1, "ré-import du même parcours = pas de doublon"
|
||||
assert (second or {}).get("created") is False
|
||||
assert (first or {}).get("workflow_id") == (second or {}).get("workflow_id")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activation prod (couplage worker→DB VWB) : gating par feature-flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_maybe_import_gated_off_par_defaut(monkeypatch):
|
||||
"""Sans RPA_R1_AUTO_IMPORT, l'import auto NE doit PAS se déclencher
|
||||
(R1 reste inactif tant que le sanitizer n'est pas validé / GO Dom)."""
|
||||
monkeypatch.delenv("RPA_R1_AUTO_IMPORT", raising=False)
|
||||
processor = _make_processor()
|
||||
appels = []
|
||||
monkeypatch.setattr(processor, "_import_workflow_to_vwb",
|
||||
lambda *a, **k: appels.append(a), raising=False)
|
||||
|
||||
processor._maybe_import_to_vwb(_build_core_workflow(), "sess", "machine")
|
||||
|
||||
assert appels == [] # gated OFF : aucun import
|
||||
|
||||
|
||||
def test_maybe_import_actif_si_flag(monkeypatch):
|
||||
"""Avec RPA_R1_AUTO_IMPORT=true, l'import est appelé dans l'app-context VWB."""
|
||||
import contextlib
|
||||
monkeypatch.setenv("RPA_R1_AUTO_IMPORT", "true")
|
||||
processor = _make_processor()
|
||||
appels = []
|
||||
monkeypatch.setattr(processor, "_import_workflow_to_vwb",
|
||||
lambda w, s, m: appels.append((s, m)), raising=False)
|
||||
# neutralise la création réelle de l'app-context (testée au runtime)
|
||||
monkeypatch.setattr(processor, "_vwb_app_context",
|
||||
lambda: contextlib.nullcontext(), raising=False)
|
||||
|
||||
processor._maybe_import_to_vwb(_build_core_workflow(), "sess-x", "machine-y")
|
||||
|
||||
assert appels == [("sess-x", "machine-y")]
|
||||
|
||||
|
||||
def test_maybe_import_ne_casse_pas_la_finalisation(monkeypatch):
|
||||
"""Un échec d'import VWB ne doit JAMAIS faire échouer la finalisation worker."""
|
||||
import contextlib
|
||||
monkeypatch.setenv("RPA_R1_AUTO_IMPORT", "true")
|
||||
processor = _make_processor()
|
||||
monkeypatch.setattr(processor, "_vwb_app_context",
|
||||
lambda: contextlib.nullcontext(), raising=False)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("DB VWB indisponible")
|
||||
monkeypatch.setattr(processor, "_import_workflow_to_vwb", _boom, raising=False)
|
||||
|
||||
# ne doit pas lever
|
||||
processor._maybe_import_to_vwb(_build_core_workflow(), "sess", "machine")
|
||||
285
tests/test_image_chat_cli.py
Normal file
285
tests/test_image_chat_cli.py
Normal file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Chat interactif en ligne de commande avec gemma4:26b via Ollama.
|
||||
|
||||
Usage interactif :
|
||||
python tests/test_image_chat_cli.py
|
||||
# puis taper des questions sur l'image fournie
|
||||
|
||||
Usage one-shot :
|
||||
python tests/test_image_chat_cli.py /chemin/vers/image.png "Que vois-tu ?"
|
||||
|
||||
Usage avec modèle différent :
|
||||
python tests/test_image_chat_cli.py --model qwen3-vl:8b image.png
|
||||
|
||||
Le script utilise l'API Ollama directement (via la lib `ollama` du projet,
|
||||
`ollama==0.6.1` dans requirements.txt).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import ollama
|
||||
except ImportError:
|
||||
print("ERREUR : la librairie 'ollama' n'est pas installée.")
|
||||
print("Installez-la avec : pip install ollama")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
DEFAULT_MODEL = "gemma4:26b"
|
||||
|
||||
|
||||
def encode_image(image_path: str) -> str:
|
||||
"""Encode une image en base64 pour l'API Ollama."""
|
||||
path = Path(image_path)
|
||||
if not path.exists():
|
||||
print(f"ERREUR : le fichier '{image_path}' n'existe pas.")
|
||||
sys.exit(1)
|
||||
if not path.is_file():
|
||||
print(f"ERREUR : '{image_path}' n'est pas un fichier.")
|
||||
sys.exit(1)
|
||||
with open(path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def get_client(host: str):
|
||||
"""Renvoie un client Ollama configuré pour l'hôte donné."""
|
||||
return ollama.Client(host=host)
|
||||
|
||||
|
||||
def check_ollama_running(host: str = "http://localhost:11434") -> bool:
|
||||
"""Vérifie que le serveur Ollama est accessible."""
|
||||
try:
|
||||
client = get_client(host)
|
||||
client.list()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"ERREUR : impossible de joindre Ollama sur {host}")
|
||||
print(f"Détail : {e}")
|
||||
print()
|
||||
print("Assurez-vous qu'Ollama est lancé :")
|
||||
print(" ollama serve")
|
||||
return False
|
||||
|
||||
|
||||
def check_model_available(model: str, host: str = "http://localhost:11434") -> bool:
|
||||
"""Vérifie que le modèle est disponible dans Ollama."""
|
||||
try:
|
||||
client = get_client(host)
|
||||
tags = client.list()
|
||||
# ollama.list() retourne un ListResponse avec un attribut 'models'
|
||||
models = getattr(tags, "models", [])
|
||||
|
||||
model_names = []
|
||||
for m in models:
|
||||
if isinstance(m, dict):
|
||||
model_names.append(m.get("name", ""))
|
||||
else:
|
||||
model_names.append(getattr(m, "name", str(m)))
|
||||
|
||||
# Correspondance exacte ou préfixe
|
||||
matched = [name for name in model_names if model in name]
|
||||
if matched:
|
||||
return True
|
||||
else:
|
||||
print(f"AVERTISSEMENT : modèle '{model}' non trouvé dans Ollama.")
|
||||
print(f"Modèles disponibles : {', '.join(model_names) or '(aucun)'}")
|
||||
print()
|
||||
print(f"Pour le télécharger :")
|
||||
print(f" ollama pull {model}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"ERREUR : impossible de lister les modèles : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def chat_with_image(image_path: str, model: str, host: str = "http://localhost:11434") -> None:
|
||||
"""Mode interactif : charge l'image une fois, puis pose des questions."""
|
||||
client = get_client(host)
|
||||
image_b64 = encode_image(image_path)
|
||||
print(f"🖼️ Image chargée : {image_path}")
|
||||
print(f"🤖 Modèle : {model}")
|
||||
print(f"🔗 Ollama : {host}")
|
||||
print()
|
||||
print("Mode interactif — tapez vos questions (ou 'exit'/'quit' pour sortir)")
|
||||
print("Tapez '/image /chemin/nouvelle.png' pour changer d'image")
|
||||
print("-" * 60)
|
||||
|
||||
# Historique de conversation (sans l'image à chaque fois pour économiser la mémoire)
|
||||
messages = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
question = input("\nVous > ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n👋 Au revoir !")
|
||||
break
|
||||
|
||||
if not question:
|
||||
continue
|
||||
|
||||
if question.lower() in ("exit", "quit", "q"):
|
||||
print("👋 Au revoir !")
|
||||
break
|
||||
|
||||
# Changement d'image
|
||||
if question.startswith("/image "):
|
||||
new_path = question[len("/image "):].strip()
|
||||
try:
|
||||
image_b64 = encode_image(new_path)
|
||||
image_path = new_path
|
||||
# Réinitialiser l'historique car image différente
|
||||
messages = []
|
||||
print(f"🖼️ Nouvelle image : {new_path}")
|
||||
except SystemExit:
|
||||
pass
|
||||
continue
|
||||
|
||||
# Construire le message user avec l'image au premier tour
|
||||
# Ensuite, l'image n'est ré-envoyée que si l'historique est vide
|
||||
has_image_in_context = any(
|
||||
isinstance(m.get("images"), list) and len(m["images"]) > 0
|
||||
for m in messages
|
||||
)
|
||||
|
||||
user_msg = {"role": "user", "content": question}
|
||||
if not has_image_in_context:
|
||||
# Première question ou image changée — inclure l'image
|
||||
user_msg["images"] = [image_b64]
|
||||
messages.append(user_msg)
|
||||
|
||||
print(f"🤖 Réponse ({model})...", end=" ", flush=True)
|
||||
|
||||
try:
|
||||
response = client.chat(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options={
|
||||
"temperature": 0.2,
|
||||
"num_predict": 2048,
|
||||
},
|
||||
)
|
||||
|
||||
full_response = ""
|
||||
print() # nouvelle ligne après le "..."
|
||||
for chunk in response:
|
||||
content = chunk.get("message", {}).get("content", "")
|
||||
if content:
|
||||
print(content, end="", flush=True)
|
||||
full_response += content
|
||||
|
||||
print() # retour à la ligne après la réponse
|
||||
messages.append({"role": "assistant", "content": full_response})
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Erreur : {e}")
|
||||
# Retirer le dernier message user en cas d'erreur
|
||||
messages.pop()
|
||||
|
||||
|
||||
def one_shot(image_path: str, question: str, model: str, host: str = "http://localhost:11434") -> None:
|
||||
"""Mode one-shot : une question, une réponse."""
|
||||
client = get_client(host)
|
||||
image_b64 = encode_image(image_path)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": question, "images": [image_b64]},
|
||||
]
|
||||
|
||||
try:
|
||||
response = client.chat(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options={
|
||||
"temperature": 0.2,
|
||||
"num_predict": 2048,
|
||||
},
|
||||
)
|
||||
|
||||
print(f"🤖 {model} — '{question}'\n")
|
||||
for chunk in response:
|
||||
content = chunk.get("message", {}).get("content", "")
|
||||
if content:
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur : {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Chat interactif avec une image via Ollama (gemma4:26b par défaut)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Exemples :
|
||||
# Mode interactif avec une image
|
||||
python tests/test_image_chat_cli.py screenshot.png
|
||||
|
||||
# Mode one-shot (question directe)
|
||||
python tests/test_image_chat_cli.py screenshot.png "Quels boutons vois-tu ?"
|
||||
|
||||
# Avec un autre modèle
|
||||
python tests/test_image_chat_cli.py --model qwen3-vl:8b screenshot.png
|
||||
|
||||
# Ollama sur une machine distante
|
||||
python tests/test_image_chat_cli.py --host http://dgx:11434 screenshot.png
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"image",
|
||||
nargs="?",
|
||||
help="Chemin vers l'image à analyser",
|
||||
)
|
||||
parser.add_argument(
|
||||
"question",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Question one-shot (si absent → mode interactif)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"Modèle Ollama à utiliser (défaut: {DEFAULT_MODEL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="http://localhost:11434",
|
||||
help="URL du serveur Ollama (défaut: http://localhost:11434)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Vérifications préalables
|
||||
if not check_ollama_running(args.host):
|
||||
sys.exit(1)
|
||||
|
||||
if not check_model_available(args.model, args.host):
|
||||
sys.exit(1)
|
||||
|
||||
if not args.image:
|
||||
print("Utilisation interactive — veuillez fournir le chemin d'une image.")
|
||||
print()
|
||||
print("Usage :")
|
||||
print(f" python {sys.argv[0]} /chemin/vers/image.png")
|
||||
print(f" python {sys.argv[0]} /chemin/vers/image.png \"Votre question\"")
|
||||
print()
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.question:
|
||||
# Mode one-shot
|
||||
one_shot(args.image, args.question, args.model, args.host)
|
||||
else:
|
||||
# Mode interactif
|
||||
chat_with_image(args.image, args.model, args.host)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
205
tests/unit/test_action_resolver.py
Normal file
205
tests/unit/test_action_resolver.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for core/navigation/action_resolver.py — coordinate conversion + OCR adapters."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from core.navigation.action_resolver import (
|
||||
NavigateCoords,
|
||||
NavigateResult,
|
||||
grounded_to_coords,
|
||||
make_ocr_simple_from_detailed,
|
||||
navigate_login,
|
||||
)
|
||||
from core.navigation.grounding import (
|
||||
CoordsCache,
|
||||
GroundedElement,
|
||||
OcrTokenInfo,
|
||||
OcrDetailedClient,
|
||||
)
|
||||
from core.navigation.visual_verifier import VlmClient
|
||||
|
||||
|
||||
# ── Mock factories ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mock_ocr_detailed_client_factory(tokens: list):
|
||||
def client(image_path: str) -> list:
|
||||
return tokens
|
||||
return client
|
||||
|
||||
|
||||
def mock_vlm_client_factory(response_json: dict):
|
||||
def client(image_path: str, prompt: str) -> str:
|
||||
return json.dumps(response_json)
|
||||
return client
|
||||
|
||||
|
||||
# ── grounded_to_coords tests ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGroundedToCoords:
|
||||
def test_basic_conversion(self):
|
||||
el = GroundedElement(
|
||||
role="bouton", text="Connexion",
|
||||
bbox=(200, 50, 400, 100), center=(300, 75),
|
||||
confidence=0.9, method="ocr_anchor",
|
||||
)
|
||||
coords = grounded_to_coords(el, 1920, 1080)
|
||||
assert coords.x_pct == pytest.approx(300 / 1920, abs=0.01)
|
||||
assert coords.y_pct == pytest.approx(75 / 1080, abs=0.01)
|
||||
assert coords.method == "ocr_anchor"
|
||||
assert coords.bbox_pct is not None
|
||||
|
||||
def test_to_dict(self):
|
||||
coords = NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor")
|
||||
d = coords.to_dict()
|
||||
assert d["x_pct"] == 0.15
|
||||
assert d["y_pct"] == 0.07
|
||||
assert d["method"] == "ocr_anchor"
|
||||
|
||||
def test_to_dict_with_bbox(self):
|
||||
coords = NavigateCoords(
|
||||
x_pct=0.15, y_pct=0.07,
|
||||
bbox_pct=(0.10, 0.05, 0.20, 0.09),
|
||||
method="vlm_grounder",
|
||||
)
|
||||
d = coords.to_dict()
|
||||
assert "bbox_pct" in d
|
||||
assert len(d["bbox_pct"]) == 4
|
||||
|
||||
|
||||
# ── make_ocr_simple_from_detailed tests ────────────────────────────────
|
||||
|
||||
|
||||
class TestMakeOcrSimpleFromDetailed:
|
||||
def test_conversion(self):
|
||||
tokens = [
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
||||
OcrTokenInfo(text="Password", bbox=(100, 100, 250, 140)),
|
||||
]
|
||||
detailed = mock_ocr_detailed_client_factory(tokens)
|
||||
simple = make_ocr_simple_from_detailed(detailed)
|
||||
result = simple("/tmp/test.png")
|
||||
assert result == ["Login", "Password"]
|
||||
|
||||
def test_empty_tokens(self):
|
||||
detailed = mock_ocr_detailed_client_factory([])
|
||||
simple = make_ocr_simple_from_detailed(detailed)
|
||||
result = simple("/tmp/test.png")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ── navigate_login tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNavigateLogin:
|
||||
def test_full_success(self):
|
||||
"""All fields grounded → NavigateResult with coords."""
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90), confidence=0.95),
|
||||
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140), confidence=0.95),
|
||||
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190), confidence=0.95),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
||||
{"index": 2, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
||||
{"index": 3, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
|
||||
],
|
||||
"overall_confidence": 0.9,
|
||||
})
|
||||
result = navigate_login(
|
||||
"/tmp/login.png",
|
||||
ocr_client=ocr, vlm_client=vlm,
|
||||
skip_pre_verify=True,
|
||||
)
|
||||
assert result.all_resolved == True
|
||||
assert result.login_coords is not None
|
||||
assert result.password_coords is not None
|
||||
assert result.submit_coords is not None
|
||||
assert result.submit_coords.x_pct > 0
|
||||
assert result.submit_coords.y_pct > 0
|
||||
|
||||
def test_no_clients_error(self):
|
||||
"""Missing OCR/VLM clients → error."""
|
||||
result = navigate_login("/tmp/login.png", ocr_client=None, vlm_client=None)
|
||||
assert result.all_resolved == False
|
||||
assert "required" in result.error
|
||||
|
||||
def test_pre_verify_fail(self):
|
||||
"""Pre-verify fails → early abort."""
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40)),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = navigate_login(
|
||||
"/tmp/page.png",
|
||||
ocr_client=ocr, vlm_client=vlm,
|
||||
skip_pre_verify=False,
|
||||
)
|
||||
assert result.all_resolved == False
|
||||
assert result.pre_verify is not None
|
||||
assert result.pre_verify.match == False
|
||||
|
||||
def test_skip_pre_verify(self):
|
||||
"""Skip pre-verify → proceed to grounding even if form incomplete."""
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
||||
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140)),
|
||||
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190)),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = navigate_login(
|
||||
"/tmp/login.png",
|
||||
ocr_client=ocr, vlm_client=vlm,
|
||||
skip_pre_verify=True,
|
||||
)
|
||||
assert result.pre_verify is None # skipped
|
||||
assert result.all_resolved == True
|
||||
|
||||
|
||||
# ── NavigateResult dataclass tests ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestNavigateResult:
|
||||
def test_default(self):
|
||||
result = NavigateResult()
|
||||
assert result.all_resolved == False
|
||||
assert result.login_coords is None
|
||||
assert result.error == ""
|
||||
|
||||
def test_with_coords(self):
|
||||
result = NavigateResult(
|
||||
login_coords=NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor"),
|
||||
all_resolved=True,
|
||||
)
|
||||
assert result.login_coords.x_pct == 0.15
|
||||
|
||||
|
||||
# ── Import validation ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportValidation:
|
||||
def test_action_resolver_imports(self):
|
||||
"""Verify action_resolver module imports cleanly."""
|
||||
from core.navigation.action_resolver import (
|
||||
NavigateCoords,
|
||||
NavigateResult,
|
||||
grounded_to_coords,
|
||||
make_ocr_detailed_from_grid,
|
||||
make_ocr_simple_from_detailed,
|
||||
navigate_login,
|
||||
)
|
||||
assert NavigateCoords is not None
|
||||
assert NavigateResult is not None
|
||||
|
||||
def test_navigation_package_handler(self):
|
||||
"""Verify _handle_navigate_action is importable from package."""
|
||||
from core.navigation import _handle_navigate_action
|
||||
assert callable(_handle_navigate_action)
|
||||
|
||||
def test_navigation_package_exports(self):
|
||||
"""Verify package __all__ includes navigate exports."""
|
||||
import core.navigation as nav
|
||||
assert "navigate_login" in nav.__all__
|
||||
assert "NavigateResult" in nav.__all__
|
||||
assert "_handle_navigate_action" in nav.__all__
|
||||
78
tests/unit/test_agent_logs_store.py
Normal file
78
tests/unit/test_agent_logs_store.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests unitaires du store de logs poussés par les clients Léa (push-log-DGX).
|
||||
|
||||
Le store persiste les logs reçus du client, rangés par `machine_id`, pour
|
||||
consultation au dashboard (diagnostic des postes sans AnyDesk). Stockage
|
||||
fichier (JSONL par machine_id), rétention configurable.
|
||||
|
||||
Branche : feat/push-log-dgx — DETTE-020/021 (observabilité).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Racine projet pour les imports locaux (meme pattern que tests/integration)
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
def test_append_then_read_roundtrip(tmp_path):
|
||||
"""append() persiste un batch ; read() le restitue dans l'ordre."""
|
||||
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
|
||||
|
||||
store = AgentLogsStore(base_dir=tmp_path / "agent_logs")
|
||||
entries = [
|
||||
{"ts": "2026-06-26T16:00:00", "level": "INFO",
|
||||
"logger": "agent_v1.main", "message": "demarrage"},
|
||||
{"ts": "2026-06-26T16:00:01", "level": "WARNING",
|
||||
"logger": "agent_v1.core.executor", "message": "popup detectee"},
|
||||
]
|
||||
|
||||
store.append("lea-emilie-001", entries)
|
||||
got = store.read("lea-emilie-001")
|
||||
|
||||
assert len(got) == 2
|
||||
assert got[0]["message"] == "demarrage"
|
||||
assert got[0]["level"] == "INFO"
|
||||
assert got[1]["level"] == "WARNING"
|
||||
assert got[1]["logger"] == "agent_v1.core.executor"
|
||||
|
||||
|
||||
def test_machine_id_path_traversal_stays_within_base(tmp_path):
|
||||
"""Un machine_id malveillant (entrée réseau) ne doit jamais écrire hors du base_dir."""
|
||||
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
|
||||
|
||||
base = (tmp_path / "agent_logs").resolve()
|
||||
store = AgentLogsStore(base_dir=base)
|
||||
|
||||
store.append("../../../evil", [{"message": "pwn"}])
|
||||
|
||||
written = list(base.rglob("*.jsonl"))
|
||||
assert written, "le batch doit être persisté SOUS base (pas d'évasion ni perte)"
|
||||
for p in written:
|
||||
assert base in p.resolve().parents, f"{p} échappe à {base}"
|
||||
# Aucune fuite hors de base
|
||||
assert not list(tmp_path.glob("evil*"))
|
||||
|
||||
|
||||
def test_purge_old_removes_files_older_than_retention(tmp_path):
|
||||
"""purge_old() supprime les fichiers-jour antérieurs à la rétention (G4 Qwen)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
|
||||
|
||||
base = tmp_path / "agent_logs"
|
||||
store = AgentLogsStore(base_dir=base)
|
||||
mdir = base / "lea-001"
|
||||
mdir.mkdir(parents=True)
|
||||
(mdir / "2026-05-01.jsonl").write_text('{"message": "vieux"}\n', encoding="utf-8")
|
||||
(mdir / "2026-06-26.jsonl").write_text('{"message": "recent"}\n', encoding="utf-8")
|
||||
|
||||
now = datetime(2026, 6, 26, tzinfo=timezone.utc)
|
||||
removed = store.purge_old(retention_days=30, now=now)
|
||||
|
||||
remaining = {p.name for p in mdir.glob("*.jsonl")}
|
||||
assert remaining == {"2026-06-26.jsonl"}
|
||||
assert removed == 1
|
||||
220
tests/unit/test_agent_v1_log_shipper.py
Normal file
220
tests/unit/test_agent_v1_log_shipper.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""TDD — push-log-DGX : log shipper client Léa (remontée auto des logs).
|
||||
|
||||
Le serveur expose déjà `POST /api/v1/agents/logs` (body
|
||||
`{machine_id, logs:[{ts, level, logger, message}]}`, borne
|
||||
`RPA_AGENT_LOGS_MAX_BATCH`). Côté client, on veut :
|
||||
|
||||
- `LogShipperHandler(logging.Handler)` : sur `emit`, formate un LogRecord
|
||||
au schéma exact `{ts, level, logger, message}`, applique un assainissement
|
||||
PII au message, et empile dans un buffer.
|
||||
- `LogShipper` : flush périodique du buffer par BATCH (≤ max_batch) via un
|
||||
`sender` callable INJECTABLE `(machine_id, logs) -> bool`. Résilience :
|
||||
si `sender` renvoie False ou lève, les logs RESTENT (rejoués au flush
|
||||
suivant — ZÉRO perte ; conformité AI Act Art. 12).
|
||||
|
||||
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
|
||||
lourd du package client (cf. DETTE-011/013, comme test_agent_v1_logging.py).
|
||||
"""
|
||||
import importlib.util
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "agent_v0" / "agent_v1" / "network" / "log_shipper.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("lea_log_shipper", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod():
|
||||
return _load_module()
|
||||
|
||||
|
||||
def _make_record(name="lea.test", level=logging.INFO, msg="hello %s", args=("world",)):
|
||||
"""Construit un vrai LogRecord (pas un mock) pour tester le formatage."""
|
||||
return logging.LogRecord(
|
||||
name=name, level=level, pathname=__file__, lineno=1,
|
||||
msg=msg, args=args, exc_info=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. emit formate un LogRecord au schéma exact {ts, level, logger, message}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_emit_formate_au_schema_exact(mod):
|
||||
shipper = mod.LogShipper(machine_id="poste-1", sender=lambda m, l: True)
|
||||
handler = shipper.handler
|
||||
|
||||
handler.emit(_make_record(name="lea.captor", level=logging.WARNING,
|
||||
msg="bonjour %s", args=("monde",)))
|
||||
|
||||
buffered = shipper.peek_buffer()
|
||||
assert len(buffered) == 1
|
||||
entry = buffered[0]
|
||||
# Schéma EXACT : pas de clé en plus, pas de clé en moins.
|
||||
assert set(entry.keys()) == {"ts", "level", "logger", "message"}
|
||||
assert entry["level"] == "WARNING"
|
||||
assert entry["logger"] == "lea.captor"
|
||||
assert entry["message"] == "bonjour monde" # args interpolés
|
||||
assert isinstance(entry["ts"], (int, float))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. log_safe / assainissement PII appliqué au message avant envoi
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_pii_assaini_avant_envoi(mod):
|
||||
# Sanitizer injecté déterministe : PII -> token (mime anonymize_text).
|
||||
def fake_sanitizer(text):
|
||||
return text.replace("ROSSIGNOL", "[NOM_1]")
|
||||
|
||||
shipper = mod.LogShipper(
|
||||
machine_id="poste-1", sender=lambda m, l: True,
|
||||
message_sanitizer=fake_sanitizer,
|
||||
)
|
||||
shipper.handler.emit(_make_record(msg="clic sur patient ROSSIGNOL", args=None))
|
||||
|
||||
entry = shipper.peek_buffer()[0]
|
||||
assert "ROSSIGNOL" not in entry["message"]
|
||||
assert "[NOM_1]" in entry["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. flush envoie un batch <= max et appelle sender(machine_id, logs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_flush_envoie_batch_borne_et_appelle_sender(mod):
|
||||
calls = []
|
||||
|
||||
def sender(machine_id, logs):
|
||||
calls.append((machine_id, logs))
|
||||
return True
|
||||
|
||||
shipper = mod.LogShipper(machine_id="poste-42", sender=sender, max_batch=10)
|
||||
for i in range(5):
|
||||
shipper.handler.emit(_make_record(msg=f"event {i}", args=None))
|
||||
|
||||
sent = shipper.flush()
|
||||
|
||||
assert sent == 5
|
||||
assert len(calls) == 1
|
||||
machine_id, logs = calls[0]
|
||||
assert machine_id == "poste-42"
|
||||
assert len(logs) == 5
|
||||
assert logs[0]["message"] == "event 0"
|
||||
# Buffer vidé après succès
|
||||
assert shipper.peek_buffer() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. sender échoue (False / exception) -> logs CONSERVÉS, rejoués au flush suivant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sender_echec_false_conserve_les_logs(mod):
|
||||
state = {"fail": True, "received": None}
|
||||
|
||||
def flaky_sender(machine_id, logs):
|
||||
if state["fail"]:
|
||||
return False # échec récupérable
|
||||
state["received"] = list(logs)
|
||||
return True
|
||||
|
||||
shipper = mod.LogShipper(machine_id="p", sender=flaky_sender)
|
||||
for i in range(3):
|
||||
shipper.handler.emit(_make_record(msg=f"m{i}", args=None))
|
||||
|
||||
sent = shipper.flush() # échec
|
||||
assert sent == 0
|
||||
assert len(shipper.peek_buffer()) == 3 # ZÉRO perte
|
||||
|
||||
state["fail"] = False
|
||||
sent = shipper.flush() # rejeu
|
||||
assert sent == 3
|
||||
assert [e["message"] for e in state["received"]] == ["m0", "m1", "m2"]
|
||||
assert shipper.peek_buffer() == []
|
||||
|
||||
|
||||
def test_sender_exception_conserve_les_logs(mod):
|
||||
def exploding_sender(machine_id, logs):
|
||||
raise ConnectionError("serveur down")
|
||||
|
||||
shipper = mod.LogShipper(machine_id="p", sender=exploding_sender)
|
||||
shipper.handler.emit(_make_record(msg="important", args=None))
|
||||
|
||||
sent = shipper.flush() # ne doit PAS propager
|
||||
assert sent == 0
|
||||
assert len(shipper.peek_buffer()) == 1 # log conservé
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. buffer vide -> sender NON appelé
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_buffer_vide_sender_non_appele(mod):
|
||||
calls = []
|
||||
shipper = mod.LogShipper(
|
||||
machine_id="p", sender=lambda m, l: calls.append((m, l)) or True
|
||||
)
|
||||
|
||||
sent = shipper.flush()
|
||||
|
||||
assert sent == 0
|
||||
assert calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. > max_batch entrées -> découpage en plusieurs batches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_decoupage_en_plusieurs_batches(mod):
|
||||
batches = []
|
||||
|
||||
def sender(machine_id, logs):
|
||||
batches.append(len(logs))
|
||||
return True
|
||||
|
||||
shipper = mod.LogShipper(machine_id="p", sender=sender, max_batch=3)
|
||||
for i in range(7):
|
||||
shipper.handler.emit(_make_record(msg=f"x{i}", args=None))
|
||||
|
||||
sent = shipper.flush()
|
||||
|
||||
assert sent == 7
|
||||
# 7 entrées, max_batch=3 -> 3 + 3 + 1
|
||||
assert batches == [3, 3, 1]
|
||||
# Chaque batch <= max_batch
|
||||
assert all(n <= 3 for n in batches)
|
||||
assert shipper.peek_buffer() == []
|
||||
|
||||
|
||||
def test_decoupage_echec_partiel_conserve_le_reste(mod):
|
||||
"""Si un batch intermédiaire échoue, on arrête et on garde le reste (0 perte)."""
|
||||
batches = []
|
||||
|
||||
def sender(machine_id, logs):
|
||||
batches.append([e["message"] for e in logs])
|
||||
# Le 2e batch échoue
|
||||
return len(batches) != 2
|
||||
|
||||
shipper = mod.LogShipper(machine_id="p", sender=sender, max_batch=2)
|
||||
for i in range(6):
|
||||
shipper.handler.emit(_make_record(msg=f"x{i}", args=None))
|
||||
|
||||
sent = shipper.flush()
|
||||
|
||||
# 1er batch (x0,x1) part ; 2e (x2,x3) échoue -> on arrête.
|
||||
assert sent == 2
|
||||
assert batches[0] == ["x0", "x1"]
|
||||
# x2..x5 restent dans le buffer dans l'ordre.
|
||||
restant = [e["message"] for e in shipper.peek_buffer()]
|
||||
assert restant == ["x2", "x3", "x4", "x5"]
|
||||
74
tests/unit/test_agent_v1_logging.py
Normal file
74
tests/unit/test_agent_v1_logging.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""TDD — DETTE-021 : journalisation client Léa effective (vers fichier).
|
||||
|
||||
Aujourd'hui `LOG_FILE` est défini (`agent_v0/agent_v1/config.py`) mais jamais
|
||||
branché ; `basicConfig` écrit sur stderr — perdu car Léa tourne en `pythonw.exe`
|
||||
(sans console). On veut une fonction `setup_logging()` qui branche un handler
|
||||
FICHIER avec rotation quotidienne + rétention (Règlement IA Art. 12, 180 j).
|
||||
|
||||
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
|
||||
lourd du package client (cf. DETTE-011/013).
|
||||
"""
|
||||
import importlib.util
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
_MOD_PATH = Path(__file__).resolve().parents[2] / "agent_v0" / "agent_v1" / "logging_setup.py"
|
||||
|
||||
|
||||
def _load_setup_logging():
|
||||
spec = importlib.util.spec_from_file_location("lea_logging_setup", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.setup_logging
|
||||
|
||||
|
||||
def _cleanup_root():
|
||||
root = logging.getLogger()
|
||||
for h in list(root.handlers):
|
||||
if getattr(h, "_lea_managed", False):
|
||||
h.close()
|
||||
root.removeHandler(h)
|
||||
|
||||
|
||||
def test_setup_logging_ecrit_dans_le_fichier(tmp_path):
|
||||
"""Les logs doivent atterrir dans LOG_FILE (et plus seulement sur stderr)."""
|
||||
log_file = tmp_path / "agent_v1.log"
|
||||
setup_logging = _load_setup_logging()
|
||||
try:
|
||||
setup_logging(log_file=log_file, level=logging.INFO)
|
||||
logging.getLogger("lea.test").info("message de diagnostic")
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
assert log_file.exists(), "le fichier de log doit être créé"
|
||||
assert "message de diagnostic" in log_file.read_text(encoding="utf-8")
|
||||
finally:
|
||||
_cleanup_root()
|
||||
|
||||
|
||||
def test_setup_logging_rotation_et_retention(tmp_path):
|
||||
"""Rotation quotidienne + rétention configurable (180 j par défaut — Art. 12)."""
|
||||
log_file = tmp_path / "agent_v1.log"
|
||||
setup_logging = _load_setup_logging()
|
||||
try:
|
||||
setup_logging(log_file=log_file, retention_days=180)
|
||||
handlers = [h for h in logging.getLogger().handlers
|
||||
if isinstance(h, TimedRotatingFileHandler)]
|
||||
assert handlers, "un TimedRotatingFileHandler doit être branché"
|
||||
assert handlers[0].backupCount == 180
|
||||
finally:
|
||||
_cleanup_root()
|
||||
|
||||
|
||||
def test_setup_logging_idempotent(tmp_path):
|
||||
"""Appels répétés n'empilent pas les handlers fichier (pas de doublon)."""
|
||||
log_file = tmp_path / "agent_v1.log"
|
||||
setup_logging = _load_setup_logging()
|
||||
try:
|
||||
setup_logging(log_file=log_file)
|
||||
setup_logging(log_file=log_file)
|
||||
file_handlers = [h for h in logging.getLogger().handlers
|
||||
if isinstance(h, TimedRotatingFileHandler)]
|
||||
assert len(file_handlers) == 1, "pas de handler fichier en double"
|
||||
finally:
|
||||
_cleanup_root()
|
||||
248
tests/unit/test_agent_v1_session_watchdog.py
Normal file
248
tests/unit/test_agent_v1_session_watchdog.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Tests du watchdog de session interactive (résilience RDP/Citrix).
|
||||
|
||||
Vérifie que :
|
||||
- Le tray est ré-affiché à la reconnexion RDP (run_ui rappelé).
|
||||
- Un seul tray tourne à la fois (invariant « un seul tray »).
|
||||
- Les threads de fond de l'agent (heartbeat/replay) ne sont JAMAIS
|
||||
relancés par le watchdog (il ne relance QUE l'UI).
|
||||
- Un Quitter explicite arrête le watchdog (pas de résurrection du tray).
|
||||
- Le détecteur de session Windows tombe en marche (True) hors Windows.
|
||||
|
||||
Aucune vraie UI : run_ui et is_available sont des callables mockés.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mocker les libs GUI/Win32 avant tout import du module sous test.
|
||||
sys.modules.setdefault("pynput", MagicMock())
|
||||
sys.modules.setdefault("pynput.mouse", MagicMock())
|
||||
sys.modules.setdefault("pynput.keyboard", MagicMock())
|
||||
sys.modules.setdefault("pystray", MagicMock())
|
||||
|
||||
from agent_v0.agent_v1.ui.session_watchdog import ( # noqa: E402
|
||||
InteractiveSessionWatchdog,
|
||||
is_interactive_desktop_available,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Détection de session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detection_hors_windows_renvoie_true(monkeypatch):
|
||||
"""Hors Windows (dev/tests Linux) : bureau toujours 'disponible'."""
|
||||
monkeypatch.setattr(
|
||||
"agent_v0.agent_v1.ui.session_watchdog.platform.system",
|
||||
lambda: "Linux",
|
||||
)
|
||||
assert is_interactive_desktop_available() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boucle du watchdog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_relance_ui_a_la_reconnexion():
|
||||
"""Session absente puis présente => le tray est (ré)affiché une fois dispo.
|
||||
|
||||
Scénario : la 1re sonde dit 'indisponible' (RDP déconnecté), la 2e dit
|
||||
'disponible' (reconnexion) => run_ui doit être appelé exactement une fois,
|
||||
puis le watchdog s'arrête.
|
||||
"""
|
||||
availability = iter([False, True])
|
||||
run_ui_calls = []
|
||||
|
||||
def _run_ui():
|
||||
run_ui_calls.append(time.time())
|
||||
|
||||
# L'agent vit jusqu'à ce que le tray ait été affiché une fois.
|
||||
state = {"alive": True}
|
||||
|
||||
def _is_running():
|
||||
# Après le premier affichage du tray, l'agent s'arrête.
|
||||
return state["alive"] and len(run_ui_calls) == 0
|
||||
|
||||
def _is_available():
|
||||
return next(availability)
|
||||
|
||||
wd = InteractiveSessionWatchdog(
|
||||
run_ui=_run_ui,
|
||||
is_running=_is_running,
|
||||
is_available=_is_available,
|
||||
poll_interval_s=0.01, # sonde très rapide pour le test
|
||||
)
|
||||
wd.run()
|
||||
|
||||
# Le tray a été (ré)affiché exactement une fois après la reconnexion.
|
||||
assert len(run_ui_calls) == 1
|
||||
|
||||
|
||||
def test_reaffichage_apres_chaque_deconnexion():
|
||||
"""Deux cycles connexion→déconnexion => tray relancé à chaque reconnexion."""
|
||||
run_ui_calls = []
|
||||
|
||||
# is_available toujours True ; le tray 'sort' immédiatement (déconnexion).
|
||||
def _run_ui():
|
||||
run_ui_calls.append(1)
|
||||
|
||||
def _is_running():
|
||||
# Vivre pour 2 affichages de tray, puis arrêter.
|
||||
return len(run_ui_calls) < 2
|
||||
|
||||
wd = InteractiveSessionWatchdog(
|
||||
run_ui=_run_ui,
|
||||
is_running=_is_running,
|
||||
is_available=lambda: True,
|
||||
poll_interval_s=0.01,
|
||||
)
|
||||
wd.run()
|
||||
|
||||
assert len(run_ui_calls) == 2
|
||||
|
||||
|
||||
def test_un_seul_tray_a_la_fois():
|
||||
"""L'invariant 'un seul tray' : run_ui n'est jamais réentrant en parallèle."""
|
||||
concurrent = {"count": 0, "max": 0}
|
||||
lock = threading.Lock()
|
||||
|
||||
def _run_ui():
|
||||
with lock:
|
||||
concurrent["count"] += 1
|
||||
concurrent["max"] = max(concurrent["max"], concurrent["count"])
|
||||
time.sleep(0.02) # simule un tray qui tourne un peu
|
||||
with lock:
|
||||
concurrent["count"] -= 1
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _is_running():
|
||||
calls["n"] += 1
|
||||
return calls["n"] <= 3 # 3 cycles de tray
|
||||
|
||||
wd = InteractiveSessionWatchdog(
|
||||
run_ui=_run_ui,
|
||||
is_running=_is_running,
|
||||
is_available=lambda: True,
|
||||
poll_interval_s=0.01,
|
||||
)
|
||||
wd.run()
|
||||
|
||||
# Jamais deux trays simultanés.
|
||||
assert concurrent["max"] == 1
|
||||
|
||||
|
||||
def test_stop_reveille_le_watchdog_en_attente():
|
||||
"""stop() sort immédiatement la boucle quand la session est absente."""
|
||||
run_ui_calls = []
|
||||
|
||||
wd = InteractiveSessionWatchdog(
|
||||
run_ui=lambda: run_ui_calls.append(1),
|
||||
is_running=lambda: True,
|
||||
is_available=lambda: False, # jamais de session => reste en attente
|
||||
poll_interval_s=60, # long : seul stop() peut débloquer
|
||||
)
|
||||
|
||||
t = threading.Thread(target=wd.run)
|
||||
t.start()
|
||||
time.sleep(0.05) # laisser entrer dans l'attente
|
||||
wd.stop()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert not t.is_alive() # le watchdog est bien sorti
|
||||
assert run_ui_calls == [] # aucun tray (jamais de session dispo)
|
||||
|
||||
|
||||
def test_crash_du_tray_ne_tue_pas_le_watchdog():
|
||||
"""Une exception dans run_ui est absorbée ; le watchdog reste maître."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def _run_ui():
|
||||
calls["n"] += 1
|
||||
raise RuntimeError("tray HS")
|
||||
|
||||
def _is_running():
|
||||
return calls["n"] < 2 # tolérer 2 crashs puis sortir
|
||||
|
||||
wd = InteractiveSessionWatchdog(
|
||||
run_ui=_run_ui,
|
||||
is_running=_is_running,
|
||||
is_available=lambda: True,
|
||||
poll_interval_s=0.01,
|
||||
)
|
||||
# Ne doit PAS lever : le crash est loggé, pas propagé.
|
||||
wd.run()
|
||||
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intégration avec main._agent_should_live (Quitter vs déconnexion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tray_run_reentrant_ne_relance_pas_les_threads_de_fond(monkeypatch):
|
||||
"""SmartTrayV1.run() appelé 2x (reconnexion RDP) : threads de fond 1x seulement.
|
||||
|
||||
On vérifie que `_start_background_once` est idempotent : les threads
|
||||
connexion/cache et l'accueil ne démarrent qu'au premier affichage, mais
|
||||
une nouvelle icône pystray est recréée à chaque appel (ré-affichage).
|
||||
"""
|
||||
import threading as _threading
|
||||
from agent_v0.agent_v1.ui import smart_tray as smart_tray_mod
|
||||
|
||||
tray = smart_tray_mod.SmartTrayV1.__new__(smart_tray_mod.SmartTrayV1)
|
||||
tray._bg_started = False
|
||||
tray.machine_id = "poste_test"
|
||||
tray.server_client = None # pas de threads réseau => simplifie
|
||||
tray.icon = None
|
||||
|
||||
greet_calls = {"n": 0}
|
||||
hotkey_calls = {"n": 0}
|
||||
icons_created = {"n": 0}
|
||||
|
||||
tray._notifier = MagicMock()
|
||||
tray._notifier.greet.side_effect = lambda: greet_calls.__setitem__("n", greet_calls["n"] + 1)
|
||||
monkeypatch.setattr(tray, "_start_hotkey", lambda: hotkey_calls.__setitem__("n", hotkey_calls["n"] + 1))
|
||||
monkeypatch.setattr(tray, "_current_icon", lambda: object())
|
||||
monkeypatch.setattr(tray, "_get_menu_items", lambda: [])
|
||||
|
||||
# Icône pystray factice : run() ne bloque pas (simule une sortie immédiate).
|
||||
class _FakeIcon:
|
||||
def __init__(self, *a, **k):
|
||||
icons_created["n"] += 1
|
||||
|
||||
def run(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(smart_tray_mod.pystray, "Icon", _FakeIcon)
|
||||
monkeypatch.setattr(smart_tray_mod.pystray, "Menu", lambda *a, **k: None)
|
||||
|
||||
# Deux affichages successifs (déconnexion → reconnexion).
|
||||
tray.run()
|
||||
tray.run()
|
||||
|
||||
# Accueil + hotkey : une seule fois (one-shot).
|
||||
assert greet_calls["n"] == 1
|
||||
assert hotkey_calls["n"] == 1
|
||||
# Mais une nouvelle icône à chaque affichage (le tray revient bien).
|
||||
assert icons_created["n"] == 2
|
||||
|
||||
|
||||
def test_agent_should_live_distingue_quit_et_deconnexion():
|
||||
"""Quitter explicite arrête le watchdog ; une déconnexion RDP non."""
|
||||
from types import SimpleNamespace
|
||||
from agent_v0.agent_v1.main import _agent_should_live
|
||||
|
||||
# Agent actif, tray sans quit demandé => doit vivre (déconnexion RDP OK).
|
||||
agent = SimpleNamespace(running=True, ui=SimpleNamespace(_quit_requested=False))
|
||||
assert _agent_should_live(agent) is True
|
||||
|
||||
# Quitter explicite => ne doit plus vivre (pas de résurrection).
|
||||
agent.ui._quit_requested = True
|
||||
assert _agent_should_live(agent) is False
|
||||
|
||||
# agent.running=False => ne vit plus (arrêt global).
|
||||
agent2 = SimpleNamespace(running=False, ui=SimpleNamespace(_quit_requested=False))
|
||||
assert _agent_should_live(agent2) is False
|
||||
408
tests/unit/test_agent_v1_updater.py
Normal file
408
tests/unit/test_agent_v1_updater.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""TDD — DETTE-022 MAJ silencieuse v2 : NOYAU client de mise à jour Léa.
|
||||
|
||||
Périmètre testé (parties PURES / testables, GATED, OFF par défaut) :
|
||||
- `parse_version` / `is_newer` côté client (R3, self-contained — le bundle
|
||||
client n'embarque pas server_v1).
|
||||
- `should_update(local_version, server_response)` : décision « faut-il
|
||||
updater ? quelle version/type ? » à partir de la réponse serveur.
|
||||
- `download_update(...)` via un `downloader` callable INJECTABLE : AUCUN
|
||||
réseau réel en test. Vérifie le SHA256, écrit le ZIP dans le staging,
|
||||
retourne un plan d'update — SANS toucher aux fichiers vivants.
|
||||
- Flag `RPA_AUTO_UPDATE_ENABLED` (défaut OFF) : `auto_update_enabled()`.
|
||||
|
||||
HORS périmètre (réservé révision humaine — trop risqué pour un agent) :
|
||||
swap réel des fichiers, édition Lea.bat, redémarrage. Le module expose des
|
||||
STUBS explicites (`apply_update`, `write_boot_ok_marker`) marqués TODO.
|
||||
|
||||
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
|
||||
lourd du package client (cf. DETTE-013, comme test_agent_v1_log_shipper).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "agent_v0" / "agent_v1" / "network" / "updater.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("lea_updater", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod():
|
||||
return _load_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3 — parse_version côté client (self-contained)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClientParseVersion:
|
||||
def test_ordre_semver(self, mod):
|
||||
assert mod.parse_version("1.0.2") < mod.parse_version("1.0.10")
|
||||
assert mod.is_newer("1.0.10", "1.0.2") is True
|
||||
assert mod.is_newer("1.0.1", "1.0.1") is False
|
||||
|
||||
def test_tolerant_et_fallback(self, mod):
|
||||
assert mod.parse_version("v1.2.3") == (1, 2, 3)
|
||||
assert mod.parse_version("garbage") == (0,)
|
||||
assert mod.parse_version(None) == (0,)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag RPA_AUTO_UPDATE_ENABLED — OFF par défaut
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFlag:
|
||||
def test_off_par_defaut(self, mod, monkeypatch):
|
||||
monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False)
|
||||
assert mod.auto_update_enabled() is False
|
||||
|
||||
def test_on_si_active(self, mod, monkeypatch):
|
||||
for val in ("true", "1", "yes", "on", "TRUE"):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", val)
|
||||
assert mod.auto_update_enabled() is True
|
||||
|
||||
def test_off_si_valeur_invalide(self, mod, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "false")
|
||||
assert mod.auto_update_enabled() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_update — décision à partir de la réponse serveur
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldUpdate:
|
||||
def test_pas_de_maj_si_response_negative(self, mod):
|
||||
plan = mod.should_update(
|
||||
"1.0.1", {"update_available": False, "latest_version": "1.0.1"}
|
||||
)
|
||||
assert plan is None
|
||||
|
||||
def test_maj_si_serveur_propose_version_plus_recente(self, mod):
|
||||
plan = mod.should_update(
|
||||
"1.0.1",
|
||||
{
|
||||
"update_available": True,
|
||||
"latest_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://srv/api/fleet/download/pc-1?type=code-only&version=1.0.2",
|
||||
},
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan["target_version"] == "1.0.2"
|
||||
assert plan["update_type"] == "code-only"
|
||||
|
||||
def test_double_garde_pas_de_downgrade(self, mod):
|
||||
# Même si le serveur dit update_available, le client revérifie semver :
|
||||
# il ne descend JAMAIS vers une version <= locale (défense en profondeur).
|
||||
plan = mod.should_update(
|
||||
"1.0.5",
|
||||
{"update_available": True, "latest_version": "1.0.2",
|
||||
"update_type": "code-only", "url": "http://x"},
|
||||
)
|
||||
assert plan is None
|
||||
|
||||
def test_type_inconnu_normalise_code_only(self, mod):
|
||||
plan = mod.should_update(
|
||||
"1.0.1",
|
||||
{"update_available": True, "latest_version": "1.0.2",
|
||||
"update_type": "weird", "url": "http://x"},
|
||||
)
|
||||
assert plan["update_type"] == "code-only"
|
||||
|
||||
def test_response_malformee_pas_de_crash(self, mod):
|
||||
assert mod.should_update("1.0.1", {}) is None
|
||||
assert mod.should_update("1.0.1", None) is None
|
||||
assert mod.should_update("1.0.1", {"update_available": True}) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_update — downloader INJECTABLE, SHA256, aucun réseau réel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDownloadUpdate:
|
||||
def test_telecharge_et_verifie_sha256_ok(self, mod, tmp_path):
|
||||
payload = b"PK\x03\x04 fake zip bytes"
|
||||
sha = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
calls = {}
|
||||
|
||||
def fake_downloader(url):
|
||||
calls["url"] = url
|
||||
return payload
|
||||
|
||||
plan = {
|
||||
"target_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://srv/dl?version=1.0.2",
|
||||
"sha256": sha,
|
||||
}
|
||||
result = mod.download_update(
|
||||
plan, staging_dir=tmp_path, downloader=fake_downloader
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert calls["url"] == "http://srv/dl?version=1.0.2"
|
||||
# Le ZIP est écrit dans le staging (Lea_next-like), PAS dans les fichiers vivants.
|
||||
staged = Path(result["staged_zip"])
|
||||
assert staged.exists()
|
||||
assert staged.read_bytes() == payload
|
||||
assert staged.parent == tmp_path
|
||||
|
||||
def test_sha256_mismatch_rejette_et_nettoie(self, mod, tmp_path):
|
||||
payload = b"corrupted"
|
||||
|
||||
def fake_downloader(url):
|
||||
return payload
|
||||
|
||||
plan = {
|
||||
"target_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://x",
|
||||
"sha256": "0" * 64, # ne correspond pas
|
||||
}
|
||||
result = mod.download_update(
|
||||
plan, staging_dir=tmp_path, downloader=fake_downloader
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "sha256" in result["error"].lower()
|
||||
# Aucun ZIP corrompu laissé dans le staging.
|
||||
assert list(tmp_path.glob("*.zip")) == []
|
||||
|
||||
def test_sha256_absent_accepte_avec_avertissement(self, mod, tmp_path):
|
||||
# Pas de sha256 fourni : best-effort, on accepte mais on le signale.
|
||||
payload = b"PK no-sha"
|
||||
|
||||
plan = {
|
||||
"target_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://x",
|
||||
}
|
||||
result = mod.download_update(
|
||||
plan, staging_dir=tmp_path, downloader=lambda u: payload
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result.get("sha256_verified") is False
|
||||
|
||||
def test_downloader_leve_pas_de_crash(self, mod, tmp_path):
|
||||
def boom(url):
|
||||
raise RuntimeError("réseau down")
|
||||
|
||||
plan = {"target_version": "1.0.2", "update_type": "code-only",
|
||||
"url": "http://x", "sha256": "x"}
|
||||
result = mod.download_update(plan, staging_dir=tmp_path, downloader=boom)
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_update — ARMEMENT du swap (extraction agent_v1_new + marqueur).
|
||||
# NE swappe PAS et NE touche PAS les fichiers vivants (Lea.bat le fait au boot).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_zip(path, entries):
|
||||
"""Fabrique un ZIP {nom: contenu} pour les tests."""
|
||||
import zipfile
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
for name, content in entries.items():
|
||||
zf.writestr(name, content)
|
||||
return path
|
||||
|
||||
|
||||
class TestApplyUpdateArm:
|
||||
def test_arme_extrait_et_pose_marqueur(self, mod, tmp_path):
|
||||
app = tmp_path / "app"; app.mkdir()
|
||||
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2", "sub/x.py": "y"})
|
||||
res = mod.apply_update(
|
||||
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
|
||||
app_dir=app,
|
||||
)
|
||||
assert res["armed"] is True and res["applied"] is False
|
||||
new_dir = app / "agent_v1_new"
|
||||
assert (new_dir / "main.py").read_text() == "v2"
|
||||
assert (new_dir / "sub" / "x.py").read_text() == "y"
|
||||
import json as _j
|
||||
data = _j.loads((app / "UPDATE_READY").read_text())
|
||||
assert data["target_version"] == "1.0.2"
|
||||
assert data["update_type"] == "code-only"
|
||||
|
||||
def test_ne_touche_pas_le_agent_v1_vivant(self, mod, tmp_path):
|
||||
app = tmp_path / "app"; (app / "agent_v1").mkdir(parents=True)
|
||||
live = app / "agent_v1" / "sentinelle.txt"
|
||||
live.write_text("VERSION_VIVANTE")
|
||||
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"})
|
||||
mod.apply_update(
|
||||
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
|
||||
app_dir=app,
|
||||
)
|
||||
assert live.read_text() == "VERSION_VIVANTE" # swap différé à Lea.bat
|
||||
|
||||
def test_zip_introuvable_pas_de_crash_ni_marqueur(self, mod, tmp_path):
|
||||
app = tmp_path / "app"; app.mkdir()
|
||||
res = mod.apply_update(
|
||||
{"target_version": "1.0.2", "update_type": "code-only",
|
||||
"staged_zip": str(tmp_path / "absent.zip")},
|
||||
app_dir=app,
|
||||
)
|
||||
assert res["armed"] is False and "error" in res
|
||||
assert not (app / "UPDATE_READY").exists()
|
||||
|
||||
def test_relance_nettoie_agent_v1_new_precedent(self, mod, tmp_path):
|
||||
app = tmp_path / "app"; app.mkdir()
|
||||
stale = app / "agent_v1_new"; stale.mkdir()
|
||||
(stale / "vieux.txt").write_text("obsolete")
|
||||
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"})
|
||||
mod.apply_update(
|
||||
{"target_version": "1.0.3", "update_type": "code-only", "staged_zip": str(z)},
|
||||
app_dir=app,
|
||||
)
|
||||
assert not (app / "agent_v1_new" / "vieux.txt").exists()
|
||||
assert (app / "agent_v1_new" / "main.py").read_text() == "v2"
|
||||
|
||||
def test_zip_slip_refuse(self, mod, tmp_path):
|
||||
app = tmp_path / "app"; app.mkdir()
|
||||
z = _make_zip(tmp_path / "evil.zip", {"../evil.py": "pwn"})
|
||||
res = mod.apply_update(
|
||||
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
|
||||
app_dir=app,
|
||||
)
|
||||
assert res["armed"] is False
|
||||
assert not (app / "evil.py").exists()
|
||||
|
||||
|
||||
class TestWriteBootOkMarker:
|
||||
def test_ecrit_boot_ok_et_desarme_pending(self, mod, tmp_path):
|
||||
app = tmp_path / "app"; app.mkdir()
|
||||
(app / "PENDING_BOOT_1.0.2").write_text("x")
|
||||
res = mod.write_boot_ok_marker("1.0.2", app_dir=app)
|
||||
assert res["written"] is True
|
||||
assert (app / "boot_ok_1.0.2").exists()
|
||||
assert not (app / "PENDING_BOOT_1.0.2").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_update_cycle — orchestrateur GATED (check → décide → stage → stub apply)
|
||||
# AUCUN réseau réel, AUCUN swap réel : checker/downloader INJECTABLES, le swap
|
||||
# reste un stub no-op (réservé révision humaine).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunUpdateCycle:
|
||||
def _checker(self, response):
|
||||
"""Fabrique un checker injectable qui renvoie `response`."""
|
||||
def _c(local_version, machine_id):
|
||||
return response
|
||||
return _c
|
||||
|
||||
def test_gate_off_ne_fait_rien(self, mod, tmp_path, monkeypatch):
|
||||
# Flag OFF (défaut) : le cycle ne doit RIEN faire (pas d'appel réseau).
|
||||
monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False)
|
||||
called = {"n": 0}
|
||||
|
||||
def _checker(local_version, machine_id):
|
||||
called["n"] += 1
|
||||
return {"update_available": True, "latest_version": "9.9.9",
|
||||
"url": "http://x", "sha256": None}
|
||||
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=_checker,
|
||||
downloader=lambda u: b"x",
|
||||
)
|
||||
assert result["status"] == "disabled"
|
||||
assert called["n"] == 0 # aucun appel réseau quand OFF
|
||||
|
||||
def test_a_jour_ne_stage_rien(self, mod, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=self._checker(
|
||||
{"update_available": False, "latest_version": "1.0.1"}
|
||||
),
|
||||
downloader=lambda u: b"should-not-be-called",
|
||||
)
|
||||
assert result["status"] == "up_to_date"
|
||||
assert list(tmp_path.glob("*.zip")) == []
|
||||
|
||||
def test_maj_dispo_arme_le_swap_mais_ne_swappe_pas(
|
||||
self, mod, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
# payload = un VRAI ZIP (le download le stage, apply_update l'extrait)
|
||||
import io, zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("main.py", "code v1.0.2")
|
||||
payload = buf.getvalue()
|
||||
sha = hashlib.sha256(payload).hexdigest()
|
||||
app = tmp_path / "app"; app.mkdir()
|
||||
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=self._checker({
|
||||
"update_available": True,
|
||||
"latest_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://srv/dl?version=1.0.2",
|
||||
"sha256": sha,
|
||||
}),
|
||||
downloader=lambda u: payload,
|
||||
app_dir=app,
|
||||
)
|
||||
# Téléchargé + vérifié + ARMÉ (agent_v1_new + UPDATE_READY), mais PAS
|
||||
# swappé : le remplacement atomique est fait par Lea.bat au reboot.
|
||||
assert result["status"] == "armed"
|
||||
assert result["target_version"] == "1.0.2"
|
||||
assert result["sha256_verified"] is True
|
||||
assert result["applied"] is False
|
||||
assert (app / "UPDATE_READY").exists()
|
||||
assert (app / "agent_v1_new" / "main.py").read_text() == "code v1.0.2"
|
||||
|
||||
def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=self._checker({
|
||||
"update_available": True,
|
||||
"latest_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://x",
|
||||
"sha256": "0" * 64,
|
||||
}),
|
||||
downloader=lambda u: b"corrupted",
|
||||
)
|
||||
assert result["status"] == "download_failed"
|
||||
assert list(tmp_path.glob("*.zip")) == []
|
||||
|
||||
def test_checker_qui_leve_pas_de_crash(self, mod, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
|
||||
def _boom(local_version, machine_id):
|
||||
raise RuntimeError("serveur down / 503")
|
||||
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=_boom,
|
||||
downloader=lambda u: b"x",
|
||||
)
|
||||
# Best-effort : jamais d'exception ne remonte (ne casse pas Léa).
|
||||
assert result["status"] == "check_failed"
|
||||
155
tests/unit/test_capture_io.py
Normal file
155
tests/unit/test_capture_io.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests unitaires de la politique de sauvegarde des captures (agent_v1).
|
||||
|
||||
Objectif : réduire le poids disque des captures (90 Go / 13 sessions = trop)
|
||||
sans casser la précision du grounding. La politique distingue le *type* de
|
||||
shot :
|
||||
|
||||
- ``crop`` → PNG lossless (cible de grounding qwen3-vl, précision pixel) ;
|
||||
- ``full`` / ``window`` / ``context`` → JPEG ``optimize=True`` (vue humaine /
|
||||
contexte, compression ~5-10x acceptable) ;
|
||||
- ``heartbeat`` → JPEG **downscalé** (liveness, pas de grounding → on peut
|
||||
réduire la résolution).
|
||||
|
||||
La fonction ``save_capture`` retourne le chemin RÉELLEMENT écrit (extension
|
||||
ajustée selon le format), pour que l'appelant streame le bon fichier.
|
||||
|
||||
Branche feat/push-log-dgx — réduction du poids de capture (unité testée,
|
||||
non encore câblée dans capturer.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
def _noisy_image(width: int, height: int) -> Image.Image:
|
||||
"""Image RGB avec du bruit réel.
|
||||
|
||||
Un aplat uni se compresse à quasi-zéro en PNG comme en JPEG : la
|
||||
comparaison de poids serait truquée. On injecte du bruit pour que la
|
||||
différence PNG/JPEG soit représentative d'un vrai screenshot.
|
||||
"""
|
||||
return Image.frombytes("RGB", (width, height), os.urandom(width * height * 3))
|
||||
|
||||
|
||||
def test_crop_reste_png_et_dimensions_identiques(tmp_path):
|
||||
"""Un crop est sauvé en PNG lossless, dimensions inchangées."""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = _noisy_image(80, 80)
|
||||
base = str(tmp_path / "shot_0001_crop")
|
||||
|
||||
out_path = save_capture(img, base, kind="crop")
|
||||
|
||||
assert out_path.endswith(".png"), f"crop doit rester PNG, obtenu {out_path}"
|
||||
assert os.path.exists(out_path)
|
||||
reread = Image.open(out_path)
|
||||
assert reread.size == (80, 80)
|
||||
# PNG lossless : les pixels doivent être identiques au bruit d'origine.
|
||||
assert list(reread.convert("RGB").getdata()) == list(img.getdata())
|
||||
|
||||
|
||||
def test_full_est_jpeg(tmp_path):
|
||||
"""Un full est sauvé en JPEG (.jpg)."""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = _noisy_image(640, 480)
|
||||
base = str(tmp_path / "shot_0001_full")
|
||||
|
||||
out_path = save_capture(img, base, kind="full")
|
||||
|
||||
assert out_path.endswith(".jpg"), f"full doit être JPEG, obtenu {out_path}"
|
||||
assert os.path.exists(out_path)
|
||||
|
||||
|
||||
def test_full_jpeg_significativement_plus_leger_que_png(tmp_path):
|
||||
"""Le JPEG full doit peser nettement moins que le PNG équivalent.
|
||||
|
||||
On génère une image bruitée plein écran (2560×1600) et on compare le
|
||||
poids du JPEG produit par la politique au poids d'un PNG lossless du
|
||||
même contenu. Le gain doit être substantiel (au moins 2x plus léger).
|
||||
"""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = _noisy_image(2560, 1600)
|
||||
|
||||
jpeg_path = save_capture(img, str(tmp_path / "full_jpeg"), kind="full")
|
||||
png_ref = tmp_path / "full_ref.png"
|
||||
img.save(png_ref, "PNG")
|
||||
|
||||
jpeg_size = os.path.getsize(jpeg_path)
|
||||
png_size = os.path.getsize(png_ref)
|
||||
|
||||
assert jpeg_size < png_size / 2, (
|
||||
f"JPEG ({jpeg_size}o) doit peser < moitié du PNG ({png_size}o)"
|
||||
)
|
||||
|
||||
|
||||
def test_context_et_window_sont_jpeg(tmp_path):
|
||||
"""context et window suivent la même politique JPEG que full."""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = _noisy_image(320, 240)
|
||||
for kind in ("context", "window"):
|
||||
out_path = save_capture(img, str(tmp_path / f"x_{kind}"), kind=kind)
|
||||
assert out_path.endswith(".jpg"), f"{kind} doit être JPEG, obtenu {out_path}"
|
||||
assert os.path.exists(out_path)
|
||||
|
||||
|
||||
def test_heartbeat_est_downscale(tmp_path):
|
||||
"""Un heartbeat est downscalé (largeur réduite) et reste JPEG."""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = _noisy_image(2560, 1600)
|
||||
out_path = save_capture(img, str(tmp_path / "heartbeat_1234"), kind="heartbeat")
|
||||
|
||||
assert out_path.endswith(".jpg"), f"heartbeat doit être JPEG, obtenu {out_path}"
|
||||
reread = Image.open(out_path)
|
||||
assert reread.width < 2560, "heartbeat doit être downscalé en largeur"
|
||||
# Ratio préservé (16:10 → la hauteur doit suivre la largeur réduite).
|
||||
ratio_src = 2560 / 1600
|
||||
ratio_out = reread.width / reread.height
|
||||
assert abs(ratio_src - ratio_out) < 0.02, "le ratio doit être préservé"
|
||||
|
||||
|
||||
def test_heartbeat_plus_leger_que_full_jpeg(tmp_path):
|
||||
"""Le downscale du heartbeat le rend plus léger que le full JPEG plein res."""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = _noisy_image(2560, 1600)
|
||||
hb = save_capture(img, str(tmp_path / "heartbeat_5678"), kind="heartbeat")
|
||||
full = save_capture(img, str(tmp_path / "shot_9999_full"), kind="full")
|
||||
|
||||
assert os.path.getsize(hb) < os.path.getsize(full), (
|
||||
"le heartbeat downscalé doit peser moins que le full JPEG plein res"
|
||||
)
|
||||
|
||||
|
||||
def test_kind_inconnu_leve_erreur(tmp_path):
|
||||
"""Un kind non reconnu doit échouer explicitement (fail-closed)."""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = _noisy_image(40, 40)
|
||||
try:
|
||||
save_capture(img, str(tmp_path / "x"), kind="inexistant")
|
||||
except ValueError:
|
||||
return
|
||||
raise AssertionError("un kind inconnu doit lever ValueError")
|
||||
|
||||
|
||||
def test_rgba_converti_pour_jpeg(tmp_path):
|
||||
"""Une image RGBA doit être convertie avant l'encodage JPEG (pas d'alpha)."""
|
||||
from agent_v0.agent_v1.vision.capture_io import save_capture
|
||||
|
||||
img = Image.new("RGBA", (64, 64), (10, 20, 30, 128))
|
||||
out_path = save_capture(img, str(tmp_path / "shot_rgba_full"), kind="full")
|
||||
assert out_path.endswith(".jpg")
|
||||
assert os.path.exists(out_path)
|
||||
320
tests/unit/test_capturer_capture_io_format.py
Normal file
320
tests/unit/test_capturer_capture_io_format.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Politique de format des captures + robustesse du répertoire shots.
|
||||
|
||||
Deux corrections testées ici (agent_v0/agent_v1/vision) :
|
||||
|
||||
1. FORMAT (allègement) : `capturer.py` doit déléguer l'écriture à
|
||||
`capture_io.save_capture`, qui applique la politique :
|
||||
- crop → PNG lossless (cible de grounding qwen3-vl)
|
||||
- full/window/context → JPEG q85
|
||||
- heartbeat → JPEG downscalé (largeur max ~1280)
|
||||
Aujourd'hui tout était sauvé en `img.save(path, "PNG", quality=...)`
|
||||
(le `quality` est ignoré par PNG → PNG lossless plein écran, ~90 Go).
|
||||
|
||||
2. BUG chemin (poste Émilie) : ``[Errno 2] No such file or directory:
|
||||
..._background/shots/context...``. Le répertoire `shots/` est créé une
|
||||
seule fois dans `__init__`, mais l'auto-cleanup (`SessionStorage`,
|
||||
`shutil.rmtree`) peut supprimer tout le dossier de session `_background`.
|
||||
Les sauvegardes suivantes doivent recréer le répertoire cible
|
||||
(`os.makedirs(dir, exist_ok=True)`) avant chaque écriture.
|
||||
|
||||
Tests 100% mockés : aucune vraie capture écran (mss est patché).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (repris du style de test_capturer_monitor_guard.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_mss(monitors):
|
||||
"""Mock `mss.mss()` renvoyant un monitor sain unique (image grise unie)."""
|
||||
|
||||
def factory():
|
||||
instance = MagicMock()
|
||||
instance.monitors = monitors
|
||||
grab_result = MagicMock()
|
||||
m = monitors[1] if len(monitors) > 1 else monitors[0]
|
||||
w, h = m["width"], m["height"]
|
||||
grab_result.size = (w, h)
|
||||
grab_result.bgra = b"\x80\x80\x80\x00" * (w * h)
|
||||
instance.grab = MagicMock(return_value=grab_result)
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(return_value=instance)
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
return cm
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
_NORMAL_MONITORS = [
|
||||
{"left": 0, "top": 0, "width": 800, "height": 600}, # composite
|
||||
{"left": 0, "top": 0, "width": 800, "height": 600}, # primaire sain
|
||||
]
|
||||
|
||||
|
||||
def _vision_capturer(tmp_path):
|
||||
from agent_v0.agent_v1.vision.capturer import VisionCapturer
|
||||
|
||||
return VisionCapturer(str(tmp_path))
|
||||
|
||||
|
||||
def _patch_mss():
|
||||
"""Contexte : mss patché + time.sleep no-op + pas de floutage.
|
||||
|
||||
Le floutage est désactivé pour isoler la politique d'écriture (le blur
|
||||
ouvre/modifie l'image mais n'impacte pas le format de sortie ; on le coupe
|
||||
pour rester déterministe).
|
||||
"""
|
||||
return (
|
||||
patch(
|
||||
"agent_v0.agent_v1.vision.capturer.mss.mss",
|
||||
side_effect=_make_mock_mss(_NORMAL_MONITORS),
|
||||
),
|
||||
patch("agent_v0.agent_v1.vision.capturer.time.sleep"),
|
||||
patch("agent_v0.agent_v1.vision.capturer.BLUR_SENSITIVE", False),
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PARTIE A — Politique save_capture (unité capture_io)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_save_capture_crop_stays_png(tmp_path: Path):
|
||||
from agent_v0.agent_v1.vision import capture_io
|
||||
|
||||
img = Image.new("RGB", (80, 80), (10, 20, 30))
|
||||
out = capture_io.save_capture(img, str(tmp_path / "shot_crop"), "crop")
|
||||
|
||||
assert out.endswith(".png"), f"crop doit rester PNG, got {out!r}"
|
||||
assert Path(out).exists()
|
||||
with Image.open(out) as reopened:
|
||||
assert reopened.format == "PNG"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["full", "window", "context"])
|
||||
def test_save_capture_context_kinds_are_jpeg(tmp_path: Path, kind: str):
|
||||
from agent_v0.agent_v1.vision import capture_io
|
||||
|
||||
img = Image.new("RGB", (640, 480), (120, 130, 140))
|
||||
out = capture_io.save_capture(img, str(tmp_path / f"shot_{kind}"), kind)
|
||||
|
||||
assert out.endswith(".jpg"), f"{kind} doit être JPEG, got {out!r}"
|
||||
assert Path(out).exists()
|
||||
with Image.open(out) as reopened:
|
||||
assert reopened.format == "JPEG"
|
||||
|
||||
|
||||
def test_save_capture_heartbeat_is_downscaled_jpeg(tmp_path: Path):
|
||||
from agent_v0.agent_v1.vision import capture_io
|
||||
|
||||
# Image large (2560) → doit être réduite à HEARTBEAT_MAX_WIDTH.
|
||||
img = Image.new("RGB", (2560, 1440), (50, 60, 70))
|
||||
out = capture_io.save_capture(img, str(tmp_path / "hb"), "heartbeat")
|
||||
|
||||
assert out.endswith(".jpg")
|
||||
with Image.open(out) as reopened:
|
||||
assert reopened.format == "JPEG"
|
||||
assert reopened.width == capture_io.HEARTBEAT_MAX_WIDTH, (
|
||||
f"heartbeat doit être downscalé à {capture_io.HEARTBEAT_MAX_WIDTH}, "
|
||||
f"got {reopened.width}"
|
||||
)
|
||||
# ratio préservé (1440 * 1280/2560 = 720)
|
||||
assert reopened.height == 720
|
||||
|
||||
|
||||
def test_save_capture_heartbeat_smaller_than_max_is_not_upscaled(tmp_path: Path):
|
||||
from agent_v0.agent_v1.vision import capture_io
|
||||
|
||||
img = Image.new("RGB", (640, 360), (1, 2, 3))
|
||||
out = capture_io.save_capture(img, str(tmp_path / "hb_small"), "heartbeat")
|
||||
with Image.open(out) as reopened:
|
||||
assert reopened.width == 640, "no-op si déjà plus petit que le max"
|
||||
|
||||
|
||||
def test_save_capture_heartbeat_downscale_reduces_pixel_count(tmp_path: Path):
|
||||
"""Preuve de l'allègement heartbeat par la mesure objective du code :
|
||||
le downscale réduit le nombre de pixels (2560×1440 → 1280×720 = /4 surface).
|
||||
On mesure la géométrie de sortie (déterministe), pas le poids d'un JPEG
|
||||
synthétique (qui dépend de libjpeg et n'est pas représentatif d'un vrai
|
||||
écran)."""
|
||||
from agent_v0.agent_v1.vision import capture_io
|
||||
|
||||
src = Image.new("RGB", (2560, 1440))
|
||||
out = capture_io.save_capture(src, str(tmp_path / "hb_measure"), "heartbeat")
|
||||
with Image.open(out) as small:
|
||||
src_pixels = src.width * src.height
|
||||
out_pixels = small.width * small.height
|
||||
assert out_pixels < src_pixels / 3, (
|
||||
f"Le downscale heartbeat doit diviser la surface par ~4 "
|
||||
f"({src_pixels} → {out_pixels})"
|
||||
)
|
||||
|
||||
|
||||
def test_save_capture_rejects_unknown_kind(tmp_path: Path):
|
||||
from agent_v0.agent_v1.vision import capture_io
|
||||
|
||||
img = Image.new("RGB", (10, 10))
|
||||
with pytest.raises(ValueError):
|
||||
capture_io.save_capture(img, str(tmp_path / "x"), "bogus")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PARTIE B — Câblage dans capturer.py (format des sorties runtime)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_capture_full_context_writes_jpeg(tmp_path: Path):
|
||||
"""capture_full_context (context / focus_change / result_of_*) → JPEG."""
|
||||
p1, p2, p3 = _patch_mss()
|
||||
with p1, p2, p3:
|
||||
cap = _vision_capturer(tmp_path)
|
||||
out = cap.capture_full_context("focus_change", force=True)
|
||||
|
||||
assert out, "capture attendue"
|
||||
assert out.endswith(".jpg"), f"context doit être JPEG, got {out!r}"
|
||||
assert Path(out).exists()
|
||||
with Image.open(out) as im:
|
||||
assert im.format == "JPEG"
|
||||
|
||||
|
||||
def test_capture_full_context_heartbeat_is_jpeg(tmp_path: Path):
|
||||
"""Un suffixe 'heartbeat' doit produire un JPEG (downscalé côté politique)."""
|
||||
p1, p2, p3 = _patch_mss()
|
||||
with p1, p2, p3:
|
||||
cap = _vision_capturer(tmp_path)
|
||||
out = cap.capture_full_context("heartbeat", force=True)
|
||||
|
||||
assert out.endswith(".jpg"), f"heartbeat doit être JPEG, got {out!r}"
|
||||
with Image.open(out) as im:
|
||||
assert im.format == "JPEG"
|
||||
|
||||
|
||||
def test_capture_dual_full_is_jpeg_crop_is_png(tmp_path: Path):
|
||||
"""capture_dual : full/window en JPEG, crop en PNG (contrat serveur)."""
|
||||
p1, p2, p3 = _patch_mss()
|
||||
with p1, p2, p3, patch(
|
||||
# Neutraliser la capture fenêtre (dépend d'API OS) pour isoler full+crop
|
||||
"agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window",
|
||||
return_value=None,
|
||||
):
|
||||
cap = _vision_capturer(tmp_path)
|
||||
result = cap.capture_dual(x=100, y=200, screenshot_id="shot42")
|
||||
|
||||
assert "full" in result and "crop" in result
|
||||
assert result["full"].endswith(".jpg"), f"full doit être JPEG, got {result['full']!r}"
|
||||
assert result["crop"].endswith(".png"), f"crop doit rester PNG, got {result['crop']!r}"
|
||||
assert Path(result["full"]).exists()
|
||||
assert Path(result["crop"]).exists()
|
||||
with Image.open(result["full"]) as im:
|
||||
assert im.format == "JPEG"
|
||||
with Image.open(result["crop"]) as im:
|
||||
assert im.format == "PNG"
|
||||
|
||||
|
||||
def test_capture_active_window_writes_jpeg(tmp_path: Path):
|
||||
"""La fenêtre active est une vue contextuelle → JPEG."""
|
||||
p1, p2, p3 = _patch_mss()
|
||||
fake_rect = {
|
||||
"rect": [100, 100, 500, 400],
|
||||
"size": [400, 300],
|
||||
"title": "Bloc-notes",
|
||||
"app_name": "notepad.exe",
|
||||
}
|
||||
full_img = Image.new("RGB", (800, 600), (90, 90, 90))
|
||||
with p1, p2, p3, patch(
|
||||
"agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect",
|
||||
return_value=fake_rect,
|
||||
):
|
||||
cap = _vision_capturer(tmp_path)
|
||||
result = cap.capture_active_window(
|
||||
x=200, y=200, screenshot_id="shotW", full_img=full_img
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["window_image"].endswith(".jpg"), (
|
||||
f"window doit être JPEG, got {result['window_image']!r}"
|
||||
)
|
||||
with Image.open(result["window_image"]) as im:
|
||||
assert im.format == "JPEG"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PARTIE C — BUG chemin : shots/ recréé si supprimé par l'auto-cleanup
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_capture_full_context_recreates_shots_dir_after_rmtree(tmp_path: Path):
|
||||
"""Reproduction du bug poste Émilie.
|
||||
|
||||
L'auto-cleanup (`SessionStorage.shutil.rmtree`) supprime tout le dossier
|
||||
de session `_background` (donc `shots/`). Une capture ultérieure ne doit
|
||||
PAS lever `[Errno 2] No such file or directory` : le répertoire cible
|
||||
doit être recréé avant l'écriture.
|
||||
"""
|
||||
p1, p2, p3 = _patch_mss()
|
||||
with p1, p2, p3:
|
||||
cap = _vision_capturer(tmp_path)
|
||||
|
||||
# Simule l'auto-cleanup : la session entière est purgée après ACK.
|
||||
shutil.rmtree(cap.shots_dir)
|
||||
assert not Path(cap.shots_dir).exists()
|
||||
|
||||
out = cap.capture_full_context("context_after_purge", force=True)
|
||||
|
||||
assert out, "La capture doit réussir même après purge du dossier shots"
|
||||
assert Path(out).exists(), "Le fichier doit être physiquement écrit"
|
||||
assert Path(cap.shots_dir).exists(), "shots/ doit avoir été recréé"
|
||||
|
||||
|
||||
def test_capture_dual_recreates_shots_dir_after_rmtree(tmp_path: Path):
|
||||
"""capture_dual doit aussi survivre à la purge du dossier shots."""
|
||||
p1, p2, p3 = _patch_mss()
|
||||
with p1, p2, p3, patch(
|
||||
"agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window",
|
||||
return_value=None,
|
||||
):
|
||||
cap = _vision_capturer(tmp_path)
|
||||
shutil.rmtree(cap.shots_dir)
|
||||
|
||||
result = cap.capture_dual(x=50, y=60, screenshot_id="shot_purge")
|
||||
|
||||
assert result.get("full") and result.get("crop"), (
|
||||
"capture_dual doit produire full+crop même après purge"
|
||||
)
|
||||
assert Path(result["full"]).exists()
|
||||
assert Path(result["crop"]).exists()
|
||||
|
||||
|
||||
def test_capture_active_window_recreates_shots_dir_after_rmtree(tmp_path: Path):
|
||||
"""capture_active_window (crop fenêtre depuis full fourni) survit à la purge."""
|
||||
p1, p2, p3 = _patch_mss()
|
||||
fake_rect = {
|
||||
"rect": [10, 10, 210, 210],
|
||||
"size": [200, 200],
|
||||
"title": "W",
|
||||
"app_name": "w.exe",
|
||||
}
|
||||
full_img = Image.new("RGB", (400, 400), (70, 70, 70))
|
||||
with p1, p2, p3, patch(
|
||||
"agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect",
|
||||
return_value=fake_rect,
|
||||
):
|
||||
cap = _vision_capturer(tmp_path)
|
||||
shutil.rmtree(cap.shots_dir)
|
||||
|
||||
result = cap.capture_active_window(
|
||||
x=50, y=50, screenshot_id="shotW_purge", full_img=full_img
|
||||
)
|
||||
|
||||
assert result is not None, "capture fenêtre doit réussir après purge"
|
||||
assert Path(result["window_image"]).exists()
|
||||
202
tests/unit/test_coords_consumption_gap.py
Normal file
202
tests/unit/test_coords_consumption_gap.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Tests documenting the coords consumption gap: write-only navigate coords.
|
||||
|
||||
Test 1 (POSITIVE): _resolve_runtime_vars mechanism works — template strings
|
||||
like {{navigate_login_coords.x_pct}} resolve correctly when variables dict
|
||||
contains the stored coords.
|
||||
|
||||
Test 2 (NEGATIVE): _edge_to_normalized_actions bakes coords as literal floats,
|
||||
never producing template strings — so runtime variable resolution is never
|
||||
triggered for navigate coords, proving the write-only gap.
|
||||
|
||||
These tests are evidence, not regression guards. Test 2 documents a known
|
||||
structural gap; when the gap is fixed, Test 2 should be updated to assert
|
||||
templates ARE produced.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("RPA_AUTH_DISABLED", "true")
|
||||
|
||||
from agent_v0.server_v1.replay_engine import (
|
||||
_edge_to_normalized_actions,
|
||||
_resolve_runtime_vars,
|
||||
_resolve_runtime_vars_in_str,
|
||||
)
|
||||
|
||||
|
||||
# ── Fake fixtures (minimal, per test_visual_anchor_semantics.py pattern) ──
|
||||
|
||||
|
||||
class _FakeAction:
|
||||
def __init__(self, type_, target=None, parameters=None):
|
||||
self.type = type_
|
||||
self.target = target
|
||||
self.parameters = parameters or {}
|
||||
|
||||
|
||||
class _FakeEdge:
|
||||
def __init__(self, action):
|
||||
self.edge_id = "edge_coords_gap"
|
||||
self.from_node = "node_src"
|
||||
self.to_node = "node_dst"
|
||||
self.action = action
|
||||
|
||||
|
||||
# ── Test 1: resolve mechanism is viable ──────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveRuntimeVarsViable:
|
||||
"""Prove _resolve_runtime_vars infrastructure works with template strings."""
|
||||
|
||||
VARIABLES = {
|
||||
"navigate_login_coords": {
|
||||
"x_pct": 0.15,
|
||||
"y_pct": 0.07,
|
||||
"method": "ocr_anchor",
|
||||
}
|
||||
}
|
||||
|
||||
def test_resolve_in_str_dot_path(self):
|
||||
"""{{navigate_login_coords.x_pct}} → "0.15" (string, not float)."""
|
||||
result = _resolve_runtime_vars_in_str(
|
||||
"{{navigate_login_coords.x_pct}}", self.VARIABLES
|
||||
)
|
||||
assert result == "0.15"
|
||||
|
||||
def test_resolve_in_str_y_pct(self):
|
||||
"""{{navigate_login_coords.y_pct}} → "0.07"."""
|
||||
result = _resolve_runtime_vars_in_str(
|
||||
"{{navigate_login_coords.y_pct}}", self.VARIABLES
|
||||
)
|
||||
assert result == "0.07"
|
||||
|
||||
def test_resolve_dict_with_templates(self):
|
||||
"""_resolve_runtime_vars substitutes templates inside dict values."""
|
||||
action = {
|
||||
"type": "click",
|
||||
"x_pct": "{{navigate_login_coords.x_pct}}",
|
||||
"y_pct": "{{navigate_login_coords.y_pct}}",
|
||||
}
|
||||
resolved = _resolve_runtime_vars(action, self.VARIABLES)
|
||||
assert resolved["x_pct"] == "0.15"
|
||||
assert resolved["y_pct"] == "0.07"
|
||||
assert resolved["type"] == "click" # no-template strings unchanged
|
||||
|
||||
def test_resolve_nested_dict(self):
|
||||
"""_resolve_runtime_vars handles nested dicts with templates."""
|
||||
action = {
|
||||
"parameters": {
|
||||
"coords": "{{navigate_login_coords.x_pct}}",
|
||||
},
|
||||
}
|
||||
resolved = _resolve_runtime_vars(action, self.VARIABLES)
|
||||
assert resolved["parameters"]["coords"] == "0.15"
|
||||
|
||||
def test_resolve_missing_var_leaves_template_intact(self):
|
||||
"""Missing variable: template string stays unchanged."""
|
||||
result = _resolve_runtime_vars_in_str(
|
||||
"{{navigate_password_coords.x_pct}}", self.VARIABLES
|
||||
)
|
||||
assert "{{navigate_password_coords.x_pct}}" in result
|
||||
|
||||
def test_resolve_float_passthrough(self):
|
||||
"""_resolve_runtime_vars returns non-str values unchanged — floats pass through."""
|
||||
action = {"x_pct": 0.15, "y_pct": 0.07}
|
||||
resolved = _resolve_runtime_vars(action, self.VARIABLES)
|
||||
# Floats are NOT substituted — they're not strings containing {{...}}
|
||||
assert resolved["x_pct"] == 0.15 # literal float, unchanged
|
||||
assert resolved["y_pct"] == 0.07
|
||||
|
||||
|
||||
# ── Test 2: compiler gap — literals not templates ────────────────────────
|
||||
|
||||
|
||||
class TestCompilerGapLiteralFloats:
|
||||
"""Document that _edge_to_normalized_actions produces literal floats,
|
||||
never template strings — so navigate coords are write-only.
|
||||
|
||||
This is the STRUCTURAL GAP: the compiler bakes coords as floats,
|
||||
_resolve_runtime_vars only operates on strings, so stored navigate
|
||||
variables are never consumed downstream.
|
||||
"""
|
||||
|
||||
def test_mouse_click_produces_literal_floats(self):
|
||||
"""mouse_click edge: x_pct/y_pct are literal floats, not templates."""
|
||||
target = SimpleNamespace(
|
||||
by_position=(0.15, 0.07),
|
||||
by_role=None,
|
||||
by_text=None,
|
||||
context_hints={},
|
||||
)
|
||||
edge = _FakeEdge(
|
||||
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
|
||||
)
|
||||
actions = _edge_to_normalized_actions(edge, params={})
|
||||
assert len(actions) == 1
|
||||
action = actions[0]
|
||||
|
||||
# GAP: coords are literal floats, not template strings
|
||||
assert isinstance(action["x_pct"], float)
|
||||
assert isinstance(action["y_pct"], float)
|
||||
assert action["x_pct"] == 0.15
|
||||
assert action["y_pct"] == 0.07
|
||||
|
||||
# Proof: no template string is ever produced by the compiler
|
||||
assert not isinstance(action["x_pct"], str)
|
||||
assert not isinstance(action["y_pct"], str)
|
||||
|
||||
def test_literal_floats_not_resolved(self):
|
||||
"""Literal floats pass through _resolve_runtime_vars unchanged —
|
||||
proving navigate coords stored in variables are NEVER consumed."""
|
||||
target = SimpleNamespace(
|
||||
by_position=(0.15, 0.07),
|
||||
by_role=None,
|
||||
by_text=None,
|
||||
context_hints={},
|
||||
)
|
||||
edge = _FakeEdge(
|
||||
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
|
||||
)
|
||||
actions = _edge_to_normalized_actions(edge, params={})
|
||||
action = actions[0]
|
||||
|
||||
# Simulate variables from a prior navigate_login step
|
||||
different_coords = {
|
||||
"navigate_login_coords": {"x_pct": 0.20, "y_pct": 0.10}
|
||||
}
|
||||
resolved = _resolve_runtime_vars(action, different_coords)
|
||||
|
||||
# Coords REMAIN the original literal floats — no substitution
|
||||
assert resolved["x_pct"] == 0.15 # NOT 0.20 (no substitution)
|
||||
assert resolved["y_pct"] == 0.07 # NOT 0.10 (no substitution)
|
||||
|
||||
def test_text_input_produces_literal_floats(self):
|
||||
"""text_input edge: same literal float pattern for click target."""
|
||||
target = SimpleNamespace(
|
||||
by_position=(0.30, 0.50),
|
||||
by_role=None,
|
||||
by_text=None,
|
||||
context_hints={},
|
||||
)
|
||||
edge = _FakeEdge(
|
||||
_FakeAction("text_input", target=target, parameters={"text": "admin"})
|
||||
)
|
||||
actions = _edge_to_normalized_actions(edge, params={})
|
||||
assert len(actions) == 1
|
||||
action = actions[0]
|
||||
|
||||
assert isinstance(action["x_pct"], float)
|
||||
assert isinstance(action["y_pct"], float)
|
||||
assert action["x_pct"] == 0.30
|
||||
assert action["y_pct"] == 0.50
|
||||
|
||||
def test_navigate_action_type_unknown(self):
|
||||
"""navigate action type is NOT handled by _edge_to_normalized_actions —
|
||||
falls into the else branch logging "Type d'action inconnu"."""
|
||||
edge = _FakeEdge(_FakeAction("navigate", parameters={"target": "login"}))
|
||||
actions = _edge_to_normalized_actions(edge, params={})
|
||||
|
||||
# navigate produces empty actions — not compiled at all
|
||||
assert actions == []
|
||||
219
tests/unit/test_extract_dossier.py
Normal file
219
tests/unit/test_extract_dossier.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Tests TDD — Extraction « dossier patient » (brique 3).
|
||||
|
||||
Deux couches testées :
|
||||
|
||||
1. ``vwb_db.persist_extracted_dossier`` : depuis une grille OCR
|
||||
(List[List[cell]]), crée ExtractionJob → ExtractedTable → ExtractedField
|
||||
et commit. Testé sur SQLite mémoire via un app-context Flask jetable
|
||||
(PAS la vraie DB VWB — isolation).
|
||||
|
||||
2. ``replay_engine._handle_extract_dossier_action`` : lit last_screenshot,
|
||||
appelle ``extract_grid_from_image`` (mocké), applique la gate qualité
|
||||
(complete / needs_review), persiste via vwb_db et n'échoue JAMAIS le
|
||||
replay (grille vide → needs_review, sans lever).
|
||||
|
||||
⚠️ Canal extraction = données patient EN CLAIR (volontaire) : on vérifie
|
||||
que les valeurs sont persistées telles quelles, sans tokenisation.
|
||||
"""
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
# vwb_db ajoute visual_workflow_builder/backend au sys.path à l'import →
|
||||
# doit précéder l'import de db.models (couplage worker→DB VWB mutualisé).
|
||||
import agent_v0.server_v1.vwb_db as vwb_db
|
||||
import agent_v0.server_v1.replay_engine as replay_engine
|
||||
|
||||
from db.models import db, ExtractionJob, ExtractedTable, ExtractedField
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures : app Flask jetable sur SQLite mémoire (isolation totale)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def mem_app():
|
||||
"""App Flask minimale liée à une DB SQLite en mémoire."""
|
||||
app = Flask("test_extract_dossier")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
|
||||
|
||||
def _grid_2x2():
|
||||
"""Grille connue 2×2 (confiances hautes)."""
|
||||
return [
|
||||
[
|
||||
{"text": "Nom", "bbox": [[0, 0], [1, 0], [1, 1], [0, 1]], "confidence": 0.95, "row": 0, "col": 0},
|
||||
{"text": "MOREL", "bbox": [[2, 0], [3, 0], [3, 1], [2, 1]], "confidence": 0.92, "row": 0, "col": 1},
|
||||
],
|
||||
[
|
||||
{"text": "IPP", "bbox": [[0, 2], [1, 2], [1, 3], [0, 3]], "confidence": 0.90, "row": 1, "col": 0},
|
||||
{"text": "25123456", "bbox": [[2, 2], [3, 2], [3, 3], [2, 3]], "confidence": 0.88, "row": 1, "col": 1},
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) persist_extracted_dossier
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
def test_persist_extracted_dossier_creates_job_table_fields(mem_app):
|
||||
job_id = vwb_db.persist_extracted_dossier(
|
||||
_grid_2x2(),
|
||||
patient_ref="MOREL Catherine",
|
||||
source_session_id="sess-42",
|
||||
screenshot_ref="/captures/last.png",
|
||||
screen_bbox={"x": 0, "y": 0, "width": 800, "height": 600},
|
||||
status="complete",
|
||||
)
|
||||
|
||||
assert isinstance(job_id, str) and job_id
|
||||
|
||||
job = db.session.get(ExtractionJob, job_id)
|
||||
assert job is not None
|
||||
assert job.status == "complete"
|
||||
assert job.patient_ref == "MOREL Catherine" # EN CLAIR, non tokenisé
|
||||
assert job.source_session_id == "sess-42"
|
||||
|
||||
tables = ExtractedTable.query.filter_by(job_id=job_id).all()
|
||||
assert len(tables) == 1
|
||||
assert tables[0].screenshot_ref == "/captures/last.png"
|
||||
assert tables[0].screen_bbox == {"x": 0, "y": 0, "width": 800, "height": 600}
|
||||
|
||||
fields = ExtractedField.query.filter_by(table_id=tables[0].id).all()
|
||||
assert len(fields) == 4 # 2×2 cellules
|
||||
values = {(f.row, f.col): f.value for f in fields}
|
||||
assert values[(0, 1)] == "MOREL" # valeur patient EN CLAIR conservée
|
||||
assert values[(1, 1)] == "25123456"
|
||||
confs = {(f.row, f.col): f.confidence for f in fields}
|
||||
assert confs[(0, 0)] == pytest.approx(0.95)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_persist_extracted_dossier_empty_grid_still_creates_job(mem_app):
|
||||
"""Grille vide → Job + Table sans Field (statut transmis tel quel)."""
|
||||
job_id = vwb_db.persist_extracted_dossier(
|
||||
[],
|
||||
patient_ref=None,
|
||||
source_session_id="sess-empty",
|
||||
screenshot_ref="/captures/empty.png",
|
||||
screen_bbox=None,
|
||||
status="needs_review",
|
||||
)
|
||||
job = db.session.get(ExtractionJob, job_id)
|
||||
assert job is not None and job.status == "needs_review"
|
||||
tables = ExtractedTable.query.filter_by(job_id=job_id).all()
|
||||
assert len(tables) == 1
|
||||
assert ExtractedField.query.filter_by(table_id=tables[0].id).count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) _handle_extract_dossier_action
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
def test_handle_extract_dossier_complete(mem_app, monkeypatch, tmp_path):
|
||||
# screenshot bidon sur disque (le mock OCR ignore le contenu)
|
||||
shot = tmp_path / "shot.png"
|
||||
shot.write_bytes(b"\x89PNG")
|
||||
|
||||
# extract_grid_from_image mocké → grille 2×2 de confiance haute
|
||||
monkeypatch.setattr(
|
||||
"core.llm.extract_grid_from_image",
|
||||
lambda *a, **k: _grid_2x2(),
|
||||
)
|
||||
# vwb_app_context pointé sur l'app mémoire de la fixture
|
||||
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
|
||||
monkeypatch.setattr(replay_engine, "vwb_db", vwb_db, raising=False)
|
||||
|
||||
replay_state = {
|
||||
"last_screenshot": str(shot),
|
||||
"variables": {},
|
||||
"replay_id": "rep-1",
|
||||
}
|
||||
action = {
|
||||
"type": "extract_dossier",
|
||||
"parameters": {
|
||||
"output_var": "dossier_id",
|
||||
"patient_ref": "MOREL Catherine",
|
||||
"expected_cols": 2,
|
||||
"min_confidence": 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-42")
|
||||
assert ok is True
|
||||
|
||||
job_id = replay_state["variables"]["dossier_id"]
|
||||
assert isinstance(job_id, str) and job_id
|
||||
with mem_app.app_context():
|
||||
job = db.session.get(ExtractionJob, job_id)
|
||||
assert job is not None
|
||||
assert job.status == "complete" # gate OK : non vide, conf ok, 2 cols
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_handle_extract_dossier_low_confidence_needs_review(mem_app, monkeypatch, tmp_path):
|
||||
shot = tmp_path / "shot.png"
|
||||
shot.write_bytes(b"\x89PNG")
|
||||
|
||||
low_grid = [
|
||||
[{"text": "x", "bbox": [], "confidence": 0.10, "row": 0, "col": 0}],
|
||||
]
|
||||
monkeypatch.setattr("core.llm.extract_grid_from_image", lambda *a, **k: low_grid)
|
||||
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
|
||||
|
||||
replay_state = {"last_screenshot": str(shot), "variables": {}, "replay_id": "rep-2"}
|
||||
action = {"type": "extract_dossier", "parameters": {"min_confidence": 0.5}}
|
||||
|
||||
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-low")
|
||||
assert ok is False # gate a basculé en needs_review
|
||||
job_id = replay_state["variables"]["extracted_dossier"]
|
||||
with mem_app.app_context():
|
||||
assert db.session.get(ExtractionJob, job_id).status == "needs_review"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_handle_extract_dossier_empty_grid_no_raise(mem_app, monkeypatch, tmp_path):
|
||||
shot = tmp_path / "shot.png"
|
||||
shot.write_bytes(b"\x89PNG")
|
||||
|
||||
monkeypatch.setattr("core.llm.extract_grid_from_image", lambda *a, **k: [])
|
||||
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
|
||||
|
||||
replay_state = {"last_screenshot": str(shot), "variables": {}, "replay_id": "rep-3"}
|
||||
action = {"type": "extract_dossier", "parameters": {}}
|
||||
|
||||
# Ne lève jamais ; grille vide → needs_review
|
||||
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-empty")
|
||||
assert ok is False
|
||||
job_id = replay_state["variables"]["extracted_dossier"]
|
||||
with mem_app.app_context():
|
||||
assert db.session.get(ExtractionJob, job_id).status == "needs_review"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_handle_extract_dossier_persist_failure_no_raise(mem_app, monkeypatch, tmp_path):
|
||||
"""Si la persistance lève, le handler log et n'échoue PAS le replay."""
|
||||
shot = tmp_path / "shot.png"
|
||||
shot.write_bytes(b"\x89PNG")
|
||||
|
||||
monkeypatch.setattr("core.llm.extract_grid_from_image", lambda *a, **k: _grid_2x2())
|
||||
monkeypatch.setattr(vwb_db, "vwb_app_context", lambda: mem_app.app_context())
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("DB down")
|
||||
monkeypatch.setattr(vwb_db, "persist_extracted_dossier", _boom)
|
||||
|
||||
replay_state = {"last_screenshot": str(shot), "variables": {}, "replay_id": "rep-4"}
|
||||
action = {"type": "extract_dossier", "parameters": {}}
|
||||
|
||||
ok = replay_engine._handle_extract_dossier_action(action, replay_state, "sess-boom")
|
||||
assert ok is False # jamais de raise
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extract_dossier_declared_in_action_type_sets():
|
||||
assert "extract_dossier" in replay_engine._ALLOWED_ACTION_TYPES
|
||||
assert "extract_dossier" in replay_engine._SERVER_SIDE_ACTION_TYPES
|
||||
68
tests/unit/test_extract_dossier_from_image.py
Normal file
68
tests/unit/test_extract_dossier_from_image.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Tests de l'orchestrateur extract_dossier_from_image.
|
||||
|
||||
Enchaîne OCR → tokens_from_grid → map_roles → assess_quality. L'OCR (`ocr_fn`)
|
||||
et le client VLM (`vlm_client`) sont INJECTABLES → testable sans réseau ni OCR
|
||||
réel. C'est cette fonction que le handler runtime `_handle_extract_dossier_action`
|
||||
appellera (avec le vrai OCR et le vrai client vLLM).
|
||||
"""
|
||||
from core.extraction.role_mapper import extract_dossier_from_image
|
||||
|
||||
|
||||
def _cell(text, x0, conf=0.9, row=0, col=0):
|
||||
return {"text": text, "bbox": [[x0, 0], [x0 + 10, 0], [x0 + 10, 8], [x0, 8]],
|
||||
"confidence": conf, "row": row, "col": col}
|
||||
|
||||
|
||||
def _fake_vlm(response):
|
||||
def client(image_path, prompt):
|
||||
return response
|
||||
return client
|
||||
|
||||
|
||||
def test_orchestre_ocr_vlm_qualite():
|
||||
grid = [[_cell("DUPONT", 0, conf=0.95, col=0), _cell("Jean", 20, conf=0.9, col=1)]]
|
||||
res = extract_dossier_from_image(
|
||||
"img.png",
|
||||
_fake_vlm('{"champs":[{"label":"Nom complet","value_ids":[0,1]}]}'),
|
||||
ocr_fn=lambda path: grid,
|
||||
)
|
||||
assert len(res["fields"]) == 1
|
||||
assert res["fields"][0].value == "DUPONT Jean"
|
||||
assert res["fields"][0].anchored is True
|
||||
assert res["status"] in ("complete", "partial", "needs_review", "failed")
|
||||
assert res["n_tokens"] == 2
|
||||
|
||||
|
||||
def test_ocr_vide_donne_failed():
|
||||
res = extract_dossier_from_image(
|
||||
"img.png",
|
||||
_fake_vlm('{"champs":[]}'),
|
||||
ocr_fn=lambda path: [],
|
||||
)
|
||||
assert res["status"] == "failed"
|
||||
assert res["fields"] == []
|
||||
|
||||
|
||||
def test_status_needs_review_si_role_requis_absent():
|
||||
grid = [[_cell("X", 0)]]
|
||||
res = extract_dossier_from_image(
|
||||
"img.png",
|
||||
_fake_vlm('{"champs":[{"label":"Autre","value_ids":[0]}]}'),
|
||||
ocr_fn=lambda path: grid,
|
||||
required_roles=["Nom"],
|
||||
)
|
||||
assert res["status"] == "needs_review"
|
||||
|
||||
|
||||
def test_roles_transmis_au_vlm():
|
||||
grid = [[_cell("X", 0)]]
|
||||
captured = {}
|
||||
|
||||
def client(image_path, prompt):
|
||||
captured["prompt"] = prompt
|
||||
return '{"champs":[]}'
|
||||
|
||||
extract_dossier_from_image(
|
||||
"img.png", client, ocr_fn=lambda path: grid, roles=["Diagnostic", "GEMSA"],
|
||||
)
|
||||
assert "Diagnostic" in captured["prompt"] and "GEMSA" in captured["prompt"]
|
||||
79
tests/unit/test_extract_grid.py
Normal file
79
tests/unit/test_extract_grid.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Tests pour extract_grid_from_image — lecture de tableau STRUCTURÉE.
|
||||
|
||||
Contrairement à extract_table_from_image (qui jette x et retourne une liste
|
||||
plate triée par y), extract_grid_from_image reconstruit une vraie grille
|
||||
List[List[cell]] : clustering des lignes par proximité y, des colonnes par
|
||||
proximité x. bbox + confiance conservées par cellule.
|
||||
|
||||
Les tokens OCR sont injectés (mock du reader EasyOCR) → pas de PNG réel,
|
||||
pas de GPU.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import core.llm.ocr_extractor as ocr_extractor
|
||||
|
||||
|
||||
def _blank_png(path: Path) -> None:
|
||||
Image.new("RGB", (300, 120), "white").save(path)
|
||||
|
||||
|
||||
def _bbox(x0: float, y0: float, x1: float, y1: float):
|
||||
"""bbox EasyOCR = 4 points [tl, tr, br, bl], chaque point (x, y)."""
|
||||
return [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
|
||||
|
||||
|
||||
def _fake_reader(tokens):
|
||||
"""Reader factice : readtext() renvoie la liste (bbox, text, conf) fournie."""
|
||||
return SimpleNamespace(readtext=lambda *a, **k: tokens)
|
||||
|
||||
|
||||
def test_extract_grid_2x3(tmp_path, monkeypatch):
|
||||
image_path = tmp_path / "table.png"
|
||||
_blank_png(image_path)
|
||||
|
||||
# 2 lignes (y≈10 et y≈60) × 3 colonnes (x≈10, x≈110, x≈210).
|
||||
# Volontairement mélangées dans l'ordre OCR pour vérifier le tri.
|
||||
tokens = [
|
||||
(_bbox(110, 58, 160, 78), "B2", 0.97),
|
||||
(_bbox(10, 10, 60, 30), "A1", 0.91),
|
||||
(_bbox(210, 12, 260, 32), "C1", 0.88),
|
||||
(_bbox(210, 60, 260, 80), "C2", 0.95),
|
||||
(_bbox(10, 60, 60, 80), "A2", 0.90),
|
||||
(_bbox(110, 8, 160, 28), "B1", 0.93),
|
||||
]
|
||||
monkeypatch.setattr(ocr_extractor, "_get_reader", lambda: _fake_reader(tokens))
|
||||
|
||||
grid = ocr_extractor.extract_grid_from_image(str(image_path))
|
||||
|
||||
# Grille 2×3 ordonnée
|
||||
assert len(grid) == 2, "doit détecter 2 lignes"
|
||||
assert all(len(row) == 3 for row in grid), "chaque ligne doit avoir 3 colonnes"
|
||||
|
||||
texts = [[cell["text"] for cell in row] for row in grid]
|
||||
assert texts == [["A1", "B1", "C1"], ["A2", "B2", "C2"]]
|
||||
|
||||
# Métadonnées conservées + indices row/col cohérents
|
||||
cell = grid[0][2]
|
||||
assert cell["text"] == "C1"
|
||||
assert cell["confidence"] == 0.88
|
||||
assert cell["bbox"] == _bbox(210, 12, 260, 32)
|
||||
assert cell["row"] == 0
|
||||
assert cell["col"] == 2
|
||||
assert grid[1][0]["row"] == 1 and grid[1][0]["col"] == 0
|
||||
|
||||
|
||||
def test_extract_grid_empty_when_no_tokens(tmp_path, monkeypatch):
|
||||
image_path = tmp_path / "blank.png"
|
||||
_blank_png(image_path)
|
||||
monkeypatch.setattr(ocr_extractor, "_get_reader", lambda: _fake_reader([]))
|
||||
|
||||
grid = ocr_extractor.extract_grid_from_image(str(image_path))
|
||||
assert grid == []
|
||||
|
||||
|
||||
def test_extract_grid_missing_file_returns_empty():
|
||||
grid = ocr_extractor.extract_grid_from_image("/no/such/file.png")
|
||||
assert grid == []
|
||||
406
tests/unit/test_grounding.py
Normal file
406
tests/unit/test_grounding.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""Tests for core/navigation/grounding.py — OCR-anchored grounding + VLM fallback + coords cache."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from core.navigation.grounding import (
|
||||
OcrTokenInfo,
|
||||
GroundedElement,
|
||||
CoordsCacheEntry,
|
||||
CoordsCache,
|
||||
bbox_center,
|
||||
make_element_key,
|
||||
ocr_anchor_ground,
|
||||
build_grounder_prompt,
|
||||
parse_grounder_response,
|
||||
ground_element,
|
||||
)
|
||||
from core.navigation.visual_verifier import normalize_text
|
||||
|
||||
|
||||
# ── Mock factories ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mock_ocr_detailed_client_factory(tokens: list):
|
||||
"""Factory for mock OcrDetailedClient returning List[OcrTokenInfo]."""
|
||||
def client(image_path: str) -> list:
|
||||
return tokens
|
||||
return client
|
||||
|
||||
|
||||
def mock_vlm_client_factory(response_json: dict):
|
||||
"""Factory for mock VlmClient returning given JSON."""
|
||||
def client(image_path: str, prompt: str) -> str:
|
||||
return json.dumps(response_json)
|
||||
return client
|
||||
|
||||
|
||||
# ── bbox_center tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBboxCenter:
|
||||
def test_basic(self):
|
||||
assert bbox_center((100, 200, 300, 400)) == (200, 300)
|
||||
|
||||
def test_zero_origin(self):
|
||||
assert bbox_center((0, 0, 100, 100)) == (50, 50)
|
||||
|
||||
def test_symmetric(self):
|
||||
assert bbox_center((10, 10, 20, 20)) == (15, 15)
|
||||
|
||||
|
||||
# ── make_element_key tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMakeElementKey:
|
||||
def test_basic(self):
|
||||
key = make_element_key("bouton", "Rechercher")
|
||||
assert key == "bouton:rechercher"
|
||||
|
||||
def test_normalized(self):
|
||||
key = make_element_key("champ", "Nom Prénom")
|
||||
assert "nom" in key and "prenom" in key
|
||||
|
||||
def test_consistent(self):
|
||||
# Same element always produces same key
|
||||
assert make_element_key("bouton", "Connexion") == make_element_key("bouton", "CONNEXION")
|
||||
|
||||
|
||||
# ── ocr_anchor_ground tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOcrAnchorGround:
|
||||
def test_exact_match(self):
|
||||
tokens = [OcrTokenInfo(text="Rechercher", bbox=(100, 50, 250, 90), confidence=0.95)]
|
||||
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
|
||||
assert result is not None
|
||||
assert result.method == "ocr_anchor"
|
||||
assert result.bbox == (100, 50, 250, 90)
|
||||
assert result.center == (175, 70)
|
||||
assert result.confidence == 0.95
|
||||
|
||||
def test_fuzzy_match(self):
|
||||
tokens = [OcrTokenInfo(text="Rechércher", bbox=(100, 50, 250, 90))]
|
||||
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
|
||||
assert result is not None
|
||||
assert result.source_ocr_text == "Rechércher"
|
||||
|
||||
def test_no_match(self):
|
||||
tokens = [OcrTokenInfo(text="Accueil", bbox=(100, 50, 250, 90))]
|
||||
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
|
||||
assert result is None
|
||||
|
||||
def test_token_without_bbox(self):
|
||||
tokens = [OcrTokenInfo(text="Rechercher", bbox=None)]
|
||||
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Rechercher"})
|
||||
assert result is None # found text but no bbox → can't ground
|
||||
|
||||
def test_no_text_target(self):
|
||||
tokens = [OcrTokenInfo(text="Dashboard", bbox=(0, 0, 1920, 1080))]
|
||||
result = ocr_anchor_ground(tokens, {"role": "page"}) # no text key
|
||||
assert result is None # no text to match
|
||||
|
||||
def test_multiple_tokens_first_match(self):
|
||||
tokens = [
|
||||
OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40)),
|
||||
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90)),
|
||||
]
|
||||
result = ocr_anchor_ground(tokens, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is not None
|
||||
assert result.bbox == (200, 50, 350, 90)
|
||||
|
||||
|
||||
# ── build_grounder_prompt tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildGrounderPrompt:
|
||||
def test_basic_prompt(self):
|
||||
prompt = build_grounder_prompt({"role": "bouton", "text": "Connexion"})
|
||||
assert "bouton" in prompt
|
||||
assert "Connexion" in prompt
|
||||
assert "bbox" in prompt
|
||||
|
||||
def test_with_context(self):
|
||||
prompt = build_grounder_prompt(
|
||||
{"role": "champ", "text": "Login"},
|
||||
context="page login DPI",
|
||||
)
|
||||
assert "page login DPI" in prompt
|
||||
|
||||
def test_with_extra(self):
|
||||
prompt = build_grounder_prompt(
|
||||
{"role": "champ", "text": "IPP", "extra": "colonne gauche"},
|
||||
)
|
||||
assert "colonne gauche" in prompt
|
||||
|
||||
|
||||
# ── parse_grounder_response tests ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseGrounderResponse:
|
||||
def test_valid_response(self):
|
||||
vlm_text = json.dumps({
|
||||
"found": True,
|
||||
"bbox": [0.1, 0.2, 0.3, 0.4],
|
||||
"confidence": 0.92,
|
||||
"description": "login button",
|
||||
})
|
||||
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is not None
|
||||
assert result.method == "vlm_grounder"
|
||||
assert result.bbox == (192, 216, 576, 432) # 0.1*1920, 0.2*1080, 0.3*1920, 0.4*1080
|
||||
assert result.confidence == 0.92
|
||||
|
||||
def test_not_found(self):
|
||||
vlm_text = json.dumps({"found": False, "bbox": [], "confidence": 0.0})
|
||||
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is None
|
||||
|
||||
def test_json_in_markdown(self):
|
||||
vlm_text = "```json\n{\"found\": true, \"bbox\": [0.5, 0.5, 0.6, 0.6], \"confidence\": 0.8}\n```"
|
||||
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is not None
|
||||
|
||||
def test_garbled_response(self):
|
||||
result = parse_grounder_response("I cannot find the element", 1920, 1080, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is None
|
||||
|
||||
def test_invalid_bbox_format(self):
|
||||
vlm_text = json.dumps({"found": True, "bbox": [0.1, 0.2], "confidence": 0.8})
|
||||
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is None # bbox must have 4 values
|
||||
|
||||
def test_confidence_as_string(self):
|
||||
vlm_text = json.dumps({"found": True, "bbox": [0.1, 0.2, 0.3, 0.4], "confidence": "0.85"})
|
||||
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is not None
|
||||
assert result.confidence == 0.85
|
||||
|
||||
def test_bbox_clamped_to_screen(self):
|
||||
vlm_text = json.dumps({"found": True, "bbox": [-0.1, -0.1, 1.5, 1.5], "confidence": 0.7})
|
||||
result = parse_grounder_response(vlm_text, 1920, 1080, {"role": "bouton", "text": "Connexion"})
|
||||
assert result is not None
|
||||
assert result.bbox[0] >= 0
|
||||
assert result.bbox[1] >= 0
|
||||
assert result.bbox[2] <= 1920
|
||||
assert result.bbox[3] <= 1080
|
||||
|
||||
|
||||
# ── ground_element (composition) tests ─────────────────────────────────
|
||||
|
||||
|
||||
class TestGroundElement:
|
||||
def test_ocr_anchor_success(self):
|
||||
"""OCR finds text with bbox → grounded via OCR (deterministic)."""
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90), confidence=0.95),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.method == "ocr_anchor"
|
||||
assert result.bbox == (200, 50, 350, 90)
|
||||
|
||||
def test_vlm_fallback(self):
|
||||
"""OCR doesn't find text → VLM grounder succeeds."""
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40)),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"found": True,
|
||||
"bbox": [0.2, 0.3, 0.4, 0.5],
|
||||
"confidence": 0.85,
|
||||
})
|
||||
result = ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.method == "vlm_grounder"
|
||||
|
||||
def test_not_found_any_method(self):
|
||||
"""Both OCR and VLM fail → None."""
|
||||
ocr = mock_ocr_detailed_client_factory([OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40))])
|
||||
vlm = mock_vlm_client_factory({"found": False, "bbox": [], "confidence": 0.0})
|
||||
result = ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_ocr_error_vlm_fallback(self):
|
||||
"""OCR engine fails → VLM fallback."""
|
||||
def failing_ocr(image_path):
|
||||
raise RuntimeError("OCR engine down")
|
||||
vlm = mock_vlm_client_factory({
|
||||
"found": True,
|
||||
"bbox": [0.2, 0.3, 0.4, 0.5],
|
||||
"confidence": 0.8,
|
||||
})
|
||||
result = ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=failing_ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.method == "vlm_grounder"
|
||||
|
||||
def test_vlm_error_ocr_success(self):
|
||||
"""VLM fails but OCR succeeds → OCR anchor used."""
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90)),
|
||||
])
|
||||
def failing_vlm(image_path, prompt):
|
||||
raise RuntimeError("VLM down")
|
||||
result = ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=failing_vlm,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.method == "ocr_anchor"
|
||||
|
||||
def test_both_fail(self):
|
||||
"""OCR + VLM both fail → None."""
|
||||
def failing_ocr(image_path):
|
||||
raise RuntimeError("OCR down")
|
||||
def failing_vlm(image_path, prompt):
|
||||
raise RuntimeError("VLM down")
|
||||
result = ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=failing_ocr,
|
||||
vlm_client=failing_vlm,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_text_target(self):
|
||||
"""Target without text → VLM grounder skipped, None."""
|
||||
ocr = mock_ocr_detailed_client_factory([])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = ground_element(
|
||||
"/tmp/page.png",
|
||||
{"role": "page"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_cache_hit(self):
|
||||
"""Cached coords exist → returned directly."""
|
||||
cache = CoordsCache()
|
||||
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
|
||||
|
||||
ocr = mock_ocr_detailed_client_factory([])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
coords_cache=cache,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.method == "cache"
|
||||
assert result.bbox == (200, 50, 350, 90)
|
||||
|
||||
def test_cache_stored_on_ocr_anchor(self):
|
||||
"""OCR anchor result → stored in cache."""
|
||||
cache = CoordsCache()
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Connexion", bbox=(200, 50, 350, 90)),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
coords_cache=cache,
|
||||
)
|
||||
cached = cache.get("bouton:connexion")
|
||||
assert cached is not None
|
||||
assert cached.bbox == (200, 50, 350, 90)
|
||||
assert cached.method == "ocr_anchor"
|
||||
|
||||
def test_cache_stored_on_vlm_grounder(self):
|
||||
"""VLM grounder result → stored in cache."""
|
||||
cache = CoordsCache()
|
||||
ocr = mock_ocr_detailed_client_factory([])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"found": True,
|
||||
"bbox": [0.2, 0.3, 0.4, 0.5],
|
||||
"confidence": 0.85,
|
||||
})
|
||||
ground_element(
|
||||
"/tmp/login.png",
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
coords_cache=cache,
|
||||
)
|
||||
cached = cache.get("bouton:connexion")
|
||||
assert cached is not None
|
||||
assert cached.method == "vlm_grounder"
|
||||
|
||||
|
||||
# ── CoordsCache tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCoordsCache:
|
||||
def test_put_and_get(self):
|
||||
cache = CoordsCache()
|
||||
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
|
||||
entry = cache.get("bouton:connexion")
|
||||
assert entry is not None
|
||||
assert entry.bbox == (200, 50, 350, 90)
|
||||
|
||||
def test_get_missing(self):
|
||||
cache = CoordsCache()
|
||||
assert cache.get("bouton:connexion") is None
|
||||
|
||||
def test_invalidate(self):
|
||||
cache = CoordsCache()
|
||||
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
|
||||
cache.invalidate("bouton:connexion")
|
||||
assert cache.get("bouton:connexion") is None
|
||||
|
||||
def test_clear(self):
|
||||
cache = CoordsCache()
|
||||
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
|
||||
cache.put("b", (0, 0, 20, 20), (10, 10), "vlm_grounder")
|
||||
cache.clear()
|
||||
assert cache.get("a") is None
|
||||
assert cache.get("b") is None
|
||||
|
||||
def test_keys(self):
|
||||
cache = CoordsCache()
|
||||
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
|
||||
cache.put("b", (0, 0, 20, 20), (10, 10), "vlm_grounder")
|
||||
assert sorted(cache.keys()) == ["a", "b"]
|
||||
|
||||
def test_update_existing(self):
|
||||
cache = CoordsCache()
|
||||
cache.put("bouton:connexion", (200, 50, 350, 90), (275, 70), "ocr_anchor")
|
||||
cache.put("bouton:connexion", (300, 60, 400, 100), (350, 80), "vlm_grounder")
|
||||
entry = cache.get("bouton:connexion")
|
||||
assert entry is not None
|
||||
assert entry.bbox == (300, 60, 400, 100) # updated
|
||||
assert entry.validation_count == 2
|
||||
|
||||
def test_validation_count_increments(self):
|
||||
cache = CoordsCache()
|
||||
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
|
||||
assert cache.get("a").validation_count == 1
|
||||
cache.put("a", (0, 0, 10, 10), (5, 5), "ocr_anchor")
|
||||
assert cache.get("a").validation_count == 2
|
||||
73
tests/unit/test_log_safe.py
Normal file
73
tests/unit/test_log_safe.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests unitaires des helpers de logging PII-safe du client Léa (agent_v1).
|
||||
|
||||
Assainissement des logs à la source : on ne logge jamais le contenu brut
|
||||
(titres de fenêtre, noms de workflow, chemins, métadonnées sensibles). On le
|
||||
remplace par un hash court stable, une longueur, ou un dict filtré.
|
||||
|
||||
Branche feat/push-log-dgx — DETTE-020 (assainissement PII des logs, brique 4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
_HEX8 = re.compile(r"^[0-9a-f]{8}$")
|
||||
|
||||
|
||||
def test_title_hash_is_short_stable_hex():
|
||||
from agent_v0.agent_v1.core.log_safe import _title_hash
|
||||
|
||||
h = _title_hash("Dossier MOREL Catherine")
|
||||
assert _HEX8.match(h), f"attendu 8 hex, obtenu {h!r}"
|
||||
assert h == _title_hash("Dossier MOREL Catherine") # déterministe
|
||||
|
||||
|
||||
def test_title_hash_never_reveals_raw_title():
|
||||
"""Propriété PII centrale : le hash ne contient jamais le contenu brut."""
|
||||
from agent_v0.agent_v1.core.log_safe import _title_hash
|
||||
|
||||
title = "Dossier MOREL Catherine"
|
||||
h = _title_hash(title)
|
||||
assert title not in h
|
||||
assert "MOREL" not in h
|
||||
|
||||
|
||||
def test_title_hash_distinguishes_different_titles():
|
||||
from agent_v0.agent_v1.core.log_safe import _title_hash
|
||||
|
||||
assert _title_hash("popup A") != _title_hash("popup B")
|
||||
|
||||
|
||||
def test_title_hash_handles_empty_and_non_ascii():
|
||||
from agent_v0.agent_v1.core.log_safe import _title_hash
|
||||
|
||||
assert _HEX8.match(_title_hash(""))
|
||||
assert _HEX8.match(_title_hash("Éléonore — café ☕"))
|
||||
|
||||
|
||||
def test_sanitize_metadata_drops_pii_keys_keeps_technical():
|
||||
from agent_v0.agent_v1.core.log_safe import _sanitize_metadata
|
||||
|
||||
meta = {
|
||||
"resolution": "1920x1080", "dpi": 96, "theme": "dark",
|
||||
"title": "Dossier Dupont", "active_window": "Medicare", "window_title": "x",
|
||||
}
|
||||
safe = _sanitize_metadata(meta)
|
||||
|
||||
assert safe == {"resolution": "1920x1080", "dpi": 96, "theme": "dark"}
|
||||
assert meta.get("title") == "Dossier Dupont" # original non muté
|
||||
|
||||
|
||||
def test_path_ext_returns_extension_only():
|
||||
from agent_v0.agent_v1.core.log_safe import _path_ext
|
||||
|
||||
assert _path_ext("/home/tim/Dossier Dupont 1980.png") == ".png"
|
||||
assert "Dupont" not in _path_ext("/x/Dupont.png")
|
||||
assert _path_ext("") == ""
|
||||
assert _path_ext("/no/ext/here") == ""
|
||||
151
tests/unit/test_navigate_handler_e2e.py
Normal file
151
tests/unit/test_navigate_handler_e2e.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""End-to-end mocked test for navigate action handler — 3 edge-case scenarios.
|
||||
|
||||
Tests the _handle_navigate_action handler with mocked OCR/VLM, verifying:
|
||||
- Nominal: all resolved, coords populated in variables
|
||||
- OCR miss + VLM fail: no phantom coords, all_resolved=False
|
||||
- No screenshot: error="no_screenshot", False return
|
||||
|
||||
NOTE: The handler uses lazy imports inside its body. Mock targets must be
|
||||
at the source module (core.navigation.action_resolver.navigate_login) rather
|
||||
than the package-level re-export (core.navigation.navigate_login).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from core.navigation.action_resolver import NavigateCoords, NavigateResult
|
||||
from core.navigation import _handle_navigate_action
|
||||
|
||||
|
||||
def _patch_all_deps(navigate_login_result=None, navigate_login_side_effect=None):
|
||||
"""Return stacked patches for handler's lazy imports + navigate_login."""
|
||||
nl_mock = MagicMock(return_value=navigate_login_result) if navigate_login_result else None
|
||||
if navigate_login_side_effect:
|
||||
nl_mock = MagicMock(side_effect=navigate_login_side_effect)
|
||||
|
||||
return (
|
||||
patch("core.llm.extract_grid_from_image", return_value=[]),
|
||||
patch("core.extraction.vlm_client.make_vllm_client", return_value=MagicMock()),
|
||||
patch("core.navigation.action_resolver.make_ocr_detailed_from_grid",
|
||||
return_value=MagicMock(return_value=[])),
|
||||
patch("core.navigation.action_resolver.navigate_login", nl_mock),
|
||||
)
|
||||
|
||||
|
||||
class TestNominalCase:
|
||||
"""All fields grounded → coords populated, all_resolved=True."""
|
||||
|
||||
def test_nominal_coords_populated(self):
|
||||
mock_result = NavigateResult(
|
||||
login_coords=NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor"),
|
||||
password_coords=NavigateCoords(x_pct=0.15, y_pct=0.25, method="ocr_anchor"),
|
||||
submit_coords=NavigateCoords(x_pct=0.50, y_pct=0.35, method="ocr_anchor"),
|
||||
all_resolved=True,
|
||||
)
|
||||
|
||||
action = {"parameters": {"action": "login"}}
|
||||
replay_state = {
|
||||
"last_screenshot_path": "/tmp/login_screen.png",
|
||||
"screen_width": 1920,
|
||||
"screen_height": 1080,
|
||||
}
|
||||
|
||||
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
|
||||
with p1, p2, p3, p4:
|
||||
result = _handle_navigate_action(action, replay_state, "test-session")
|
||||
|
||||
assert result is True
|
||||
vars_ = replay_state["variables"]
|
||||
assert "navigate_login_coords" in vars_
|
||||
assert vars_["navigate_login_coords"]["x_pct"] == 0.15
|
||||
assert "navigate_password_coords" in vars_
|
||||
assert "navigate_submit_coords" in vars_
|
||||
assert vars_["navigate_result"]["all_resolved"] is True
|
||||
|
||||
|
||||
class TestOcrMissVlmFail:
|
||||
"""OCR misses target + VLM grounder also fails → no phantom coords."""
|
||||
|
||||
def test_no_phantom_coords_on_failure(self):
|
||||
mock_result = NavigateResult(
|
||||
login_coords=None,
|
||||
password_coords=None,
|
||||
submit_coords=None,
|
||||
all_resolved=False,
|
||||
error="grounding failed — no login form elements found",
|
||||
)
|
||||
|
||||
action = {"parameters": {"action": "login"}}
|
||||
replay_state = {
|
||||
"last_screenshot_path": "/tmp/no_login_form.png",
|
||||
"screen_width": 1920,
|
||||
"screen_height": 1080,
|
||||
}
|
||||
|
||||
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
|
||||
with p1, p2, p3, p4:
|
||||
result = _handle_navigate_action(action, replay_state, "test-session")
|
||||
|
||||
assert result is False
|
||||
vars_ = replay_state["variables"]
|
||||
# No coords keys should be present (coords are None → not stored)
|
||||
assert "navigate_login_coords" not in vars_
|
||||
assert "navigate_password_coords" not in vars_
|
||||
assert "navigate_submit_coords" not in vars_
|
||||
# Error must be non-empty
|
||||
assert vars_["navigate_result"]["all_resolved"] is False
|
||||
assert "grounding failed" in vars_["navigate_result"]["error"]
|
||||
|
||||
|
||||
class TestNoScreenshot:
|
||||
"""No screenshot in replay_state → error="no_screenshot", False."""
|
||||
|
||||
def test_no_screenshot_error(self):
|
||||
action = {"parameters": {"action": "login"}}
|
||||
replay_state = {} # No screenshot at all
|
||||
|
||||
result = _handle_navigate_action(action, replay_state, "test-session")
|
||||
|
||||
assert result is False
|
||||
vars_ = replay_state["variables"]
|
||||
assert vars_["navigate_login_coords"]["error"] == "no_screenshot"
|
||||
|
||||
def test_empty_screenshot_path(self):
|
||||
action = {"parameters": {"action": "login"}}
|
||||
replay_state = {"last_screenshot_path": ""}
|
||||
|
||||
result = _handle_navigate_action(action, replay_state, "test-session")
|
||||
|
||||
assert result is False
|
||||
vars_ = replay_state["variables"]
|
||||
assert vars_["navigate_login_coords"]["error"] == "no_screenshot"
|
||||
|
||||
|
||||
class TestNeverFailReplay:
|
||||
"""Handler must never raise — even on malformed input, returns False."""
|
||||
|
||||
def test_missing_parameters(self):
|
||||
action = {} # No "parameters" key
|
||||
replay_state = {"last_screenshot_path": "/tmp/x.png"}
|
||||
|
||||
mock_result = NavigateResult(all_resolved=False, error="no params")
|
||||
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
|
||||
with p1, p2, p3, p4:
|
||||
result = _handle_navigate_action(action, replay_state, "test-session")
|
||||
assert result is False
|
||||
|
||||
def test_exception_in_inner_call(self):
|
||||
action = {"parameters": {"action": "login"}}
|
||||
replay_state = {
|
||||
"last_screenshot_path": "/tmp/login.png",
|
||||
"screen_width": 1920,
|
||||
"screen_height": 1080,
|
||||
}
|
||||
|
||||
p1, p2, p3, p4 = _patch_all_deps(navigate_login_side_effect=RuntimeError("boom"))
|
||||
with p1, p2, p3, p4:
|
||||
result = _handle_navigate_action(action, replay_state, "test-session")
|
||||
|
||||
assert result is False
|
||||
vars_ = replay_state["variables"]
|
||||
assert vars_["navigate_result"]["all_resolved"] is False
|
||||
assert "boom" in vars_["navigate_result"]["error"]
|
||||
62
tests/unit/test_navigate_wiring.py
Normal file
62
tests/unit/test_navigate_wiring.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Boot non-regression test for navigate wiring — catches import/regression bugs.
|
||||
|
||||
This test would have caught the ImportError where _handle_navigate_action
|
||||
was incorrectly imported from replay_engine instead of core/navigation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestApiStreamImports:
|
||||
"""(1) api_stream must import without error."""
|
||||
|
||||
def test_import_api_stream(self):
|
||||
from agent_v0.server_v1 import api_stream
|
||||
assert api_stream is not None
|
||||
|
||||
|
||||
class TestAllowedActionTypes:
|
||||
"""(2) 'navigate' must be in both _ALLOWED and _SERVER_SIDE."""
|
||||
|
||||
def test_navigate_in_allowed(self):
|
||||
from agent_v0.server_v1.replay_engine import _ALLOWED_ACTION_TYPES
|
||||
assert "navigate" in _ALLOWED_ACTION_TYPES
|
||||
|
||||
def test_navigate_in_server_side(self):
|
||||
from agent_v0.server_v1.replay_engine import _SERVER_SIDE_ACTION_TYPES
|
||||
assert "navigate" in _SERVER_SIDE_ACTION_TYPES
|
||||
|
||||
|
||||
class TestNavigateHandlerCallable:
|
||||
"""(3) _handle_navigate_action must be callable with correct signature."""
|
||||
|
||||
def test_handler_imported_from_core_navigation(self):
|
||||
from core.navigation import _handle_navigate_action
|
||||
assert callable(_handle_navigate_action)
|
||||
|
||||
def test_handler_imported_in_api_stream(self):
|
||||
from agent_v0.server_v1 import api_stream
|
||||
handler = api_stream._handle_navigate_action
|
||||
assert callable(handler)
|
||||
|
||||
def test_handler_signature(self):
|
||||
"""Signature: (action: dict, replay_state: dict, session_id: str) -> bool."""
|
||||
from core.navigation import _handle_navigate_action
|
||||
import inspect
|
||||
sig = inspect.signature(_handle_navigate_action)
|
||||
params = list(sig.parameters.keys())
|
||||
assert params == ["action", "replay_state", "session_id"]
|
||||
assert sig.return_annotation == bool
|
||||
|
||||
|
||||
class TestDispatchBlockExists:
|
||||
"""Verify the navigate dispatch block is wired in api_stream."""
|
||||
|
||||
def test_navigate_dispatch_reference(self):
|
||||
"""Source must contain the navigate dispatch elif block."""
|
||||
import agent_v0.server_v1.api_stream as mod
|
||||
source = inspect.getsource(mod)
|
||||
assert "type_ == \"navigate\"" in source
|
||||
|
||||
|
||||
import inspect
|
||||
236
tests/unit/test_pii_sanitizer.py
Normal file
236
tests/unit/test_pii_sanitizer.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Tests de l'assainissement PII des données capturées (titres, texte, OCR).
|
||||
|
||||
Couche 1 (sans modèle) : filet regex sur la PII structurée (IPP, NIR, TEL,
|
||||
EMAIL, AGE) + règles structurelles cliniques (NOM (NAISSANCE) Prénom ;
|
||||
[Nom Prénom] des fenêtres PACS), avec tokens TYPÉS et COHÉRENTS ([IPP_1]…).
|
||||
|
||||
Réutilise l'approche du projet `anonymisation` (placeholders + regex). La
|
||||
couche NER (noms libres) viendra en complément. Cas réels remontés en clinique
|
||||
le 28/06 (anonymisés ici par construction). Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
def test_ipp_et_age_tokenises():
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
titre = "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Expert Sante - Mozilla Firefox"
|
||||
out, ents = anonymize_text(titre)
|
||||
|
||||
assert "168246" not in out, out # IPP retiré
|
||||
assert "[IPP_1]" in out
|
||||
assert "90 ans" not in out # âge retiré
|
||||
assert "[AGE_1]" in out
|
||||
# le nom format clinique « NOM (NAISSANCE) Prénom » est tokenisé
|
||||
assert "VIOLA" not in out and "Liliane" not in out, out
|
||||
assert "[NOM_1]" in out
|
||||
# le logiciel n'est pas pris pour de la PII
|
||||
assert "Firefox" in out and "Expert Sante" in out
|
||||
types = {e["type"] for e in ents}
|
||||
assert {"IPP", "AGE", "NOM"} <= types
|
||||
|
||||
|
||||
def test_nom_entre_crochets_pacs():
|
||||
"""Le PACS met le patient entre crochets : `[DATTIN Alix]`."""
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
titre = "GXD5 Pacs 4.0.4.307 CIM ARES - [DATTIN Alix] - Mozilla Firefox"
|
||||
out, _ = anonymize_text(titre)
|
||||
|
||||
assert "DATTIN" not in out and "Alix" not in out, out
|
||||
assert "[NOM_1]" in out
|
||||
assert "Pacs" in out and "Firefox" in out # contexte logiciel préservé
|
||||
|
||||
|
||||
def test_coherence_meme_ipp_meme_token():
|
||||
"""Même valeur PII -> même token (sur un mapping partagé de session)."""
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
mapping: dict = {}
|
||||
o1, _ = anonymize_text("IPP: 168246 ouvert", mapping=mapping)
|
||||
o2, _ = anonymize_text("dossier IPP: 168246 fermé", mapping=mapping)
|
||||
o3, _ = anonymize_text("IPP: 270020 autre", mapping=mapping)
|
||||
|
||||
assert "[IPP_1]" in o1 and "[IPP_1]" in o2 # même patient -> même token
|
||||
assert "[IPP_2]" in o3 # patient différent -> token différent
|
||||
assert "270020" not in o3
|
||||
|
||||
|
||||
def test_email_et_telephone():
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
out, _ = anonymize_text("contact j.dupont@chu.fr / 06 12 34 56 78")
|
||||
assert "@chu.fr" not in out and "[EMAIL_1]" in out
|
||||
assert "06 12 34 56 78" not in out and "[TEL_1]" in out
|
||||
|
||||
|
||||
def test_texte_sans_pii_inchange():
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
t = "Expert Sante - Consultation - Mozilla Firefox"
|
||||
out, ents = anonymize_text(t)
|
||||
assert out == t
|
||||
assert ents == []
|
||||
|
||||
|
||||
# --- sanitize_event : assainissement au niveau event (option b pour text_input) ---
|
||||
|
||||
def test_sanitize_text_input_remplace_contenu_par_saisie():
|
||||
"""Option b (Dom) : le contenu tapé n'est pas gardé -> [SAISIE]."""
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_event
|
||||
|
||||
ev = {
|
||||
"type": "text_input",
|
||||
"text": "hemorragie post-operatoire saignement", # contenu médical
|
||||
"raw_keys": ["h", "e", "m"],
|
||||
"window": {"title": "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Firefox",
|
||||
"app_name": "firefox.exe"},
|
||||
}
|
||||
out = sanitize_event(ev)
|
||||
|
||||
assert out["text"] == "[SAISIE]"
|
||||
assert out["raw_keys"] == "[SAISIE]"
|
||||
# le titre de la fenêtre est assaini (identité tokenisée, app gardée)
|
||||
assert "168246" not in out["window"]["title"]
|
||||
assert "VIOLA" not in out["window"]["title"]
|
||||
assert "[IPP_1]" in out["window"]["title"] and "Firefox" in out["window"]["title"]
|
||||
# l'event d'origine n'est PAS muté
|
||||
assert ev["text"].startswith("hemorragie")
|
||||
|
||||
|
||||
def test_sanitize_heartbeat_titre_direct():
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_event
|
||||
|
||||
ev = {"type": "heartbeat",
|
||||
"active_window_title": "GXD5 Pacs CIM ARES - [DATTIN Alix] - Firefox"}
|
||||
out = sanitize_event(ev)
|
||||
assert "DATTIN" not in out["active_window_title"]
|
||||
assert "[NOM_1]" in out["active_window_title"] and "Pacs" in out["active_window_title"]
|
||||
|
||||
|
||||
def test_sanitize_focus_change_to_from_window():
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_event
|
||||
|
||||
ev = {"type": "window_focus_change",
|
||||
"from": None,
|
||||
"to": {"title": "LAVAL (BARTHELEMY) Nicole 86 ans - Expert Sante", "app_name": "firefox.exe"},
|
||||
"window": {"title": "LAVAL (BARTHELEMY) Nicole 86 ans - Expert Sante"}}
|
||||
out = sanitize_event(ev)
|
||||
assert out["from"] is None # null géré
|
||||
assert "LAVAL" not in out["to"]["title"]
|
||||
assert "[NOM_1]" in out["to"]["title"]
|
||||
# cohérence : même patient dans to et window -> même token
|
||||
assert out["window"]["title"] == out["to"]["title"]
|
||||
|
||||
|
||||
def test_sanitize_action_result_inchange():
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_event
|
||||
|
||||
ev = {"type": "action_result", "base_shot_id": "shot_0003", "image": "x.png"}
|
||||
assert sanitize_event(ev) == ev
|
||||
|
||||
|
||||
def test_prenom_nom_inverse():
|
||||
"""FN-1/2/3 (Qwen) : « Prénom NOM » inversé (sans parens/crochets)."""
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
m: dict = {}
|
||||
for s, leak in [("Alix DATTIN - Mozilla Firefox", "DATTIN"),
|
||||
("Agathe RONDOT - PACS CIM ARES", "RONDOT"),
|
||||
("Marie FLANDINETTE - Mozilla Firefox", "FLANDINETTE")]:
|
||||
out, _ = anonymize_text(s, mapping=m)
|
||||
assert leak not in out, out
|
||||
assert "[NOM_" in out
|
||||
# pas de faux positif sur les logiciels (2e mot non capitalisé tout en majuscules)
|
||||
out, ents = anonymize_text("Mozilla Firefox - Expert Sante - Consultation")
|
||||
assert out == "Mozilla Firefox - Expert Sante - Consultation"
|
||||
assert ents == []
|
||||
|
||||
|
||||
def test_sanitize_event_titre_imbrique_vision_info():
|
||||
"""FN-4 (Qwen) : titre PII imbriqué dans vision_info.window_capture (228 events)."""
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_event
|
||||
|
||||
titre = "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Firefox"
|
||||
ev = {
|
||||
"type": "mouse_click",
|
||||
"window": {"title": titre, "app_name": "firefox.exe"},
|
||||
"vision_info": {"window_capture": {"window_title": titre, "app_name": "firefox.exe"}},
|
||||
}
|
||||
out = sanitize_event(ev)
|
||||
|
||||
wc = out["vision_info"]["window_capture"]["window_title"]
|
||||
assert "168246" not in wc and "VIOLA" not in wc, wc
|
||||
assert "[IPP_1]" in wc
|
||||
# cohérence : même titre dans window et vision_info -> même token
|
||||
assert out["window"]["title"] == wc
|
||||
|
||||
|
||||
def test_sanitize_workflow_dict_tokenise_by_text_garde_ui():
|
||||
"""R1/PII : un workflow appris ne doit pas porter de PII brute dans ses cibles
|
||||
(by_text) ni ses noms avant import en DB VWB ; l'interface est préservée."""
|
||||
import json
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_workflow_dict
|
||||
|
||||
wf = {
|
||||
"name": "Dossier patient",
|
||||
"nodes": [{"node_id": "n1", "name": "VIOLA (VIOLA) Liliane 90 ans"}],
|
||||
"edges": [{
|
||||
"edge_id": "e1",
|
||||
"action": {
|
||||
"type": "mouse_click",
|
||||
"target": {"by_text": "Valider", "by_role": "ocr"},
|
||||
},
|
||||
}],
|
||||
}
|
||||
out = sanitize_workflow_dict(wf)
|
||||
s = json.dumps(out, ensure_ascii=False)
|
||||
assert "VIOLA" not in s # nom clinique tokenisé (dans un node name)
|
||||
assert "[NOM_1]" in s
|
||||
assert "90 ans" not in s # âge tokenisé
|
||||
assert "Valider" in s # cible UI préservée (by_text)
|
||||
assert "VIOLA" in json.dumps(wf, ensure_ascii=False) # original non muté
|
||||
|
||||
|
||||
def test_chevauchement_prefix_capitalise():
|
||||
"""FN bloquant (Claude R1) : mot capitalisé avant NOM (NAISSANCE) Prénom
|
||||
-> RE_PRENOM_NOM captait « Dossier VIOLA » et bloquait RE_NOM_NAISSANCE
|
||||
« VIOLA (VIOLA) Liliane ». Fix : résolution par priorité détecteur + longueur."""
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
m: dict = {}
|
||||
for titre, leak in [("Dossier VIOLA (VIOLA) Liliane", "VIOLA"),
|
||||
("Patient ROSSIGNOL (SOUBIE) Pierrette", "ROSSIGNOL"),
|
||||
("Fenetre LAVAL (BARTHELEMY) Nicole", "LAVAL")]:
|
||||
out, _ = anonymize_text(titre, mapping=m)
|
||||
assert leak not in out, f"FN: {leak} still visible in '{out}'"
|
||||
|
||||
# contrôle : sans préfixe, toujours OK
|
||||
out, _ = anonymize_text("VIOLA (VIOLA) Liliane", mapping=m)
|
||||
assert "VIOLA" not in out
|
||||
|
||||
|
||||
def test_gxd5_diagnostics_numero_et_nom():
|
||||
"""GXD5 Diagnostics — numéro de dossier + nom tout-majuscules (3 patients prod)."""
|
||||
from agent_v0.server_v1.pii_sanitizer import anonymize_text
|
||||
|
||||
m: dict = {}
|
||||
for titre, num_leak, nom_leak in [
|
||||
("GXD5 Diagnostics - 128008 - BENVENISTE MARIE-LAURENCE", "128008", "BENVENISTE"),
|
||||
("GXD5 Diagnostics - 272223 - LEMOINE ERIC", "272223", "LEMOINE"),
|
||||
("GXD5 Diagnostics - 153442 - ROSELIER MATHEO", "153442", "ROSELIER"),
|
||||
]:
|
||||
out, ents = anonymize_text(titre, mapping=m)
|
||||
assert num_leak not in out, f"FN: numéro {num_leak} visible dans '{out}'"
|
||||
assert nom_leak not in out, f"FN: nom {nom_leak} visible dans '{out}'"
|
||||
types = {e["type"] for e in ents}
|
||||
assert "DOSSIER" in types, f"Pas de token DOSSIER dans {ents}"
|
||||
assert "NOM" in types, f"Pas de token NOM dans {ents}"
|
||||
75
tests/unit/test_resolve_lea_zip_template.py
Normal file
75
tests/unit/test_resolve_lea_zip_template.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Tests unitaires pour _resolve_lea_zip_template (DETTE-024).
|
||||
|
||||
La fonction est injectable (full_path, legacy_path en paramètres)
|
||||
→ testable sans instancier Flask ni lire le vrai deploy/.
|
||||
|
||||
Pattern anti-DETTE-013 : os.environ.setdefault avant l'import du module.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DASHBOARD_AUTH_DISABLED", "true")
|
||||
|
||||
import pytest # noqa: E402
|
||||
from web_dashboard.app import _resolve_lea_zip_template # noqa: E402
|
||||
|
||||
|
||||
class TestResolveLéaZipTemplate:
|
||||
"""DETTE-024 — sélection du ZIP template pour le download fleet."""
|
||||
|
||||
def test_full_present_retourne_full(self, tmp_path):
|
||||
"""Si le ZIP complet autoportant est présent, il est retourné."""
|
||||
full = tmp_path / "Lea_full_v1.0.1.zip"
|
||||
legacy = tmp_path / "Lea_v1.0.0.zip"
|
||||
full.write_bytes(b"full-stub")
|
||||
legacy.write_bytes(b"legacy-stub")
|
||||
|
||||
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
|
||||
|
||||
assert result == full, f"Attendu full ({full}), obtenu {result}"
|
||||
|
||||
def test_full_absent_retourne_legacy_avec_warning(self, tmp_path, caplog):
|
||||
"""Si le ZIP complet est absent, le legacy est retourné + WARNING loggué.
|
||||
|
||||
Le WARNING est le signal observable en production (DETTE-024) :
|
||||
sans lui, le fallback silencieux rendait le problème invisible.
|
||||
"""
|
||||
import logging
|
||||
|
||||
full = tmp_path / "Lea_full_v1.0.1.zip"
|
||||
legacy = tmp_path / "Lea_v1.0.0.zip"
|
||||
# full intentionnellement absent
|
||||
legacy.write_bytes(b"legacy-stub")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
|
||||
|
||||
assert result == legacy, f"Attendu legacy ({legacy}), obtenu {result}"
|
||||
# Le WARNING DETTE-024 doit apparaître dans les logs
|
||||
assert any(
|
||||
"DETTE-024" in record.message for record in caplog.records
|
||||
), (
|
||||
"Un WARNING DETTE-024 doit être émis quand le ZIP complet est absent "
|
||||
f"(logs: {[r.message for r in caplog.records]})"
|
||||
)
|
||||
|
||||
def test_full_et_legacy_absents_retourne_none(self, tmp_path):
|
||||
"""Si aucun ZIP n'existe, retourne None (la route renvoie 500)."""
|
||||
full = tmp_path / "Lea_full_v1.0.1.zip"
|
||||
legacy = tmp_path / "Lea_v1.0.0.zip"
|
||||
# aucun des deux créés
|
||||
|
||||
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
|
||||
|
||||
assert result is None, f"Attendu None, obtenu {result}"
|
||||
|
||||
def test_full_prime_sur_legacy(self, tmp_path):
|
||||
"""Le full est retourné même si le legacy existe aussi (priorité correcte)."""
|
||||
full = tmp_path / "Lea_full_v1.0.1.zip"
|
||||
legacy = tmp_path / "Lea_v1.0.0.zip"
|
||||
full.write_bytes(b"full-stub")
|
||||
legacy.write_bytes(b"legacy-stub")
|
||||
|
||||
result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy)
|
||||
|
||||
assert result == full
|
||||
assert result != legacy
|
||||
296
tests/unit/test_role_mapper.py
Normal file
296
tests/unit/test_role_mapper.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Tests du role_mapper : reconstruction de champs ANCRÉS sur l'OCR.
|
||||
|
||||
Principe cardinal (cf. gate vert 30/06) : le VLM ne fournit QUE des ids de tokens OCR
|
||||
(value_ids) ; la valeur est reconstruite côté Python depuis l'OCR. Aucun texte produit
|
||||
par le VLM ne doit pouvoir entrer dans une valeur -> 0 hallucination par construction.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from core.extraction.role_mapper import (
|
||||
MappedField,
|
||||
OcrToken,
|
||||
assess_quality,
|
||||
build_role_prompt,
|
||||
map_roles,
|
||||
reconstruct_fields,
|
||||
tokens_from_grid,
|
||||
)
|
||||
|
||||
|
||||
def _tok(tid, text, conf=0.9, bbox=(0, 0, 10, 10)):
|
||||
return OcrToken(id=tid, text=text, confidence=conf, bbox=bbox)
|
||||
|
||||
|
||||
def test_reconstruit_value_concatene_tokens_dans_lordre():
|
||||
tokens = [_tok(0, "DUPONT"), _tok(1, "Jean")]
|
||||
fields = reconstruct_fields(tokens, [{"label": "Nom complet", "value_ids": [0, 1]}])
|
||||
assert len(fields) == 1
|
||||
assert fields[0].label == "Nom complet"
|
||||
assert fields[0].value == "DUPONT Jean"
|
||||
assert fields[0].anchored is True
|
||||
|
||||
|
||||
def test_ignore_les_ids_hors_plage_et_les_liste():
|
||||
tokens = [_tok(0, "DUPONT")]
|
||||
fields = reconstruct_fields(tokens, [{"label": "Nom", "value_ids": [0, 99]}])
|
||||
assert fields[0].value == "DUPONT"
|
||||
assert fields[0].invalid_ids == [99]
|
||||
assert fields[0].anchored is True
|
||||
|
||||
|
||||
def test_value_ids_vide_donne_champ_non_ancre():
|
||||
tokens = [_tok(0, "DUPONT")]
|
||||
fields = reconstruct_fields(tokens, [{"label": "Poids", "value_ids": []}])
|
||||
assert fields[0].value == ""
|
||||
assert fields[0].anchored is False
|
||||
|
||||
|
||||
def test_aucun_id_valide_donne_champ_non_ancre():
|
||||
tokens = [_tok(0, "DUPONT")]
|
||||
fields = reconstruct_fields(tokens, [{"label": "Poids", "value_ids": [7, 8]}])
|
||||
assert fields[0].anchored is False
|
||||
assert fields[0].value == ""
|
||||
assert fields[0].invalid_ids == [7, 8]
|
||||
|
||||
|
||||
def test_dedup_ids_en_preservant_lordre():
|
||||
tokens = [_tok(0, "DUPONT"), _tok(1, "Jean")]
|
||||
fields = reconstruct_fields(tokens, [{"label": "X", "value_ids": [1, 1, 0]}])
|
||||
assert fields[0].value == "Jean DUPONT"
|
||||
assert fields[0].value_ids == [1, 0]
|
||||
|
||||
|
||||
def test_confidence_est_le_min_des_tokens_ancres():
|
||||
tokens = [_tok(0, "A", conf=0.95), _tok(1, "B", conf=0.70)]
|
||||
fields = reconstruct_fields(tokens, [{"label": "X", "value_ids": [0, 1]}])
|
||||
assert fields[0].confidence == pytest.approx(0.70)
|
||||
|
||||
|
||||
def test_bbox_englobante_des_tokens_ancres():
|
||||
tokens = [_tok(0, "A", bbox=(0, 0, 10, 10)), _tok(1, "B", bbox=(20, 5, 40, 15))]
|
||||
fields = reconstruct_fields(tokens, [{"label": "X", "value_ids": [0, 1]}])
|
||||
assert fields[0].bbox == (0, 0, 40, 15)
|
||||
|
||||
|
||||
def test_invariant_aucun_texte_hors_ocr():
|
||||
# 'value' fournie par le VLM est ignorée : seul value_ids compte.
|
||||
tokens = [_tok(0, "DUPONT")]
|
||||
fields = reconstruct_fields(
|
||||
tokens, [{"label": "Nom", "value_ids": [0], "value": "HALLUCINATION"}]
|
||||
)
|
||||
assert fields[0].value == "DUPONT"
|
||||
|
||||
|
||||
def test_tokens_from_grid_indexe_et_normalise_bbox():
|
||||
# grille extract_grid_from_image : bbox = 4 points EasyOCR
|
||||
grid = [
|
||||
[
|
||||
{"text": "Nom", "bbox": [[0, 0], [10, 0], [10, 8], [0, 8]],
|
||||
"confidence": 0.9, "row": 0, "col": 0},
|
||||
{"text": "DUPONT", "bbox": [[20, 0], [60, 0], [60, 8], [20, 8]],
|
||||
"confidence": 0.95, "row": 0, "col": 1},
|
||||
],
|
||||
]
|
||||
tokens = tokens_from_grid(grid)
|
||||
assert [t.id for t in tokens] == [0, 1]
|
||||
assert tokens[0].text == "Nom"
|
||||
assert tokens[1].bbox == (20, 0, 60, 8)
|
||||
|
||||
|
||||
# --- map_roles : orchestrateur (client VLM injectable, donc testable hors-ligne) ---
|
||||
|
||||
def _fake_client(response, capture=None):
|
||||
"""Faux client VLM : enregistre éventuellement le prompt reçu, renvoie une réponse fixe."""
|
||||
def client(image_path, prompt):
|
||||
if capture is not None:
|
||||
capture["prompt"] = prompt
|
||||
capture["image_path"] = image_path
|
||||
return response
|
||||
return client
|
||||
|
||||
|
||||
def test_map_roles_reconstruit_via_client_injecte():
|
||||
tokens = [_tok(0, "DUPONT"), _tok(1, "Jean")]
|
||||
client = _fake_client('{"champs":[{"label":"Nom complet","value_ids":[0,1]}]}')
|
||||
fields = map_roles("img.png", tokens, client)
|
||||
assert len(fields) == 1
|
||||
assert fields[0].label == "Nom complet"
|
||||
assert fields[0].value == "DUPONT Jean"
|
||||
|
||||
|
||||
def test_map_roles_tolere_les_fences_json():
|
||||
tokens = [_tok(0, "DUPONT")]
|
||||
client = _fake_client('```json\n{"champs":[{"label":"Nom","value_ids":[0]}]}\n```')
|
||||
fields = map_roles("img.png", tokens, client)
|
||||
assert fields[0].value == "DUPONT"
|
||||
|
||||
|
||||
def test_map_roles_json_invalide_retourne_liste_vide():
|
||||
# robustesse batch : une réponse VLM non-JSON ne doit pas crasher.
|
||||
tokens = [_tok(0, "DUPONT")]
|
||||
client = _fake_client("désolé, je n'ai pas compris")
|
||||
fields = map_roles("img.png", tokens, client)
|
||||
assert fields == []
|
||||
|
||||
|
||||
def test_build_role_prompt_inclut_les_tokens_avec_ids():
|
||||
tokens = [_tok(0, "Poids"), _tok(1, "72")]
|
||||
prompt = build_role_prompt(tokens)
|
||||
assert "Poids" in prompt and "72" in prompt
|
||||
assert "value_ids" in prompt # on demande bien des ids, pas du texte recopié
|
||||
|
||||
|
||||
def test_build_role_prompt_guide_liste_les_roles_attendus():
|
||||
tokens = [_tok(0, "X")]
|
||||
prompt = build_role_prompt(tokens, roles=["Nom", "IPP", "Poids"])
|
||||
assert "Nom" in prompt and "IPP" in prompt and "Poids" in prompt
|
||||
|
||||
|
||||
def test_map_roles_passe_les_roles_au_prompt():
|
||||
tokens = [_tok(0, "X")]
|
||||
cap = {}
|
||||
client = _fake_client('{"champs":[]}', capture=cap)
|
||||
map_roles("img.png", tokens, client, roles=["Diagnostic", "GEMSA"])
|
||||
assert "Diagnostic" in cap["prompt"] and "GEMSA" in cap["prompt"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assess_quality — évaluation de la qualité d'extraction d'un dossier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _field(label, value="val", anchored=True, confidence=0.9, value_ids=None, invalid_ids=None):
|
||||
"""Helper : construit un MappedField directement (sans passer par OCR/VLM)."""
|
||||
return MappedField(
|
||||
label=label,
|
||||
value=value if anchored else "",
|
||||
value_ids=value_ids or ([0] if anchored else []),
|
||||
confidence=confidence,
|
||||
bbox=(0, 0, 10, 10) if anchored else None,
|
||||
anchored=anchored,
|
||||
invalid_ids=invalid_ids or [],
|
||||
)
|
||||
|
||||
|
||||
# --- failed ---
|
||||
|
||||
def test_assess_quality_failed_aucun_champ():
|
||||
"""Liste vide → failed."""
|
||||
assert assess_quality([]) == "failed"
|
||||
|
||||
|
||||
def test_assess_quality_failed_aucun_champ_ancre():
|
||||
"""Tous non ancrés → failed."""
|
||||
fields = [_field("Nom", anchored=False), _field("IPP", anchored=False)]
|
||||
assert assess_quality(fields) == "failed"
|
||||
|
||||
|
||||
def test_assess_quality_failed_un_champ_value_vide():
|
||||
"""Un seul champ, anchored=False, value vide → failed."""
|
||||
fields = [_field("Nom", anchored=False, value_ids=[])]
|
||||
assert assess_quality(fields) == "failed"
|
||||
|
||||
|
||||
# --- needs_review ---
|
||||
|
||||
def test_assess_quality_needs_review_role_requis_absent():
|
||||
"""Un rôle requis n'est pas dans fields → needs_review."""
|
||||
fields = [_field("Nom", anchored=True)]
|
||||
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "needs_review"
|
||||
|
||||
|
||||
def test_assess_quality_needs_review_role_requis_non_ancre():
|
||||
"""Rôle requis présent mais anchored=False → needs_review."""
|
||||
fields = [_field("Nom", anchored=True), _field("IPP", anchored=False)]
|
||||
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "needs_review"
|
||||
|
||||
|
||||
def test_assess_quality_needs_review_matching_insensible_casse():
|
||||
"""Matching label ↔ required_role insensible à la casse."""
|
||||
fields = [_field("nom complet", anchored=True), _field("ipp", anchored=True)]
|
||||
# required_roles en maj : doit quand même matcher
|
||||
assert assess_quality(fields, required_roles=["Nom Complet", "IPP"]) != "needs_review"
|
||||
|
||||
|
||||
def test_assess_quality_needs_review_matching_insensible_espaces():
|
||||
"""Matching insensible aux espaces en trop (strip)."""
|
||||
fields = [_field(" Nom ", anchored=True)]
|
||||
assert assess_quality(fields, required_roles=["Nom"]) != "needs_review"
|
||||
|
||||
|
||||
def test_assess_quality_needs_review_priorite_sur_partial():
|
||||
"""needs_review > partial : role manquant + confidence basse → needs_review."""
|
||||
fields = [
|
||||
_field("Nom", anchored=True, confidence=0.4), # basse
|
||||
# "IPP" absent → needs_review
|
||||
]
|
||||
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "needs_review"
|
||||
|
||||
|
||||
# --- partial ---
|
||||
|
||||
def test_assess_quality_partial_confidence_basse():
|
||||
"""Tous requis ancrés mais un champ ancré a confidence < min_confidence → partial."""
|
||||
fields = [
|
||||
_field("Nom", anchored=True, confidence=0.9),
|
||||
_field("IPP", anchored=True, confidence=0.4), # < 0.6
|
||||
]
|
||||
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "partial"
|
||||
|
||||
|
||||
def test_assess_quality_partial_champs_non_ancres_en_surplus():
|
||||
"""Tous requis ancrés, confidence ok, mais il y a des champs non ancrés en plus → partial."""
|
||||
fields = [
|
||||
_field("Nom", anchored=True, confidence=0.9),
|
||||
_field("Inconnu", anchored=False), # non ancré hors requis
|
||||
]
|
||||
assert assess_quality(fields, required_roles=["Nom"]) == "partial"
|
||||
|
||||
|
||||
def test_assess_quality_partial_sans_required_roles_confidence_basse():
|
||||
"""Sans required_roles, un champ ancré à confidence basse → partial."""
|
||||
fields = [
|
||||
_field("Nom", anchored=True, confidence=0.9),
|
||||
_field("IPP", anchored=True, confidence=0.3),
|
||||
]
|
||||
assert assess_quality(fields) == "partial"
|
||||
|
||||
|
||||
def test_assess_quality_partial_sans_required_roles_champ_non_ancre():
|
||||
"""Sans required_roles, au moins un champ non ancré → partial."""
|
||||
fields = [
|
||||
_field("Nom", anchored=True, confidence=0.9),
|
||||
_field("IPP", anchored=False),
|
||||
]
|
||||
assert assess_quality(fields) == "partial"
|
||||
|
||||
|
||||
# --- complete ---
|
||||
|
||||
def test_assess_quality_complete_tous_requis_ancres_confidence_ok():
|
||||
"""Tous requis ancrés, toutes confidences >= 0.6, aucun non ancré → complete."""
|
||||
fields = [
|
||||
_field("Nom", anchored=True, confidence=0.9),
|
||||
_field("IPP", anchored=True, confidence=0.7),
|
||||
]
|
||||
assert assess_quality(fields, required_roles=["Nom", "IPP"]) == "complete"
|
||||
|
||||
|
||||
def test_assess_quality_complete_sans_required_roles():
|
||||
"""Sans required_roles, au moins un champ ancré, tous >= min_confidence, aucun non ancré → complete."""
|
||||
fields = [
|
||||
_field("Nom", anchored=True, confidence=0.8),
|
||||
_field("IPP", anchored=True, confidence=0.95),
|
||||
]
|
||||
assert assess_quality(fields) == "complete"
|
||||
|
||||
|
||||
def test_assess_quality_complete_seuil_exactement_min_confidence():
|
||||
"""Confidence exactement égale à min_confidence (0.6) → complete (borne incluse)."""
|
||||
fields = [_field("Nom", anchored=True, confidence=0.6)]
|
||||
assert assess_quality(fields, required_roles=["Nom"]) == "complete"
|
||||
|
||||
|
||||
def test_assess_quality_complete_min_confidence_personnalise():
|
||||
"""Seuil personnalisé : confidence=0.7 >= min_confidence=0.7 → complete."""
|
||||
fields = [_field("Nom", anchored=True, confidence=0.7)]
|
||||
assert assess_quality(fields, min_confidence=0.7) == "complete"
|
||||
163
tests/unit/test_sanitize_log_entries.py
Normal file
163
tests/unit/test_sanitize_log_entries.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Tests TDD de sanitize_log_entries — assainissement PII des logs Léa reçus côté serveur.
|
||||
|
||||
Branche feat/push-log-dgx. N'importe QUE pii_sanitizer (pas api_stream, DETTE-013).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. message avec PII → brut absent, tokens présents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_message_pii_tokenise():
|
||||
"""Un nom clinique + numéro long disparaissent ; des tokens [...] les remplacent.
|
||||
|
||||
Couche 1 (regex, sans NER) : détecte le format « Prénom NOM » (RE_PRENOM_NOM)
|
||||
et l'IPP structuré (RE_IPP). Le format inverse « NOM Prénom » relève de la
|
||||
couche 2 NER — hors scope ici.
|
||||
"""
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
|
||||
|
||||
entries = [
|
||||
{
|
||||
"ts": "2026-06-30T10:00:00Z",
|
||||
"level": "INFO",
|
||||
"logger": "lea.replay",
|
||||
"message": "Ouverture dossier Catherine MOREL IPP: 295841",
|
||||
}
|
||||
]
|
||||
result = sanitize_log_entries(entries)
|
||||
|
||||
assert len(result) == 1
|
||||
msg = result[0]["message"]
|
||||
assert "MOREL" not in msg, f"NOM toujours présent : {msg!r}"
|
||||
assert "Catherine" not in msg, f"Prénom toujours présent : {msg!r}"
|
||||
assert "295841" not in msg, f"IPP toujours présent : {msg!r}"
|
||||
assert "[" in msg, f"Aucun token dans : {msg!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. ts / level préservés à l'identique
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ts_level_preserves():
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
|
||||
|
||||
entries = [
|
||||
{"ts": "2026-06-30T10:00:00Z", "level": "WARNING",
|
||||
"logger": "lea.core", "message": "simple message sans pii"}
|
||||
]
|
||||
result = sanitize_log_entries(entries)
|
||||
|
||||
assert result[0]["ts"] == "2026-06-30T10:00:00Z"
|
||||
assert result[0]["level"] == "WARNING"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. liste vide → liste vide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_liste_vide():
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
|
||||
|
||||
assert sanitize_log_entries([]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. entrée sans clé `message` → pas de crash, entrée conservée
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_entree_sans_message():
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
|
||||
|
||||
entries = [{"ts": "2026-06-30T10:00:01Z", "level": "DEBUG", "logger": "lea.init"}]
|
||||
result = sanitize_log_entries(entries)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "message" not in result[0] # champ absent → reste absent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. cohérence : même PII dans 2 entrées → même token (mapping partagé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_coherence_mapping_partage():
|
||||
"""La même PII dans deux messages du batch reçoit le même token."""
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
|
||||
|
||||
entries = [
|
||||
{"ts": "T1", "level": "INFO", "logger": "l", "message": "IPP: 295841 reçu"},
|
||||
{"ts": "T2", "level": "INFO", "logger": "l", "message": "Relance IPP: 295841"},
|
||||
]
|
||||
result = sanitize_log_entries(entries)
|
||||
|
||||
msg1 = result[0]["message"]
|
||||
msg2 = result[1]["message"]
|
||||
|
||||
# le brut est absent des deux
|
||||
assert "295841" not in msg1
|
||||
assert "295841" not in msg2
|
||||
|
||||
# le token est identique (mapping partagé)
|
||||
import re
|
||||
tokens1 = re.findall(r"\[IPP_\d+\]", msg1)
|
||||
tokens2 = re.findall(r"\[IPP_\d+\]", msg2)
|
||||
assert tokens1, f"Pas de token IPP dans msg1 : {msg1!r}"
|
||||
assert tokens2, f"Pas de token IPP dans msg2 : {msg2!r}"
|
||||
assert tokens1[0] == tokens2[0], (
|
||||
f"Tokens différents pour la même PII : {tokens1[0]} vs {tokens2[0]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. `message` non-str → skip proprement, pas de crash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_message_non_str():
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
|
||||
|
||||
entries = [
|
||||
{"ts": "T1", "level": "INFO", "logger": "l", "message": None},
|
||||
{"ts": "T2", "level": "INFO", "logger": "l", "message": 42},
|
||||
{"ts": "T3", "level": "INFO", "logger": "l", "message": ["liste"]},
|
||||
]
|
||||
result = sanitize_log_entries(entries)
|
||||
|
||||
assert len(result) == 3
|
||||
# les valeurs non-str sont préservées telles quelles
|
||||
assert result[0]["message"] is None
|
||||
assert result[1]["message"] == 42
|
||||
assert result[2]["message"] == ["liste"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. champ `logger` str est aussi assaini si porteur de PII
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_logger_pii_tokenise():
|
||||
"""Si le champ logger contient de la PII (ex. chemin patient), il est assaini."""
|
||||
from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries
|
||||
|
||||
entries = [
|
||||
{
|
||||
"ts": "T1",
|
||||
"level": "INFO",
|
||||
"logger": "lea.patient.MOREL_Catherine",
|
||||
"message": "step start",
|
||||
}
|
||||
]
|
||||
result = sanitize_log_entries(entries)
|
||||
logger_out = result[0]["logger"]
|
||||
# Le NOM doit être tokenisé (RE_PRENOM_NOM captera « Catherine MOREL » …
|
||||
# mais « MOREL_Catherine » n'est pas le format clinique standard — le test
|
||||
# vérifie surtout qu'il n'y a pas de crash et que le champ est traité.)
|
||||
# On ne fixe pas d'assertion sur la valeur : juste pas de crash.
|
||||
assert isinstance(logger_out, str)
|
||||
68
tests/unit/test_stream_event_pii_wiring.py
Normal file
68
tests/unit/test_stream_event_pii_wiring.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Non-régression sécurité : câblage PII au chokepoint ``stream_event``.
|
||||
|
||||
Invariant : un event contenant de la PII patient (titre de fenêtre + contenu
|
||||
saisi) passé à ``stream_event`` ne doit JAMAIS écrire la PII brute dans le
|
||||
journal ``live_events.jsonl``, ni la propager au worker ou au shadow observer.
|
||||
L'assainissement a lieu une seule fois, en amont des chemins de
|
||||
persistance/traitement (``api_stream.py``, hook ``sanitize_event``).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
# Le module serveur refuse de se charger sans token (sécurité prod) ;
|
||||
# en test unitaire on désactive l'auth pour pouvoir importer le module.
|
||||
os.environ.setdefault("RPA_AUTH_DISABLED", "true")
|
||||
|
||||
import agent_v0.server_v1.api_stream as api
|
||||
|
||||
|
||||
def _event_avec_pii():
|
||||
# PII captée par la couche 1 : IPP (structurel) + contenu saisi.
|
||||
# Contexte = logiciel métier réel du POC (pas la maquette Easily abandonnée).
|
||||
# (Les noms libres sans marqueur relèvent de la couche 2 NER — hors scope ici.)
|
||||
return {
|
||||
"type": "text_input",
|
||||
"text": "anticoagulant 75mg matin",
|
||||
"active_window_title": "Gxd5diag - Recherche dossier (IPP: 123456)",
|
||||
}
|
||||
|
||||
|
||||
def test_stream_event_assainit_et_propage_sur_les_chemins(tmp_path, monkeypatch):
|
||||
"""Le chokepoint applique sanitize_event UNE fois et tous les chemins
|
||||
(jsonl, worker, shadow) reçoivent la copie assainie — pas la valeur brute."""
|
||||
captured = {}
|
||||
monkeypatch.setattr(api, "_ensure_session_registered", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
api.worker,
|
||||
"process_event_direct",
|
||||
lambda sid, ev: (captured.__setitem__("worker", ev), {})[1],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api, "shadow_observe_event", lambda sid, ev: captured.__setitem__("shadow", ev)
|
||||
)
|
||||
monkeypatch.setattr(api, "LIVE_SESSIONS_DIR", tmp_path)
|
||||
api._session_pii_mapping.pop("sess_pii", None)
|
||||
|
||||
se = api.StreamEvent(
|
||||
session_id="sess_pii",
|
||||
machine_id="lea-test",
|
||||
timestamp=1000.0,
|
||||
event=_event_avec_pii(),
|
||||
)
|
||||
|
||||
asyncio.run(api.stream_event(se))
|
||||
|
||||
# 1. le journal sur disque ne contient ni l'IPP brut ni le contenu saisi
|
||||
jsonl = (tmp_path / "lea-test" / "sess_pii" / "live_events.jsonl").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "123456" not in jsonl
|
||||
assert "anticoagulant 75mg" not in jsonl
|
||||
# 2. contenu saisi masqué + IPP tokenisé (preuve que le titre est traité)
|
||||
assert "[SAISIE]" in jsonl
|
||||
assert "[IPP_1]" in jsonl
|
||||
# 3. worker et shadow reçoivent l'event assaini, pas la valeur brute
|
||||
assert captured["worker"]["text"] == "[SAISIE]"
|
||||
assert "123456" not in json.dumps(captured["worker"], ensure_ascii=False)
|
||||
assert "123456" not in json.dumps(captured["shadow"], ensure_ascii=False)
|
||||
@@ -57,3 +57,45 @@ def test_returns_sha256_hex():
|
||||
sig = trajectory_signature([{"action_type": "mouse_click", "target": "x"}])
|
||||
assert len(sig) == 64
|
||||
assert all(c in "0123456789abcdef" for c in sig)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R1/R2 amendés — verdict Qwen 2026-06-25 : normalisation déterministe + PII
|
||||
# neutralisée par regex DÉDIÉES (pas de pii_blur, pas de NER). Stabilité
|
||||
# labo/DGX = portabilité de la signature. Noms sans titre : stratégie (b)
|
||||
# (impact 0 en labo, gate = audit agrégat DGX avant prod).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_target_normalized_case_and_accents():
|
||||
"""Q2 : casse et accents ne changent pas la signature (même cible sémantique)."""
|
||||
a = [{"action_type": "mouse_click", "target": "Valider"}]
|
||||
b = [{"action_type": "mouse_click", "target": "VALIDER"}]
|
||||
c = [{"action_type": "mouse_click", "target": "validér"}]
|
||||
assert trajectory_signature(a) == trajectory_signature(b) == trajectory_signature(c)
|
||||
|
||||
|
||||
def test_pii_ipp_neutralized():
|
||||
"""R1 : deux IPP différents sur le même champ → MÊME signature (PII neutralisée).
|
||||
Et une cible sans identifiant reste discriminée."""
|
||||
a = [{"action_type": "mouse_click", "target": "Patient IPP 25012257"}]
|
||||
b = [{"action_type": "mouse_click", "target": "Patient IPP 30045678"}]
|
||||
assert trajectory_signature(a) == trajectory_signature(b)
|
||||
c = [{"action_type": "mouse_click", "target": "Patient liste"}]
|
||||
assert trajectory_signature(a) != trajectory_signature(c)
|
||||
|
||||
|
||||
def test_pii_date_neutralized():
|
||||
"""R1 : deux dates différentes → MÊME signature."""
|
||||
a = [{"action_type": "mouse_click", "target": "RDV du 12/05/2026"}]
|
||||
b = [{"action_type": "mouse_click", "target": "RDV du 03/11/2025"}]
|
||||
assert trajectory_signature(a) == trajectory_signature(b)
|
||||
|
||||
|
||||
def test_pii_phone_and_email_neutralized():
|
||||
"""R1 : téléphone (FR) et email neutralisés (deux valeurs distinctes → même sig)."""
|
||||
tel_a = [{"action_type": "text_input", "target": "tel 06 12 34 56 78"}]
|
||||
tel_b = [{"action_type": "text_input", "target": "tel 07 98 76 54 32"}]
|
||||
assert trajectory_signature(tel_a) == trajectory_signature(tel_b)
|
||||
mail_a = [{"action_type": "text_input", "target": "mail jean.dupont@chu.fr"}]
|
||||
mail_b = [{"action_type": "text_input", "target": "mail m.martin@chu.fr"}]
|
||||
assert trajectory_signature(mail_a) == trajectory_signature(mail_b)
|
||||
|
||||
135
tests/unit/test_update_check_server.py
Normal file
135
tests/unit/test_update_check_server.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""TDD — DETTE-022 MAJ silencieuse v2 : logique PURE serveur de décision d'update.
|
||||
|
||||
Périmètre testé ICI = parties PURES, testables sans démarrer le serveur
|
||||
(DETTE-013 : on N'IMPORTE PAS `api_stream` — on charge le module
|
||||
`update_check.py` par chemin, comme test_agent_v1_log_shipper).
|
||||
|
||||
Couvre :
|
||||
- R3 `parse_version()` : tuple d'entiers, "1.0.2" < "1.0.10", égalité,
|
||||
"v1.2.3"/espaces tolérés, format invalide → fallback sans crash.
|
||||
- R2 logique de décision PURE `decide_update()` : compare version courante
|
||||
vs dernière dispo, choisit `update_type` (code-only/full), construit la
|
||||
réponse `{update_available, latest_version, update_type, url}`.
|
||||
|
||||
Le NOYAU dangereux (swap fichiers / Lea.bat / restart) est HORS périmètre.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "agent_v0" / "server_v1" / "update_check.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("rpa_update_check", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod():
|
||||
return _load_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3 — parse_version : tuple d'entiers (semver), pas comparaison lexicale
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseVersion:
|
||||
def test_parse_basique(self, mod):
|
||||
assert mod.parse_version("1.0.2") == (1, 0, 2)
|
||||
assert mod.parse_version("1.0.10") == (1, 0, 10)
|
||||
|
||||
def test_ordre_semver_pas_lexical(self, mod):
|
||||
# Le bug classique : "1.0.2" < "1.0.10" est FAUX en lexicographique.
|
||||
assert mod.parse_version("1.0.2") < mod.parse_version("1.0.10")
|
||||
assert mod.parse_version("1.0.10") > mod.parse_version("1.0.2")
|
||||
assert mod.parse_version("2.0.0") > mod.parse_version("1.99.99")
|
||||
|
||||
def test_egalite(self, mod):
|
||||
assert mod.parse_version("1.0.1") == mod.parse_version("1.0.1")
|
||||
|
||||
def test_prefixe_v_et_espaces_toleres(self, mod):
|
||||
assert mod.parse_version("v1.2.3") == mod.parse_version("1.2.3")
|
||||
assert mod.parse_version(" 1.2.3 ") == (1, 2, 3)
|
||||
assert mod.parse_version("V1.2.3") == (1, 2, 3)
|
||||
|
||||
def test_format_invalide_fallback_sans_crash(self, mod):
|
||||
# Ne doit jamais lever — fallback (0,) (= la plus basse).
|
||||
assert mod.parse_version("") == (0,)
|
||||
assert mod.parse_version("abc") == (0,)
|
||||
assert mod.parse_version(None) == (0,)
|
||||
assert mod.parse_version("1.x.3") == (0,)
|
||||
# Une version valide reste toujours > au fallback invalide.
|
||||
assert mod.parse_version("0.0.1") > mod.parse_version("garbage")
|
||||
|
||||
def test_is_newer_helper(self, mod):
|
||||
assert mod.is_newer("1.0.2", "1.0.1") is True
|
||||
assert mod.is_newer("1.0.10", "1.0.2") is True
|
||||
assert mod.is_newer("1.0.1", "1.0.1") is False
|
||||
assert mod.is_newer("1.0.0", "1.0.1") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R2 — decide_update : logique PURE de décision serveur
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecideUpdate:
|
||||
def test_pas_de_maj_si_a_jour(self, mod):
|
||||
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.1")
|
||||
assert resp["update_available"] is False
|
||||
assert resp["latest_version"] == "1.0.1"
|
||||
assert resp["update_type"] is None
|
||||
assert resp["url"] is None
|
||||
|
||||
def test_pas_de_maj_si_client_plus_recent(self, mod):
|
||||
# Client en avance (dev local) → jamais de downgrade.
|
||||
resp = mod.decide_update(current_version="1.0.5", latest_version="1.0.2")
|
||||
assert resp["update_available"] is False
|
||||
|
||||
def test_maj_disponible_code_only_par_defaut(self, mod):
|
||||
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.2")
|
||||
assert resp["update_available"] is True
|
||||
assert resp["latest_version"] == "1.0.2"
|
||||
# R2 : code-only = défaut (99% des cas, ~500 Ko).
|
||||
assert resp["update_type"] == "code-only"
|
||||
assert "1.0.2" in resp["url"]
|
||||
assert "code-only" in resp["url"]
|
||||
|
||||
def test_maj_full_si_demande(self, mod):
|
||||
resp = mod.decide_update(
|
||||
current_version="1.0.1", latest_version="1.1.0", update_type="full"
|
||||
)
|
||||
assert resp["update_available"] is True
|
||||
assert resp["update_type"] == "full"
|
||||
assert "full" in resp["url"]
|
||||
|
||||
def test_update_type_invalide_retombe_sur_code_only(self, mod):
|
||||
resp = mod.decide_update(
|
||||
current_version="1.0.1", latest_version="1.0.2", update_type="banana"
|
||||
)
|
||||
assert resp["update_type"] == "code-only"
|
||||
|
||||
def test_ordre_semver_dans_decision(self, mod):
|
||||
# 1.0.2 < 1.0.10 → MAJ dispo (pas de faux négatif lexical).
|
||||
resp = mod.decide_update(current_version="1.0.2", latest_version="1.0.10")
|
||||
assert resp["update_available"] is True
|
||||
|
||||
def test_url_inclut_machine_id_si_fourni(self, mod):
|
||||
resp = mod.decide_update(
|
||||
current_version="1.0.1", latest_version="1.0.2", machine_id="pc-7"
|
||||
)
|
||||
assert "pc-7" in resp["url"]
|
||||
|
||||
def test_versions_invalides_pas_de_crash_pas_de_maj(self, mod):
|
||||
# latest illisible → on ne propose RIEN (prudence : pas de MAJ douteuse).
|
||||
resp = mod.decide_update(current_version="1.0.1", latest_version="garbage")
|
||||
assert resp["update_available"] is False
|
||||
resp2 = mod.decide_update(current_version="", latest_version="")
|
||||
assert resp2["update_available"] is False
|
||||
162
tests/unit/test_update_policy_canary.py
Normal file
162
tests/unit/test_update_policy_canary.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""TDD — DETTE-022 v2 : CANARY server-side pour la MAJ silencieuse Léa.
|
||||
|
||||
Périmètre testé ICI = logique PURE de la POLITIQUE de déploiement canary,
|
||||
testable sans démarrer le serveur (DETTE-013 : on N'IMPORTE PAS `api_stream`
|
||||
— on charge `update_policy.py` par chemin, comme test_update_check_server).
|
||||
|
||||
Objectif SÉCURITÉ (10+ postes cliniques live) : une MAJ ne doit JAMAIS
|
||||
partir sur toute la flotte d'un coup. Le canary résout la version cible
|
||||
*par machine* :
|
||||
|
||||
- un poste dans la liste canary reçoit la version `canary` (Émilie d'abord) ;
|
||||
- tous les autres restent sur la version `stable` (floor) tant que le canary
|
||||
n'est pas promu.
|
||||
|
||||
`resolve_target_version(machine_id, ...)` est la brique PURE ; `decide_update`
|
||||
côté serveur l'appelle pour choisir la version cible avant de comparer.
|
||||
|
||||
Le NOYAU dangereux (swap fichiers / Lea.bat / restart) reste HORS périmètre.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "agent_v0" / "server_v1" / "update_policy.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("rpa_update_policy", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod():
|
||||
return _load_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_canary_machines — liste d'allow-list (CSV / espaces tolérés)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseCanaryMachines:
|
||||
def test_liste_csv(self, mod):
|
||||
assert mod.parse_canary_machines("lea-4zbgwxty") == {"lea-4zbgwxty"}
|
||||
assert mod.parse_canary_machines("a,b,c") == {"a", "b", "c"}
|
||||
|
||||
def test_espaces_et_vides_toleres(self, mod):
|
||||
assert mod.parse_canary_machines(" a , b , ") == {"a", "b"}
|
||||
assert mod.parse_canary_machines("") == set()
|
||||
assert mod.parse_canary_machines(None) == set()
|
||||
|
||||
def test_supporte_separateurs_espace_et_point_virgule(self, mod):
|
||||
# Tolérant : virgule, point-virgule, espace comme séparateurs.
|
||||
assert mod.parse_canary_machines("a; b c") == {"a", "b", "c"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_target_version — LE cœur canary (sécurité)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveTargetVersion:
|
||||
def test_machine_canary_recoit_version_canary(self, mod):
|
||||
# Émilie (canary) reçoit la nouvelle version en premier.
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.2"
|
||||
|
||||
def test_machine_hors_canary_reste_sur_stable(self, mod):
|
||||
# Tous les autres postes restent sur la version stable (floor).
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-autre-poste",
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_pas_de_canary_configure_tout_le_monde_stable(self, mod):
|
||||
# Aucun canary défini → personne ne monte (défaut ultra-prudent).
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines=set(),
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_canary_version_absente_retombe_sur_stable(self, mod):
|
||||
# Si canary_version n'est pas fournie, même un poste canary reste stable.
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.1",
|
||||
canary_version=None,
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_machine_id_none_reste_stable(self, mod):
|
||||
# machine_id inconnu / non fourni → jamais canary (prudence).
|
||||
target = mod.resolve_target_version(
|
||||
machine_id=None,
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_canary_ne_downgrade_jamais_en_dessous_de_stable(self, mod):
|
||||
# GARDE-FOU : si le canary_version est PLUS ANCIEN que stable (erreur
|
||||
# de config), on NE descend PAS le poste canary — on sert stable.
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.5",
|
||||
canary_version="1.0.2", # plus ancien → config douteuse
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.5"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lecture depuis l'environnement (pilotage sans rebuild) — défauts prudents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnvPolicy:
|
||||
def test_defauts_prudents_aucune_maj(self, mod, monkeypatch):
|
||||
# Aucune var positionnée → stable par défaut, pas de canary.
|
||||
for var in (
|
||||
"RPA_AGENT_STABLE_VERSION",
|
||||
"RPA_AGENT_CANARY_VERSION",
|
||||
"RPA_AGENT_CANARY_MACHINES",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
assert mod.stable_version_from_env() == "1.0.1"
|
||||
assert mod.canary_version_from_env() is None
|
||||
assert mod.canary_machines_from_env() == set()
|
||||
# Un poste quelconque reste sur stable.
|
||||
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.1"
|
||||
|
||||
def test_canary_actif_via_env_seul_le_poste_canary_monte(self, mod, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty")
|
||||
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2"
|
||||
assert mod.resolve_target_version_from_env("autre-poste") == "1.0.1"
|
||||
|
||||
def test_promotion_toute_la_flotte_suit(self, mod, monkeypatch):
|
||||
# Promotion : on met stable = version canary, on vide la liste canary.
|
||||
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.2")
|
||||
monkeypatch.delenv("RPA_AGENT_CANARY_VERSION", raising=False)
|
||||
monkeypatch.delenv("RPA_AGENT_CANARY_MACHINES", raising=False)
|
||||
assert mod.resolve_target_version_from_env("autre-poste") == "1.0.2"
|
||||
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2"
|
||||
336
tests/unit/test_visual_login.py
Normal file
336
tests/unit/test_visual_login.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Tests for core/navigation/visual_login.py — login form resolution + verification."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from core.navigation.visual_login import (
|
||||
LoginFormConfig,
|
||||
LoginResolution,
|
||||
dpi_urgences_login_config,
|
||||
verify_login_visible,
|
||||
verify_login_success,
|
||||
resolve_login_form,
|
||||
_ocr_detailed_to_simple,
|
||||
)
|
||||
from core.navigation.grounding import (
|
||||
CoordsCache,
|
||||
GroundedElement,
|
||||
OcrTokenInfo,
|
||||
OcrDetailedClient,
|
||||
)
|
||||
from core.navigation.visual_verifier import (
|
||||
ScreenMatchResult,
|
||||
VlmClient,
|
||||
OcrClient,
|
||||
)
|
||||
|
||||
|
||||
# ── Mock factories ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mock_ocr_detailed_client_factory(tokens: list):
|
||||
"""Factory for mock OcrDetailedClient."""
|
||||
def client(image_path: str) -> list:
|
||||
return tokens
|
||||
return client
|
||||
|
||||
|
||||
def mock_ocr_simple_client_factory(tokens: list):
|
||||
"""Factory for mock OcrClient (text-only)."""
|
||||
def client(image_path: str) -> list:
|
||||
return tokens
|
||||
return client
|
||||
|
||||
|
||||
def mock_vlm_client_factory(response_json: dict):
|
||||
"""Factory for mock VlmClient."""
|
||||
def client(image_path: str, prompt: str) -> str:
|
||||
return json.dumps(response_json)
|
||||
return client
|
||||
|
||||
|
||||
# ── Default config tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDpiUrgencesLoginConfig:
|
||||
def test_default_config(self):
|
||||
config = dpi_urgences_login_config()
|
||||
assert config.login_field["role"] == "champ"
|
||||
assert config.login_field["text"] == "Login"
|
||||
assert config.password_field["text"] == "Mot de passe"
|
||||
assert config.submit_button["text"] == "Connexion"
|
||||
assert len(config.success_elements) >= 1
|
||||
assert config.context != ""
|
||||
|
||||
def test_config_fields_are_dicts(self):
|
||||
config = dpi_urgences_login_config()
|
||||
assert isinstance(config.login_field, dict)
|
||||
assert isinstance(config.password_field, dict)
|
||||
assert isinstance(config.submit_button, dict)
|
||||
|
||||
|
||||
# ── _ocr_detailed_to_simple tests ────────────────────────────────────
|
||||
|
||||
|
||||
class TestOcrDetailedToSimple:
|
||||
def test_conversion(self):
|
||||
tokens = [
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 200, 90)),
|
||||
OcrTokenInfo(text="Password", bbox=(100, 100, 200, 140)),
|
||||
]
|
||||
detailed = mock_ocr_detailed_client_factory(tokens)
|
||||
simple = _ocr_detailed_to_simple(detailed)
|
||||
result = simple("/tmp/test.png")
|
||||
assert result == ["Login", "Password"]
|
||||
|
||||
def test_empty_tokens(self):
|
||||
detailed = mock_ocr_detailed_client_factory([])
|
||||
simple = _ocr_detailed_to_simple(detailed)
|
||||
result = simple("/tmp/test.png")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ── verify_login_visible tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyLoginVisible:
|
||||
def test_form_visible(self):
|
||||
"""All 3 fields found by OCR + roles confirmed → match."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
context="DPI login",
|
||||
)
|
||||
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe", "Connexion"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
||||
{"index": 2, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
||||
{"index": 3, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
|
||||
],
|
||||
"overall_confidence": 0.9,
|
||||
})
|
||||
result = verify_login_visible("/tmp/login.png", config, ocr, vlm)
|
||||
assert result.match == True
|
||||
|
||||
def test_form_missing_button(self):
|
||||
"""Connexion button not found by OCR → mismatch."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
)
|
||||
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe"]) # missing Connexion
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_login_visible("/tmp/login.png", config, ocr, vlm)
|
||||
assert result.match == False
|
||||
|
||||
def test_form_wrong_role(self):
|
||||
"""OCR finds text but VLM says button is a label → mismatch."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
)
|
||||
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe", "Connexion"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
||||
{"index": 2, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
||||
{"index": 3, "role_confirmed": False, "actual_role": "label", "confidence": 0.5},
|
||||
],
|
||||
"overall_confidence": 0.5,
|
||||
})
|
||||
result = verify_login_visible("/tmp/login.png", config, ocr, vlm)
|
||||
assert result.match == False
|
||||
|
||||
|
||||
# ── verify_login_success tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyLoginSuccess:
|
||||
def test_dashboard_visible(self):
|
||||
"""Dashboard found by OCR + role confirmed → success."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
success_elements=[{"role": "page", "text": "Dashboard"}],
|
||||
)
|
||||
ocr = mock_ocr_simple_client_factory(["Dashboard", "Accueil"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "page", "confidence": 0.92},
|
||||
],
|
||||
"overall_confidence": 0.92,
|
||||
})
|
||||
result = verify_login_success("/tmp/dashboard.png", config, ocr, vlm)
|
||||
assert result.match == True
|
||||
|
||||
def test_no_success_elements(self):
|
||||
"""Config has no success_elements → can't verify."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
success_elements=[], # empty!
|
||||
)
|
||||
ocr = mock_ocr_simple_client_factory(["Dashboard"])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_login_success("/tmp/page.png", config, ocr, vlm)
|
||||
assert result.match == False
|
||||
assert "no success_elements" in result.reason
|
||||
|
||||
def test_still_on_login_page(self):
|
||||
"""After login, still seeing login form → mismatch."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
success_elements=[{"role": "page", "text": "Dashboard"}],
|
||||
)
|
||||
# OCR sees login form texts, not Dashboard
|
||||
ocr = mock_ocr_simple_client_factory(["Login", "Mot de passe", "Connexion"])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_login_success("/tmp/still_login.png", config, ocr, vlm)
|
||||
assert result.match == False
|
||||
|
||||
|
||||
# ── resolve_login_form tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveLoginForm:
|
||||
def test_all_fields_ocr_anchor(self):
|
||||
"""All 3 fields found by OCR with bbox → full resolution."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
)
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
||||
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140)),
|
||||
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190)),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
|
||||
assert result.all_resolved == True
|
||||
assert result.login_field is not None
|
||||
assert result.login_field.method == "ocr_anchor"
|
||||
assert result.password_field is not None
|
||||
assert result.submit_button is not None
|
||||
assert result.method == "ocr_anchor"
|
||||
|
||||
def test_partial_ocr_vlm_fallback(self):
|
||||
"""Login + password by OCR, button by VLM → mixed method."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Password"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
)
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
||||
OcrTokenInfo(text="Password", bbox=(100, 100, 250, 140)),
|
||||
# Connexion not in OCR → VLM fallback
|
||||
])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"found": True,
|
||||
"bbox": [0.2, 0.4, 0.4, 0.5],
|
||||
"confidence": 0.85,
|
||||
})
|
||||
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
|
||||
assert result.all_resolved == True
|
||||
assert result.login_field.method == "ocr_anchor"
|
||||
assert result.submit_button.method == "vlm_grounder"
|
||||
assert result.method == "mixed"
|
||||
|
||||
def test_incomplete_resolution(self):
|
||||
"""Button not found by OCR or VLM → incomplete."""
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Password"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
)
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
||||
OcrTokenInfo(text="Password", bbox=(100, 100, 250, 140)),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({"found": False, "bbox": [], "confidence": 0.0})
|
||||
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
|
||||
assert result.all_resolved == False
|
||||
assert result.submit_button is None
|
||||
|
||||
def test_cache_hit(self):
|
||||
"""All fields cached → returned directly."""
|
||||
cache = CoordsCache()
|
||||
cache.put("champ:login", (100, 50, 250, 90), (175, 70), "ocr_anchor")
|
||||
cache.put("champ:mot de passe", (100, 100, 250, 140), (175, 120), "ocr_anchor")
|
||||
cache.put("bouton:connexion", (100, 150, 250, 190), (175, 170), "ocr_anchor")
|
||||
|
||||
config = LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login"},
|
||||
password_field={"role": "champ", "text": "Mot de passe"},
|
||||
submit_button={"role": "bouton", "text": "Connexion"},
|
||||
)
|
||||
ocr = mock_ocr_detailed_client_factory([])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = resolve_login_form(
|
||||
"/tmp/login.png", config, ocr, vlm, coords_cache=cache,
|
||||
)
|
||||
assert result.all_resolved == True
|
||||
assert result.method == "cache"
|
||||
assert result.login_field.center == (175, 70)
|
||||
|
||||
def test_with_dpi_default_config(self):
|
||||
"""Full flow with dpi_urgences_login_config."""
|
||||
config = dpi_urgences_login_config()
|
||||
ocr = mock_ocr_detailed_client_factory([
|
||||
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
||||
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140)),
|
||||
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190)),
|
||||
])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = resolve_login_form("/tmp/login.png", config, ocr, vlm)
|
||||
assert result.all_resolved == True
|
||||
|
||||
|
||||
# ── LoginResolution describe tests ────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoginResolutionDescribe:
|
||||
def test_all_resolved(self):
|
||||
resolution = LoginResolution(
|
||||
login_field=GroundedElement(
|
||||
role="champ", text="Login",
|
||||
bbox=(100, 50, 250, 90), center=(175, 70),
|
||||
confidence=0.9, method="ocr_anchor",
|
||||
),
|
||||
password_field=GroundedElement(
|
||||
role="champ", text="Mot de passe",
|
||||
bbox=(100, 100, 250, 140), center=(175, 120),
|
||||
confidence=0.9, method="ocr_anchor",
|
||||
),
|
||||
submit_button=GroundedElement(
|
||||
role="bouton", text="Connexion",
|
||||
bbox=(100, 150, 250, 190), center=(175, 170),
|
||||
confidence=0.9, method="ocr_anchor",
|
||||
),
|
||||
all_resolved=True,
|
||||
method="ocr_anchor",
|
||||
)
|
||||
desc = resolution.describe()
|
||||
assert "OK" in desc
|
||||
assert "login@" in desc
|
||||
assert "button@" in desc
|
||||
|
||||
def test_incomplete(self):
|
||||
resolution = LoginResolution(
|
||||
login_field=None,
|
||||
password_field=None,
|
||||
submit_button=None,
|
||||
all_resolved=False,
|
||||
method="",
|
||||
)
|
||||
desc = resolution.describe()
|
||||
assert "INCOMPLETE" in desc
|
||||
assert "NOT FOUND" in desc
|
||||
490
tests/unit/test_visual_verifier.py
Normal file
490
tests/unit/test_visual_verifier.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""Tests for core/navigation/visual_verifier.py — OCR-anchored architecture.
|
||||
|
||||
Tests pure functions (normalize_text, fuzzy_match, ocr_presence_check,
|
||||
build_role_confirm_prompt, parse_role_confirm_response) offline,
|
||||
then verifies verify_screen_match with mock OcrClient + VlmClient.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from core.navigation.visual_verifier import (
|
||||
normalize_text,
|
||||
fuzzy_match,
|
||||
ocr_presence_check,
|
||||
build_role_confirm_prompt,
|
||||
parse_role_confirm_response,
|
||||
verify_screen_match,
|
||||
verify_before,
|
||||
verify_after,
|
||||
ScreenMatchResult,
|
||||
OcrPresenceResult,
|
||||
)
|
||||
|
||||
|
||||
# ── Mock factories ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mock_ocr_client_factory(tokens: list):
|
||||
"""Factory that creates a mock OcrClient returning the given tokens."""
|
||||
def client(image_path: str) -> list:
|
||||
return tokens
|
||||
return client
|
||||
|
||||
|
||||
def mock_vlm_client_factory(response_json: dict):
|
||||
"""Factory that creates a mock VlmClient returning the given JSON."""
|
||||
def client(image_path: str, prompt: str) -> str:
|
||||
return json.dumps(response_json)
|
||||
return client
|
||||
|
||||
|
||||
# ── normalize_text tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeText:
|
||||
def test_lowercase(self):
|
||||
assert normalize_text("RECHERCHER") == "rechercher"
|
||||
|
||||
def test_strip_accents(self):
|
||||
assert normalize_text("Recherché") == "recherche"
|
||||
|
||||
def test_collapse_whitespace(self):
|
||||
assert normalize_text(" hello world ") == "hello world"
|
||||
|
||||
def test_combined(self):
|
||||
assert normalize_text(" Nom Prénom ") == "nom prenom"
|
||||
|
||||
def test_empty(self):
|
||||
assert normalize_text("") == ""
|
||||
|
||||
def test_numbers_preserved(self):
|
||||
assert normalize_text("IPP 12345") == "ipp 12345"
|
||||
|
||||
|
||||
# ── fuzzy_match tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFuzzyMatch:
|
||||
def test_exact_match(self):
|
||||
assert fuzzy_match("Rechercher", "Rechercher") == True
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert fuzzy_match("rechercher", "RECHERCHER") == True
|
||||
|
||||
def test_accent_match(self):
|
||||
assert fuzzy_match("Recherché", "Recherche") == True
|
||||
|
||||
def test_substring_containment(self):
|
||||
# Short text contained in longer OCR token
|
||||
assert fuzzy_match("Rechercher", "Bouton Rechercher") == True
|
||||
|
||||
def test_reverse_containment(self):
|
||||
# OCR token contained in expected text
|
||||
assert fuzzy_match("Nom Prénom Patient", "Nom") == True
|
||||
|
||||
def test_fuzzy_ratio(self):
|
||||
# Similar but not exact/substring — ratio ~0.90
|
||||
assert fuzzy_match("Connexion", "Connection", threshold=0.8) == True
|
||||
|
||||
def test_no_match(self):
|
||||
assert fuzzy_match("Dashboard", "Login", threshold=0.8) == False
|
||||
|
||||
def test_custom_threshold(self):
|
||||
# "Connection" vs "Connexion" ratio ~0.90, passes at 0.8 but fails at 0.95
|
||||
assert fuzzy_match("Connexion", "Connection", threshold=0.95) == False
|
||||
|
||||
|
||||
# ── ocr_presence_check tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOcrPresenceCheck:
|
||||
def test_all_found(self):
|
||||
tokens = ["Rechercher", "Connexion", "Nom Patient"]
|
||||
elements = [
|
||||
{"role": "bouton", "text": "Rechercher"},
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
]
|
||||
result = ocr_presence_check(tokens, elements)
|
||||
assert result.all_found == True
|
||||
assert result.presence_ratio == 1.0
|
||||
assert len(result.missing) == 0
|
||||
assert result.found_texts["Rechercher"] == "Rechercher"
|
||||
|
||||
def test_partial_found(self):
|
||||
tokens = ["Rechercher"]
|
||||
elements = [
|
||||
{"role": "bouton", "text": "Rechercher"},
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
]
|
||||
result = ocr_presence_check(tokens, elements)
|
||||
assert result.all_found == False
|
||||
assert result.presence_ratio == 0.5
|
||||
assert "bouton: Connexion" in result.missing
|
||||
|
||||
def test_none_found(self):
|
||||
tokens = ["Accueil", "Paramètres"]
|
||||
elements = [
|
||||
{"role": "bouton", "text": "Rechercher"},
|
||||
]
|
||||
result = ocr_presence_check(tokens, elements)
|
||||
assert result.all_found == False
|
||||
assert result.presence_ratio == 0.0
|
||||
assert "bouton: Rechercher" in result.missing
|
||||
|
||||
def test_fuzzy_match_in_presence(self):
|
||||
tokens = ["Rechércher"] # OCR with accent variation
|
||||
elements = [{"role": "bouton", "text": "Rechercher"}]
|
||||
result = ocr_presence_check(tokens, elements)
|
||||
assert result.all_found == True
|
||||
|
||||
def test_empty_tokens(self):
|
||||
result = ocr_presence_check([], [{"role": "bouton", "text": "Login"}])
|
||||
assert result.all_found == False
|
||||
assert result.presence_ratio == 0.0
|
||||
|
||||
def test_empty_elements(self):
|
||||
result = ocr_presence_check(["Login", "Password"], [])
|
||||
assert result.all_found == True
|
||||
assert result.presence_ratio == 1.0
|
||||
|
||||
def test_no_text_key(self):
|
||||
elements = [{"role": "page"}] # no text key
|
||||
result = ocr_presence_check(["Dashboard"], elements)
|
||||
assert result.all_found == True # no text to check → trivially found
|
||||
|
||||
def test_multiple_elements_same_text(self):
|
||||
tokens = ["Connexion"]
|
||||
elements = [
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
{"role": "label", "text": "Connexion"},
|
||||
]
|
||||
result = ocr_presence_check(tokens, elements)
|
||||
assert result.all_found == True
|
||||
|
||||
|
||||
# ── build_role_confirm_prompt tests ───────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildRoleConfirmPrompt:
|
||||
def test_basic_prompt(self):
|
||||
found = [
|
||||
{"text": "Rechercher", "expected_role": "bouton", "matched_ocr": "Rechercher"},
|
||||
]
|
||||
expected = [{"role": "bouton", "text": "Rechercher"}]
|
||||
prompt = build_role_confirm_prompt(found, expected)
|
||||
assert "Text \"Rechercher\"" in prompt
|
||||
assert "expected role: bouton" in prompt
|
||||
assert "role_confirmed" in prompt
|
||||
|
||||
def test_with_context(self):
|
||||
found = [
|
||||
{"text": "Connexion", "expected_role": "bouton", "matched_ocr": "Connexion"},
|
||||
]
|
||||
expected = [{"role": "bouton", "text": "Connexion"}]
|
||||
prompt = build_role_confirm_prompt(found, expected, context="page login DPI")
|
||||
assert "Context: page login DPI" in prompt
|
||||
|
||||
def test_multiple_elements(self):
|
||||
found = [
|
||||
{"text": "Login", "expected_role": "champ", "matched_ocr": "Login"},
|
||||
{"text": "Password", "expected_role": "champ", "matched_ocr": "Password"},
|
||||
{"text": "Connexion", "expected_role": "bouton", "matched_ocr": "Connexion"},
|
||||
]
|
||||
expected = [
|
||||
{"role": "champ", "text": "Login"},
|
||||
{"role": "champ", "text": "Password"},
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
]
|
||||
prompt = build_role_confirm_prompt(found, expected)
|
||||
assert "1." in prompt
|
||||
assert "2." in prompt
|
||||
assert "3." in prompt
|
||||
|
||||
def test_no_self_declaration(self):
|
||||
"""Prompt must NOT ask VLM to declare presence — only role."""
|
||||
found = [
|
||||
{"text": "Login", "expected_role": "champ", "matched_ocr": "Login"},
|
||||
]
|
||||
expected = [{"role": "champ", "text": "Login"}]
|
||||
prompt = build_role_confirm_prompt(found, expected)
|
||||
assert "present" not in prompt.lower() or "confirmed" in prompt.lower()
|
||||
|
||||
|
||||
# ── parse_role_confirm_response tests ─────────────────────────────────
|
||||
|
||||
|
||||
class TestParseRoleConfirmResponse:
|
||||
def test_valid_json(self):
|
||||
data = json.dumps({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.92},
|
||||
],
|
||||
"overall_confidence": 0.92,
|
||||
})
|
||||
result = parse_role_confirm_response(data)
|
||||
assert len(result["confirmed"]) == 1
|
||||
assert result["overall_confidence"] == 0.92
|
||||
|
||||
def test_json_in_markdown(self):
|
||||
vlm_text = "```json\n{\"confirmed\": [], \"overall_confidence\": 0.0}\n```"
|
||||
result = parse_role_confirm_response(vlm_text)
|
||||
assert result["overall_confidence"] == 0.0
|
||||
|
||||
def test_garbled_response(self):
|
||||
result = parse_role_confirm_response("I cannot determine the roles")
|
||||
assert result["overall_confidence"] == 0.0
|
||||
assert len(result["confirmed"]) == 0
|
||||
|
||||
def test_confidence_as_string(self):
|
||||
data = json.dumps({"confirmed": [], "overall_confidence": "0.85"})
|
||||
result = parse_role_confirm_response(data)
|
||||
assert result["overall_confidence"] == 0.85
|
||||
|
||||
|
||||
# ── verify_screen_match (OCR-anchored) tests ─────────────────────────
|
||||
|
||||
|
||||
class TestVerifyScreenMatchOcrAnchored:
|
||||
def test_full_match(self):
|
||||
ocr = mock_ocr_client_factory(["Rechercher", "Connexion", "Dashboard"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.92},
|
||||
],
|
||||
"overall_confidence": 0.92,
|
||||
})
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[{"role": "bouton", "text": "Rechercher"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == True
|
||||
assert result.confidence >= 0.7
|
||||
|
||||
def test_ocr_presence_fail(self):
|
||||
"""OCR doesn't find expected text → mismatch (deterministic, no VLM needed)."""
|
||||
ocr = mock_ocr_client_factory(["Accueil", "Paramètres"])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[{"role": "bouton", "text": "Rechercher"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == False
|
||||
assert "OCR presence" in result.reason
|
||||
assert len(result.mismatches) > 0
|
||||
|
||||
def test_role_not_confirmed(self):
|
||||
"""OCR finds text, VLM says it's a label not a button → mismatch."""
|
||||
ocr = mock_ocr_client_factory(["Rechercher"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": False, "actual_role": "label", "confidence": 0.6},
|
||||
],
|
||||
"overall_confidence": 0.6,
|
||||
})
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[{"role": "bouton", "text": "Rechercher"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == False
|
||||
|
||||
def test_ocr_error(self):
|
||||
"""OCR engine fails → fail-safe mismatch."""
|
||||
def failing_ocr(image_path):
|
||||
raise RuntimeError("OCR engine down")
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[{"role": "bouton", "text": "Rechercher"}],
|
||||
ocr_client=failing_ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == False
|
||||
assert "OCR error" in result.reason
|
||||
|
||||
def test_vlm_error_partial_match(self):
|
||||
"""OCR finds texts, VLM fails → partial match (presence OK, role unknown)."""
|
||||
ocr = mock_ocr_client_factory(["Rechercher"])
|
||||
def failing_vlm(image_path, prompt):
|
||||
raise RuntimeError("VLM service down")
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[{"role": "bouton", "text": "Rechercher"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=failing_vlm,
|
||||
)
|
||||
# Presence confirmed by OCR → partial match, confidence=0.5
|
||||
assert result.match == True
|
||||
assert result.confidence == 0.5
|
||||
assert "VLM role confirm failed" in result.reason
|
||||
|
||||
def test_no_expected_elements(self):
|
||||
ocr = mock_ocr_client_factory(["Login"])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_screen_match("/tmp/test.png", [], ocr_client=ocr, vlm_client=vlm)
|
||||
assert result.match == True
|
||||
assert result.confidence == 1.0
|
||||
|
||||
def test_describe_match(self):
|
||||
result = ScreenMatchResult(match=True, confidence=0.92)
|
||||
assert "OK" in result.describe()
|
||||
|
||||
def test_describe_mismatch(self):
|
||||
result = ScreenMatchResult(
|
||||
match=False, confidence=0.3,
|
||||
mismatches=["bouton: Rechercher"],
|
||||
)
|
||||
assert "mismatch" in result.describe()
|
||||
|
||||
def test_multiple_elements_mixed(self):
|
||||
"""2 elements: 1 found+role OK, 1 not found in OCR → mismatch."""
|
||||
ocr = mock_ocr_client_factory(["Connexion"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
|
||||
],
|
||||
"overall_confidence": 0.9,
|
||||
})
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[
|
||||
{"role": "bouton", "text": "Connexion"},
|
||||
{"role": "champ", "text": "Nom Patient"},
|
||||
],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == False # "Nom Patient" not found by OCR
|
||||
|
||||
def test_fuzzy_ocr_match(self):
|
||||
"""OCR reads 'Rechércher' (accent), expected 'Rechercher' → still found."""
|
||||
ocr = mock_ocr_client_factory(["Rechércher"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
|
||||
],
|
||||
"overall_confidence": 0.9,
|
||||
})
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[{"role": "bouton", "text": "Rechercher"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == True
|
||||
|
||||
def test_no_text_elements_trivially_match(self):
|
||||
"""Elements without text key → no presence check needed → trivially OK."""
|
||||
ocr = mock_ocr_client_factory(["Dashboard"])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_screen_match(
|
||||
"/tmp/test.png",
|
||||
[{"role": "page"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == True
|
||||
|
||||
|
||||
# ── verify_before / verify_after tests ────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyBeforeAfter:
|
||||
def test_verify_before_match(self):
|
||||
ocr = mock_ocr_client_factory(["Login", "Password", "Connexion"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.85},
|
||||
],
|
||||
"overall_confidence": 0.85,
|
||||
})
|
||||
result = verify_before(
|
||||
"/tmp/login.png",
|
||||
[{"role": "champ", "text": "Login"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
context="page login",
|
||||
)
|
||||
assert result.match == True
|
||||
|
||||
def test_verify_after_higher_threshold(self):
|
||||
"""verify_after uses min_confidence=0.8. VLM returns 0.75 → mismatch."""
|
||||
ocr = mock_ocr_client_factory(["Dashboard"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "page", "confidence": 0.75},
|
||||
],
|
||||
"overall_confidence": 0.75,
|
||||
})
|
||||
result = verify_after(
|
||||
"/tmp/dashboard.png",
|
||||
[{"role": "page", "text": "Dashboard"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
# 0.75 < 0.8 threshold → role mismatch
|
||||
assert result.match == False
|
||||
|
||||
def test_verify_after_passes_at_0_8(self):
|
||||
ocr = mock_ocr_client_factory(["Dashboard"])
|
||||
vlm = mock_vlm_client_factory({
|
||||
"confirmed": [
|
||||
{"index": 1, "role_confirmed": True, "actual_role": "page", "confidence": 0.85},
|
||||
],
|
||||
"overall_confidence": 0.85,
|
||||
})
|
||||
result = verify_after(
|
||||
"/tmp/dashboard.png",
|
||||
[{"role": "page", "text": "Dashboard"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
)
|
||||
assert result.match == True
|
||||
|
||||
def test_verify_before_ocr_missing(self):
|
||||
"""Pre-action: expected text not on screen → mismatch (can't proceed)."""
|
||||
ocr = mock_ocr_client_factory(["Accueil"])
|
||||
vlm = mock_vlm_client_factory({})
|
||||
result = verify_before(
|
||||
"/tmp/page.png",
|
||||
[{"role": "bouton", "text": "Connexion"}],
|
||||
ocr_client=ocr,
|
||||
vlm_client=vlm,
|
||||
context="pre-login",
|
||||
)
|
||||
assert result.match == False
|
||||
assert "OCR presence" in result.reason
|
||||
|
||||
|
||||
# ── OcrPresenceResult dataclass tests ─────────────────────────────────
|
||||
|
||||
|
||||
class TestOcrPresenceResult:
|
||||
def test_presence_ratio_all_found(self):
|
||||
result = OcrPresenceResult(
|
||||
found_texts={"Login": "Login", "Password": "Password"},
|
||||
missing=[],
|
||||
all_found=True,
|
||||
)
|
||||
assert result.presence_ratio == 1.0
|
||||
|
||||
def test_presence_ratio_half_found(self):
|
||||
result = OcrPresenceResult(
|
||||
found_texts={"Login": "Login", "Password": ""},
|
||||
missing=["champ: Password"],
|
||||
all_found=False,
|
||||
)
|
||||
assert result.presence_ratio == 0.5
|
||||
|
||||
def test_presence_ratio_empty(self):
|
||||
result = OcrPresenceResult(
|
||||
found_texts={},
|
||||
missing=[],
|
||||
all_found=True,
|
||||
)
|
||||
assert result.presence_ratio == 1.0
|
||||
65
tests/unit/test_vlm_client.py
Normal file
65
tests/unit/test_vlm_client.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Tests du client vLLM serveur (image + prompt -> texte).
|
||||
|
||||
Le POST réseau est injectable (`post_fn`) → testable sans vLLM. Sert de
|
||||
`vlm_client` à `extract_dossier_from_image` dans le handler runtime.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from core.extraction.vlm_client import build_chat_body, img_data_url, make_vllm_client
|
||||
|
||||
|
||||
def _png(tmp_path, w=2000, h=1000):
|
||||
from PIL import Image
|
||||
p = tmp_path / "x.png"
|
||||
Image.new("RGB", (w, h), (255, 255, 255)).save(p)
|
||||
return str(p)
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, code, payload=None, text=""):
|
||||
self.status_code = code
|
||||
self._p = payload or {}
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
|
||||
def test_img_data_url_downscale(tmp_path):
|
||||
url = img_data_url(_png(tmp_path), max_w=1280)
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_build_chat_body_structure(tmp_path):
|
||||
body = build_chat_body(_png(tmp_path), "PROMPT", model="M", max_tokens=1500, max_w=1280)
|
||||
assert body["model"] == "M"
|
||||
assert body["max_tokens"] == 1500
|
||||
# thinking désactivé (vérifié hier : think=on -> vide/lent)
|
||||
assert body["chat_template_kwargs"]["enable_thinking"] is False
|
||||
content = body["messages"][0]["content"]
|
||||
assert any(c["type"] == "image_url" for c in content)
|
||||
assert any(c["type"] == "text" and c["text"] == "PROMPT" for c in content)
|
||||
|
||||
|
||||
def test_client_retourne_content(tmp_path):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["body"] = json
|
||||
return _Resp(200, {"choices": [{"message": {"content": "REPONSE"}}]})
|
||||
|
||||
client = make_vllm_client(model="M", post_fn=fake_post)
|
||||
out = client(_png(tmp_path), "PROMPT")
|
||||
assert out == "REPONSE"
|
||||
assert "/v1/chat/completions" in captured["url"]
|
||||
assert captured["body"]["messages"][0]["content"][1]["text"] == "PROMPT"
|
||||
|
||||
|
||||
def test_client_erreur_status_leve(tmp_path):
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
return _Resp(500, text="boom")
|
||||
|
||||
client = make_vllm_client(post_fn=fake_post)
|
||||
with pytest.raises(RuntimeError):
|
||||
client(_png(tmp_path), "PROMPT")
|
||||
44
tests/unit/test_workflow_graph_machine_id.py
Normal file
44
tests/unit/test_workflow_graph_machine_id.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Test de non-régression : conservation du machine_id au round-trip to_dict/from_dict.
|
||||
|
||||
Bug : les workflows listés via /api/v1/traces/stream/workflows étaient tous
|
||||
attribués à machine_id="default" alors que les sessions portaient le bon
|
||||
machine_id (lea-*). Cause : to_dict ne sérialisait pas l'attribut d'instance
|
||||
`_machine_id` et from_dict ne le reposait pas (il dormait dans
|
||||
metadata['machine_id']). list_workflows tombait alors sur le fallback "default".
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from core.models.workflow_graph import Workflow
|
||||
|
||||
|
||||
def _make_minimal_workflow(machine_id: str) -> Workflow:
|
||||
"""Construit un workflow minimal portant un machine_id dans ses métadonnées."""
|
||||
now = datetime.now().isoformat()
|
||||
return Workflow.from_dict({
|
||||
"workflow_id": "wf-test",
|
||||
"name": "wf-test",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"safety_rules": {},
|
||||
"stats": {},
|
||||
"learning": {},
|
||||
"entry_nodes": [],
|
||||
"end_nodes": [],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"metadata": {"machine_id": machine_id},
|
||||
})
|
||||
|
||||
|
||||
def test_machine_id_preserved_after_to_dict_from_dict_round_trip():
|
||||
"""Un workflow doit conserver son machine_id après un round-trip de (dé)sérialisation."""
|
||||
wf = _make_minimal_workflow("lea-poste-3")
|
||||
# Simule l'étiquetage runtime fait par le stream_processor
|
||||
wf._machine_id = "lea-poste-3"
|
||||
|
||||
restored = Workflow.from_dict(wf.to_dict())
|
||||
|
||||
# Invariant : le machine_id survit au round-trip (comme le fait list_workflows)
|
||||
assert getattr(restored, "_machine_id", "default") == "lea-poste-3"
|
||||
@@ -321,6 +321,70 @@ class ExecutionStep(db.Model):
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction — « dossier patient extrait » (brique 2)
|
||||
#
|
||||
# ⚠️ CANAL EXTRACTION ≠ canal apprentissage. Ces tables conservent les
|
||||
# VRAIES données patient (patient_ref, ExtractedField.value) : c'est le but,
|
||||
# constituer le dossier. Elles NE doivent PAS être anonymisées/tokenisées
|
||||
# (à l'inverse du canal apprentissage, cf. pii_sanitizer). Aucun appel
|
||||
# d'assainissement PII ne doit cibler ces colonnes.
|
||||
#
|
||||
# Sémantique de preuve réutilisée de contracts/evidence.py (VWBEvidence) :
|
||||
# screenshot_ref ≈ screenshot, screen_bbox/bbox ≈ highlight_box, confidence
|
||||
# ≈ confidence_score, created_at ≈ timestamp.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExtractionJob(db.Model):
|
||||
"""Dossier patient extrait — racine d'une session d'extraction."""
|
||||
__tablename__ = 'extraction_jobs'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
patient_ref = db.Column(db.String(255), nullable=True) # donnée patient EN CLAIR (volontaire)
|
||||
source_session_id = db.Column(db.String(64), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
# status: 'needs_review' (revue humaine requise) | 'complete' (validé)
|
||||
status = db.Column(db.String(32), default='needs_review')
|
||||
|
||||
tables = db.relationship('ExtractedTable', backref='job', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ExtractionJob {self.id}: {self.status}>'
|
||||
|
||||
|
||||
class ExtractedTable(db.Model):
|
||||
"""Tableau extrait d'un écran (preuve : screenshot_ref + screen_bbox)."""
|
||||
__tablename__ = 'extracted_tables'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
job_id = db.Column(db.String(64), db.ForeignKey('extraction_jobs.id'), nullable=False)
|
||||
screen_bbox = db.Column(db.JSON, nullable=True) # {x, y, width, height}
|
||||
screenshot_ref = db.Column(db.String(512), nullable=True)
|
||||
|
||||
fields = db.relationship('ExtractedField', backref='table', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ExtractedTable {self.id}>'
|
||||
|
||||
|
||||
class ExtractedField(db.Model):
|
||||
"""Cellule extraite (donnée patient EN CLAIR) + preuve bbox/confidence."""
|
||||
__tablename__ = 'extracted_fields'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
table_id = db.Column(db.String(64), db.ForeignKey('extracted_tables.id'), nullable=False)
|
||||
row = db.Column(db.Integer, nullable=True)
|
||||
col = db.Column(db.Integer, nullable=True)
|
||||
value = db.Column(db.Text, nullable=True) # valeur patient EN CLAIR (volontaire)
|
||||
bbox = db.Column(db.JSON, nullable=True) # {x, y, width, height}
|
||||
confidence = db.Column(db.Float, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ExtractedField {self.id}: r{self.row}c{self.col}>'
|
||||
|
||||
|
||||
# Session active (en mémoire, pas en DB)
|
||||
class SessionState:
|
||||
"""État de la session utilisateur (en mémoire)"""
|
||||
|
||||
Binary file not shown.
@@ -295,6 +295,175 @@ def convert_learned_to_vwb_steps(
|
||||
return workflow_meta, steps, warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pont R1 — import IDEMPOTENT d'un workflow core en DB VWB (create-or-update)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Marqueur stable de signature de trajectoire embarqué dans `Workflow.description`.
|
||||
# Le modèle `Workflow` n'a PAS (encore) de colonne dédiée ; on réutilise donc le
|
||||
# même mécanisme que la route GET /learned-workflows existante, qui détecte les
|
||||
# imports via `description.contains(...)`. La clé d'idempotence est la SIGNATURE
|
||||
# DE TRAJECTOIRE (cf. core.execution.trajectory_signature), pas le workflow_id de
|
||||
# session (qui change à chaque ré-apprentissage du même parcours).
|
||||
_TRAJ_SIG_MARKER = "[traj_sig:"
|
||||
|
||||
|
||||
def _trajectory_signature_marker(signature: str) -> str:
|
||||
"""Marqueur texte stable à embarquer dans la description."""
|
||||
return f"{_TRAJ_SIG_MARKER}{signature}]"
|
||||
|
||||
|
||||
def _find_existing_learned_workflow(db_session, signature: str):
|
||||
"""Cherche un Workflow `source='learned_import'` de MÊME signature de trajectoire.
|
||||
|
||||
Ne considère QUE les imports appris : les workflows `source='manual'`
|
||||
(démo Urgence_aiva, etc.) sont volontairement exclus du filtre et donc
|
||||
jamais candidats à la mise à jour.
|
||||
"""
|
||||
from db.models import Workflow # import paresseux (modèles liés au runtime VWB)
|
||||
|
||||
marker = _trajectory_signature_marker(signature)
|
||||
return (
|
||||
db_session.query(Workflow)
|
||||
.filter(
|
||||
Workflow.source == "learned_import",
|
||||
Workflow.description.contains(marker),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def import_core_workflow_to_db(
|
||||
core_dict: Dict[str, Any],
|
||||
*,
|
||||
machine_id: str,
|
||||
source_session_id: str,
|
||||
db_session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Importe un workflow core (JSON appris par Léa) en DB VWB, de façon IDEMPOTENTE.
|
||||
|
||||
Fusion par **signature de trajectoire** (décision produit Dom 23/06) :
|
||||
1. calcule `sig = workflow_trajectory_signature(core_dict)` ;
|
||||
2. cherche un `Workflow` `source='learned_import'` de même signature ;
|
||||
3. si trouvé → **skip** (pas de doublon, le workflow existant fait foi) ;
|
||||
sinon → crée `Workflow` + `Step`(s) via `convert_learned_to_vwb_steps`.
|
||||
|
||||
Le nouveau workflow est marqué `source='learned_import'`,
|
||||
`review_status='pending_review'`. Les workflows `source='manual'` ne sont
|
||||
JAMAIS touchés (cf. `_find_existing_learned_workflow`).
|
||||
|
||||
Args:
|
||||
core_dict: workflow core (dict JSON) tel qu'appris/sauvegardé.
|
||||
machine_id: poste d'origine (traçabilité, stocké en tag/description).
|
||||
source_session_id: session ayant produit ce workflow (traçabilité).
|
||||
db_session: session SQLAlchemy (l'app appelante détient le contexte).
|
||||
|
||||
Returns:
|
||||
dict {created: bool, workflow_id: str, signature: str, warnings: list}.
|
||||
`created=False` quand un workflow de même trajectoire existait déjà.
|
||||
|
||||
Note (non-wiring) : cette unité n'est PAS branchée au worker live ni à la
|
||||
route HTTP existante ; voir le rapport de câblage R1.
|
||||
"""
|
||||
# Imports paresseux : garde le module léger et évite un import core/DB au load.
|
||||
from core.execution.trajectory_signature import workflow_trajectory_signature
|
||||
from db.models import Workflow, Step
|
||||
|
||||
signature = workflow_trajectory_signature(core_dict)
|
||||
|
||||
# --- Idempotence : même trajectoire déjà importée ? → skip (pas de doublon) ---
|
||||
existing = _find_existing_learned_workflow(db_session, signature)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"Workflow appris déjà présent (signature %s…) → import ignoré, "
|
||||
"réutilisation de %s",
|
||||
signature[:12],
|
||||
existing.id,
|
||||
)
|
||||
return {
|
||||
"created": False,
|
||||
"workflow_id": existing.id,
|
||||
"signature": signature,
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
# --- Création : conversion core → steps VWB, puis écriture DB ---
|
||||
wf_meta, steps_list, warnings = convert_learned_to_vwb_steps(core_dict)
|
||||
|
||||
current_name = (wf_meta.get("name") or "").strip()
|
||||
if current_name.lower() in {"", "unnamed workflow", "workflow importé"}:
|
||||
# Réutilise la dérivation de nom de la route HTTP si disponible.
|
||||
try:
|
||||
from api_v3.learned_workflows import _derive_default_name
|
||||
wf_meta["name"] = _derive_default_name(core_dict)
|
||||
except Exception: # pragma: no cover - fallback minimal
|
||||
wf_meta["name"] = f"Léa import — {datetime.now():%Y-%m-%d %H:%M}"
|
||||
|
||||
wf_id = f"wf_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# La signature est embarquée dans la description (clé d'idempotence) + une
|
||||
# ligne de traçabilité (workflow core d'origine).
|
||||
base_desc = (wf_meta.get("description") or "").strip()
|
||||
description = "\n\n".join(
|
||||
part
|
||||
for part in (
|
||||
base_desc,
|
||||
f"[Importé depuis workflow appris: {core_dict.get('workflow_id', '')}]",
|
||||
_trajectory_signature_marker(signature),
|
||||
)
|
||||
if part
|
||||
)
|
||||
|
||||
workflow = Workflow(
|
||||
id=wf_id,
|
||||
name=wf_meta["name"],
|
||||
description=description,
|
||||
source="learned_import",
|
||||
review_status="pending_review",
|
||||
)
|
||||
|
||||
# Tags : conserver ceux du workflow + traçabilité machine/session.
|
||||
tags = list(wf_meta.get("tags") or [])
|
||||
tags.extend([f"machine:{machine_id}", f"session:{source_session_id}"])
|
||||
workflow.tags = tags
|
||||
|
||||
db_session.add(workflow)
|
||||
|
||||
for step_data in steps_list:
|
||||
step = Step(
|
||||
id=f"step_{uuid.uuid4().hex[:12]}",
|
||||
workflow_id=wf_id,
|
||||
action_type=step_data["action_type"],
|
||||
order=step_data["order"],
|
||||
position_x=step_data.get("position_x", 0),
|
||||
position_y=step_data.get("position_y", 0),
|
||||
label=step_data.get("label", step_data["action_type"]),
|
||||
)
|
||||
params = dict(step_data.get("parameters", {}))
|
||||
# L'image d'ancre (_anchor_image_base64) est laissée dans params : la
|
||||
# persistance d'ancre (VisualAnchor + fichier) reste pilotée par la route
|
||||
# HTTP existante. Cette unité se concentre sur l'idempotence Workflow/Step.
|
||||
step.parameters = params
|
||||
db_session.add(step)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
logger.info(
|
||||
"Workflow appris importé (R1) : %s (signature %s…, %d étapes, machine %s)",
|
||||
wf_id,
|
||||
signature[:12],
|
||||
len(steps_list),
|
||||
machine_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"created": True,
|
||||
"workflow_id": wf_id,
|
||||
"signature": signature,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _convert_compound_substep(
|
||||
sub_type: str, sub: Dict[str, Any], parent_target: Dict[str, Any]
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test TDD — Extraction (brique 2) : modèle « dossier patient extrait ».
|
||||
|
||||
Objectif : valider les 3 modèles métier d'extraction (absents avant cette brique) :
|
||||
ExtractionJob → ExtractedTable → ExtractedField
|
||||
avec leurs relations, cascade, et le `status` ∈ {complete, needs_review}.
|
||||
|
||||
⚠️ CANAL EXTRACTION ≠ canal apprentissage : ici on conserve les **vraies
|
||||
données patient** (le but est de constituer le dossier). Pas d'anonymisation.
|
||||
Le test pose donc une valeur patient en clair et vérifie qu'elle est restituée
|
||||
telle quelle.
|
||||
|
||||
Isolation (même pattern que test_import_core_workflow_to_db.py) :
|
||||
- pas d'app Flask complète (`app.py`), pas de socketio/blueprints ;
|
||||
- `db` partagé (`db.models.db`) lié à une SQLite **en mémoire**.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
_BACKEND = Path(__file__).resolve().parent.parent.parent # .../visual_workflow_builder/backend
|
||||
_ROOT = _BACKEND.parent.parent # .../rpa_vision_v3
|
||||
for p in (str(_ROOT), str(_BACKEND)):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from db.models import db # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_app():
|
||||
"""App Flask minimale liée à une SQLite en mémoire, schéma créé."""
|
||||
app = Flask("test_extraction_models")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
def test_extraction_job_table_field_chain(db_app):
|
||||
"""Chaîne complète Job → Table → Field, relations + status par défaut."""
|
||||
from db.models import ExtractionJob, ExtractedTable, ExtractedField
|
||||
|
||||
with db_app.app_context():
|
||||
job = ExtractionJob(
|
||||
id="job_001",
|
||||
patient_ref="MOREL Catherine", # donnée patient EN CLAIR (canal extraction)
|
||||
source_session_id="sess_extract_001",
|
||||
)
|
||||
|
||||
table = ExtractedTable(
|
||||
id="tbl_001",
|
||||
job=job,
|
||||
screen_bbox={"x": 10, "y": 20, "width": 300, "height": 120},
|
||||
screenshot_ref="data/extract/sess_extract_001/screen_0.png",
|
||||
)
|
||||
field = ExtractedField(
|
||||
id="fld_001",
|
||||
table=table,
|
||||
row=0,
|
||||
col=1,
|
||||
value="1975-04-12",
|
||||
bbox={"x": 110, "y": 22, "width": 80, "height": 18},
|
||||
confidence=0.94,
|
||||
)
|
||||
|
||||
db.session.add(job)
|
||||
db.session.commit()
|
||||
|
||||
# status par défaut appliqué à l'INSERT = needs_review (revue humaine requise)
|
||||
assert job.status == "needs_review"
|
||||
|
||||
# Relations descendantes
|
||||
assert job.tables.count() == 1
|
||||
assert job.tables.first().fields.count() == 1
|
||||
|
||||
# Relations remontantes
|
||||
f = ExtractedField.query.get("fld_001")
|
||||
assert f.table.job.patient_ref == "MOREL Catherine" # patient conservé en clair
|
||||
assert f.value == "1975-04-12"
|
||||
assert f.bbox["width"] == 80
|
||||
assert f.confidence == pytest.approx(0.94)
|
||||
assert f.table.screen_bbox["height"] == 120
|
||||
|
||||
|
||||
def test_status_complete_is_accepted(db_app):
|
||||
"""`status` accepte 'complete' (extraction validée)."""
|
||||
from db.models import ExtractionJob
|
||||
|
||||
with db_app.app_context():
|
||||
job = ExtractionJob(id="job_ok", patient_ref="DUPONT Jean", status="complete")
|
||||
db.session.add(job)
|
||||
db.session.commit()
|
||||
assert ExtractionJob.query.get("job_ok").status == "complete"
|
||||
assert job.created_at is not None and isinstance(job.created_at, datetime)
|
||||
|
||||
|
||||
def test_cascade_delete_removes_children(db_app):
|
||||
"""Supprimer le Job supprime tables + fields (cascade, pas d'orphelins)."""
|
||||
from db.models import ExtractionJob, ExtractedTable, ExtractedField
|
||||
|
||||
with db_app.app_context():
|
||||
job = ExtractionJob(id="job_del", patient_ref="X")
|
||||
table = ExtractedTable(id="tbl_del", job=job, screen_bbox={}, screenshot_ref="s.png")
|
||||
ExtractedField(id="fld_del", table=table, row=0, col=0, value="v",
|
||||
bbox={}, confidence=0.5)
|
||||
db.session.add(job)
|
||||
db.session.commit()
|
||||
|
||||
db.session.delete(job)
|
||||
db.session.commit()
|
||||
|
||||
assert ExtractionJob.query.count() == 0
|
||||
assert ExtractedTable.query.count() == 0
|
||||
assert ExtractedField.query.count() == 0
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test TDD — pont R1 : `import_core_workflow_to_db` IDEMPOTENT.
|
||||
|
||||
Objectif chantier R1 : une session auto-apprise (workflow core JSON) doit pouvoir
|
||||
être (ré)importée en DB VWB **sans créer de doublon**. La fusion se fait par
|
||||
**signature de trajectoire** (cf. `core.execution.trajectory_signature`) — décision
|
||||
produit Dom 23/06 : create-or-update, pas create-only.
|
||||
|
||||
Coeur du test (b) : ré-importer le MÊME core_dict 2× → toujours UN seul workflow.
|
||||
|
||||
Ce module est volontairement isolé du chemin live :
|
||||
- il ne démarre PAS l'app Flask complète (`app.py`) ;
|
||||
- il lie le `db` partagé (`db.models.db`) à une SQLite **en mémoire** via une
|
||||
app Flask minimale, même pattern que `tests/conftest.py` mais sans dépendances
|
||||
lourdes (pas de socketio, pas de blueprints).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
# --- Chemins : racine projet (pour core.*) + backend (pour db.models, services.*) ---
|
||||
_BACKEND = Path(__file__).resolve().parent.parent.parent # .../visual_workflow_builder/backend
|
||||
_ROOT = _BACKEND.parent.parent # .../rpa_vision_v3
|
||||
for p in (str(_ROOT), str(_BACKEND)):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from db.models import db, Workflow, Step # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures DB en mémoire (app Flask minimale, db partagé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def db_app():
|
||||
"""App Flask minimale liée à une SQLite en mémoire, schéma créé."""
|
||||
app = Flask("test_import_core_workflow")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures de workflows core (format JSON appris par Léa)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _core_workflow_bloc_notes() -> dict:
|
||||
"""Workflow core minimal : ouvrir Bloc-notes et saisir du texte."""
|
||||
return {
|
||||
"workflow_id": "wf_sess_bloc_notes_001",
|
||||
"name": "Léa Bloc-notes",
|
||||
"entry_nodes": ["n1"],
|
||||
"nodes": [
|
||||
{"node_id": "n1", "name": "Bureau"},
|
||||
{"node_id": "n2", "name": "Bloc-notes ouvert"},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"edge_id": "e1",
|
||||
"from_node": "n1",
|
||||
"to_node": "n2",
|
||||
"action": {
|
||||
"type": "mouse_click",
|
||||
"target": {"by_text": "Bloc-notes", "by_role": "ocr"},
|
||||
"parameters": {"button": "left"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"edge_id": "e2",
|
||||
"from_node": "n2",
|
||||
"to_node": "n2",
|
||||
"action": {
|
||||
"type": "text_input",
|
||||
"target": {"by_text": "zone de saisie"},
|
||||
"parameters": {"text": "bonjour"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _core_workflow_calculatrice() -> dict:
|
||||
"""Workflow core d'une trajectoire DIFFÉRENTE (calculatrice)."""
|
||||
return {
|
||||
"workflow_id": "wf_sess_calc_002",
|
||||
"name": "Léa Calculatrice",
|
||||
"entry_nodes": ["n1"],
|
||||
"nodes": [
|
||||
{"node_id": "n1", "name": "Bureau"},
|
||||
{"node_id": "n2", "name": "Calculatrice"},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"edge_id": "e1",
|
||||
"from_node": "n1",
|
||||
"to_node": "n2",
|
||||
"action": {
|
||||
"type": "mouse_click",
|
||||
"target": {"by_text": "Calculatrice", "by_role": "ocr"},
|
||||
"parameters": {"button": "left"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_import_creates_workflow_with_steps(db_app):
|
||||
"""(a) Un core_dict → 1 workflow VWB créé, avec ses steps."""
|
||||
from services.learned_workflow_bridge import import_core_workflow_to_db
|
||||
|
||||
with db_app.app_context():
|
||||
result = import_core_workflow_to_db(
|
||||
_core_workflow_bloc_notes(),
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
source_session_id="sess_bloc_notes_001",
|
||||
db_session=db.session,
|
||||
)
|
||||
|
||||
assert result["created"] is True
|
||||
wf_id = result["workflow_id"]
|
||||
assert wf_id
|
||||
|
||||
wf = Workflow.query.get(wf_id)
|
||||
assert wf is not None
|
||||
assert wf.source == "learned_import"
|
||||
assert wf.review_status == "pending_review"
|
||||
|
||||
steps = Step.query.filter_by(workflow_id=wf_id).all()
|
||||
assert len(steps) >= 1, "le workflow importé doit avoir au moins une étape"
|
||||
|
||||
|
||||
def test_reimport_same_workflow_is_idempotent(db_app):
|
||||
"""(b) COEUR — ré-importer le MÊME core_dict 2× → toujours 1 seul workflow."""
|
||||
from services.learned_workflow_bridge import import_core_workflow_to_db
|
||||
|
||||
with db_app.app_context():
|
||||
first = import_core_workflow_to_db(
|
||||
_core_workflow_bloc_notes(),
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
source_session_id="sess_bloc_notes_001",
|
||||
db_session=db.session,
|
||||
)
|
||||
second = import_core_workflow_to_db(
|
||||
_core_workflow_bloc_notes(),
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
source_session_id="sess_bloc_notes_001_rerun",
|
||||
db_session=db.session,
|
||||
)
|
||||
|
||||
# UN seul workflow en DB malgré deux imports
|
||||
assert Workflow.query.count() == 1, "ré-import du même parcours = pas de doublon"
|
||||
|
||||
# Le second pointe vers le même workflow, marqué non-créé
|
||||
assert first["workflow_id"] == second["workflow_id"]
|
||||
assert second["created"] is False
|
||||
|
||||
|
||||
def test_different_trajectories_create_two_workflows(db_app):
|
||||
"""(c) Deux trajectoires différentes → 2 workflows distincts."""
|
||||
from services.learned_workflow_bridge import import_core_workflow_to_db
|
||||
|
||||
with db_app.app_context():
|
||||
r1 = import_core_workflow_to_db(
|
||||
_core_workflow_bloc_notes(),
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
source_session_id="sess_a",
|
||||
db_session=db.session,
|
||||
)
|
||||
r2 = import_core_workflow_to_db(
|
||||
_core_workflow_calculatrice(),
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
source_session_id="sess_b",
|
||||
db_session=db.session,
|
||||
)
|
||||
|
||||
assert Workflow.query.count() == 2
|
||||
assert r1["workflow_id"] != r2["workflow_id"]
|
||||
|
||||
|
||||
def test_manual_workflow_is_never_touched(db_app):
|
||||
"""(d) Un workflow source='manual' préexistant n'est jamais modifié.
|
||||
|
||||
Même si, par construction, il partageait la signature d'un parcours importé,
|
||||
la fonction ne doit cibler QUE les workflows source='learned_import'.
|
||||
"""
|
||||
from services.learned_workflow_bridge import import_core_workflow_to_db
|
||||
|
||||
with db_app.app_context():
|
||||
# Workflow manuel préexistant (démo Urgence_aiva) — intouchable
|
||||
manual = Workflow(
|
||||
id="wf_manual_demo",
|
||||
name="Urgence_aiva_demo",
|
||||
description="Démo manuelle critique",
|
||||
source="manual",
|
||||
review_status="approved",
|
||||
)
|
||||
db.session.add(manual)
|
||||
db.session.commit()
|
||||
manual_name_before = manual.name
|
||||
manual_review_before = manual.review_status
|
||||
|
||||
import_core_workflow_to_db(
|
||||
_core_workflow_bloc_notes(),
|
||||
machine_id="DESKTOP-TEST_windows",
|
||||
source_session_id="sess_x",
|
||||
db_session=db.session,
|
||||
)
|
||||
|
||||
manual_after = Workflow.query.get("wf_manual_demo")
|
||||
assert manual_after.name == manual_name_before
|
||||
assert manual_after.review_status == manual_review_before
|
||||
assert manual_after.source == "manual"
|
||||
@@ -2231,16 +2231,37 @@ _LEA_ZIP_TEMPLATE_FULL = BASE_PATH / "deploy" / "build" / "Lea_full_v1.0.1.zip"
|
||||
_LEA_ZIP_TEMPLATE_LEGACY = BASE_PATH / "deploy" / "Lea_v1.0.0.zip"
|
||||
|
||||
|
||||
def _resolve_lea_zip_template():
|
||||
def _resolve_lea_zip_template(
|
||||
full_path: Path = _LEA_ZIP_TEMPLATE_FULL,
|
||||
legacy_path: Path = _LEA_ZIP_TEMPLATE_LEGACY,
|
||||
) -> "Path | None":
|
||||
"""Résout le ZIP à servir, à la volée (le complet peut être buildé
|
||||
après le démarrage du dashboard). Préfère le ZIP complet autoportant ;
|
||||
retombe sur l'ancien ZIP léger uniquement s'il existe.
|
||||
Retourne None si aucun template n'est présent.
|
||||
|
||||
Les paramètres full_path/legacy_path sont injectables pour les tests
|
||||
(évite de démarrer Flask — DETTE-013).
|
||||
|
||||
⚠️ DETTE-024 : si le ZIP complet est absent, un avertissement est loggué
|
||||
explicitement pour ne pas masquer silencieusement l'absence du full.
|
||||
"""
|
||||
if _LEA_ZIP_TEMPLATE_FULL.exists():
|
||||
return _LEA_ZIP_TEMPLATE_FULL
|
||||
if _LEA_ZIP_TEMPLATE_LEGACY.exists():
|
||||
return _LEA_ZIP_TEMPLATE_LEGACY
|
||||
if full_path.exists():
|
||||
return full_path
|
||||
# Full absent → fallback sur le legacy, mais log d'avertissement obligatoire.
|
||||
if legacy_path.exists():
|
||||
try:
|
||||
api_logger.warning(
|
||||
"DETTE-024 — ZIP Léa complet autoportant ABSENT (%s) ; "
|
||||
"fallback sur ZIP léger NON autoportant (%s). "
|
||||
"Le poste recevra un ZIP sans Python embarqué → non installable "
|
||||
"sans Python système. Exécuter deploy/build_package_full.sh.",
|
||||
full_path,
|
||||
legacy_path,
|
||||
)
|
||||
except Exception:
|
||||
pass # api_logger pas encore initialisé au module load (import tardif ok)
|
||||
return legacy_path
|
||||
return None
|
||||
|
||||
|
||||
@@ -2389,8 +2410,14 @@ def download_agent_package(machine_id):
|
||||
# Sécurité : l'auth Basic est déjà gérée par before_request
|
||||
|
||||
# 1. Résoudre + vérifier que le ZIP template existe (à la volée)
|
||||
# _resolve_lea_zip_template() logue un WARNING si le full est absent (DETTE-024).
|
||||
zip_template = _resolve_lea_zip_template()
|
||||
if zip_template is None:
|
||||
api_logger.error(
|
||||
"download_agent_package(%s) — aucun ZIP template présent. "
|
||||
"full=%s legacy=%s",
|
||||
machine_id, _LEA_ZIP_TEMPLATE_FULL, _LEA_ZIP_TEMPLATE_LEGACY,
|
||||
)
|
||||
return jsonify({
|
||||
'error': 'ZIP template introuvable',
|
||||
'detail': (
|
||||
@@ -2399,6 +2426,13 @@ def download_agent_package(machine_id):
|
||||
'autoportant) ou deploy/build_package.sh (ZIP léger).'
|
||||
),
|
||||
}), 500
|
||||
is_full = (zip_template == _LEA_ZIP_TEMPLATE_FULL)
|
||||
zip_kind = "full-autoportant" if is_full else "legacy-léger⚠️"
|
||||
api_logger.info(
|
||||
"download_agent_package(%s) — ZIP sélectionné : %s (%s, %d Ko)",
|
||||
machine_id, zip_template.name, zip_kind,
|
||||
zip_template.stat().st_size // 1024,
|
||||
)
|
||||
|
||||
# 2. Vérifier que le machine_id est enregistré
|
||||
agent = _fetch_fleet_agent(machine_id)
|
||||
|
||||
Reference in New Issue
Block a user