Architecture 3 niveaux implémentée et testée (137 tests unitaires + 21 visuels) : MÉSO (acteur intelligent) : - P0 Critic : vérification sémantique post-action via gemma4 (replay_verifier.py) - P1 Observer : pré-analyse écran avant chaque action (api_stream.py /pre_analyze) - P2 Grounding/Policy : séparation localisation (grounding.py) et décision (policy.py) - P3 Recovery : rollback automatique Ctrl+Z/Escape/Alt+F4 (recovery.py) - P4 Learning : apprentissage runtime avec boucle de consolidation (replay_learner.py) MACRO (planificateur) : - TaskPlanner : comprend les ordres en langage naturel via gemma4 (task_planner.py) - Contexte métier TIM/CIM-10 pour les hôpitaux (domain_context.py) - Endpoint POST /api/v1/task pour l'exécution par instruction Traçabilité : - Audit trail complet avec 18 champs par action (audit_trail.py) - Endpoints GET /audit/history, /audit/summary, /audit/export (CSV) Grounding : - Fix parsing bbox_2d qwen2.5vl (pixels relatifs, pas grille 1000x1000) - Benchmarks visuels sur captures réelles (3 approches : baseline, zoom, Citrix) - Reproductibilité validée : variance < 0.008 sur 10 itérations Sécurité : - Tokens de production retirés du code source → .env.local - Secret key aléatoire si non configuré - Suppression logs qui leakent les tokens Résultats : 80% de replay (vs 12.5% avant), 100% détection visuelle Citrix JPEG Q20 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
446 lines
17 KiB
Python
446 lines
17 KiB
Python
"""
|
|
Tests visuels sur captures d'écran réelles — Grounding benchmark.
|
|
|
|
Vérifie que le système trouve les bons éléments UI sur des screenshots
|
|
Windows réels. Pas besoin de VM — juste les images et le serveur.
|
|
|
|
Chaque test :
|
|
1. Charge un screenshot réel (sessions enregistrées)
|
|
2. Demande au serveur de localiser un élément (via /resolve_target)
|
|
3. Vérifie que les coordonnées retournées sont dans la zone attendue
|
|
|
|
C'est l'apprentissage de l'environnement Windows :
|
|
- Rechercher un programme
|
|
- Fermer/réduire/agrandir une fenêtre
|
|
- Naviguer dans les onglets
|
|
- Utiliser les menus
|
|
"""
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
_ROOT = str(Path(__file__).resolve().parents[2])
|
|
if _ROOT not in sys.path:
|
|
sys.path.insert(0, _ROOT)
|
|
|
|
# Répertoire des screenshots de test
|
|
_SHOTS_DIR = Path(_ROOT) / "data/training/live_sessions/DESKTOP-ST3VBSD_windows/sess_20260404T135010_cec5c8/shots"
|
|
|
|
# Résolution des screenshots
|
|
_SCREEN_W = 1280
|
|
_SCREEN_H = 800
|
|
|
|
|
|
def _load_screenshot(name: str) -> Optional[str]:
|
|
"""Charger un screenshot en base64."""
|
|
path = _SHOTS_DIR / name
|
|
if not path.is_file():
|
|
pytest.skip(f"Screenshot {name} non disponible")
|
|
return base64.b64encode(path.read_bytes()).decode()
|
|
|
|
|
|
def _in_zone(x_pct: float, y_pct: float, zone: dict) -> bool:
|
|
"""Vérifier si un point est dans une zone attendue.
|
|
|
|
zone = {"x_min": 0.3, "x_max": 0.5, "y_min": 0.9, "y_max": 1.0}
|
|
"""
|
|
return (
|
|
zone["x_min"] <= x_pct <= zone["x_max"]
|
|
and zone["y_min"] <= y_pct <= zone["y_max"]
|
|
)
|
|
|
|
|
|
def _resolve_via_server(
|
|
screenshot_b64: str,
|
|
target_spec: dict,
|
|
strict: bool = True,
|
|
) -> Optional[dict]:
|
|
"""Résoudre une cible visuellement via le VLM (qwen2.5vl grounding direct).
|
|
|
|
Appelle qwen2.5vl directement pour le grounding (bbox_2d).
|
|
Si le VLM ne trouve pas, essaie aussi via l'endpoint serveur.
|
|
"""
|
|
import requests
|
|
import re
|
|
|
|
# ── Stratégie 1 : Grounding VLM direct (qwen2.5vl) ──
|
|
by_text = target_spec.get("by_text", "")
|
|
vlm_desc = target_spec.get("vlm_description", "")
|
|
search_text = by_text or vlm_desc
|
|
|
|
if search_text:
|
|
try:
|
|
prompt = f"Detect the element '{search_text}' with a bounding box."
|
|
resp = requests.post(
|
|
"http://localhost:11434/api/chat",
|
|
json={
|
|
"model": "qwen2.5vl:7b",
|
|
"messages": [{"role": "user", "content": prompt, "images": [screenshot_b64]}],
|
|
"stream": False,
|
|
"options": {"temperature": 0.0, "num_predict": 100},
|
|
},
|
|
timeout=30,
|
|
)
|
|
if resp.ok:
|
|
content = resp.json().get("message", {}).get("content", "")
|
|
# Parser bbox_2d — qwen2.5vl retourne des pixels relatifs à l'image,
|
|
# PAS une grille 1000x1000.
|
|
bbox_match = re.search(
|
|
r'"bbox_2d"\s*:\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]',
|
|
content,
|
|
)
|
|
if bbox_match:
|
|
x1, y1, x2, y2 = [int(bbox_match.group(i)) for i in range(1, 5)]
|
|
# Normaliser par les dimensions de l'image (pixels → 0-1)
|
|
cx = (x1 + x2) / 2 / _SCREEN_W
|
|
cy = (y1 + y2) / 2 / _SCREEN_H
|
|
if 0.0 <= cx <= 1.0 and 0.0 <= cy <= 1.0:
|
|
return {
|
|
"resolved": True,
|
|
"method": "vlm_grounding",
|
|
"x_pct": cx,
|
|
"y_pct": cy,
|
|
"score": 0.8,
|
|
"raw_bbox": [x1, y1, x2, y2],
|
|
}
|
|
except requests.Timeout:
|
|
pytest.skip("qwen2.5vl timeout — premier chargement ?")
|
|
except requests.ConnectionError:
|
|
pytest.skip("Ollama non disponible (localhost:11434)")
|
|
|
|
# ── Stratégie 2 : Endpoint serveur (fallback) ──
|
|
token = os.environ.get("RPA_API_TOKEN", "")
|
|
if not token:
|
|
env_file = Path(_ROOT) / ".env.local"
|
|
if env_file.is_file():
|
|
for line in env_file.read_text().splitlines():
|
|
if line.startswith("RPA_API_TOKEN="):
|
|
token = line.split("=", 1)[1].strip()
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
|
|
try:
|
|
resp = requests.post(
|
|
"http://localhost:5005/api/v1/traces/stream/replay/resolve_target",
|
|
json={
|
|
"session_id": "visual_test",
|
|
"screenshot_b64": screenshot_b64,
|
|
"target_spec": target_spec,
|
|
"screen_width": _SCREEN_W,
|
|
"screen_height": _SCREEN_H,
|
|
"fallback_x_pct": 0.5,
|
|
"fallback_y_pct": 0.5,
|
|
"strict_mode": strict,
|
|
},
|
|
headers=headers,
|
|
timeout=30,
|
|
)
|
|
if resp.ok:
|
|
data = resp.json()
|
|
if data.get("resolved"):
|
|
return data
|
|
except Exception:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def _assert_found_in_zone(result: dict, zone: dict, element_name: str):
|
|
"""Vérifier qu'un élément a été trouvé dans la zone attendue."""
|
|
assert result is not None, f"{element_name}: pas de réponse du serveur"
|
|
assert result.get("resolved"), (
|
|
f"{element_name}: non trouvé (reason={result.get('reason', '?')})"
|
|
)
|
|
x = result.get("x_pct", 0)
|
|
y = result.get("y_pct", 0)
|
|
assert _in_zone(x, y, zone), (
|
|
f"{element_name}: trouvé à ({x:.3f}, {y:.3f}) "
|
|
f"mais attendu dans zone x=[{zone['x_min']:.2f}-{zone['x_max']:.2f}] "
|
|
f"y=[{zone['y_min']:.2f}-{zone['y_max']:.2f}]"
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# shot_0001 : Explorateur de fichiers Windows
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.visual
|
|
class TestExplorateurFichiers:
|
|
"""Tests sur l'Explorateur de fichiers Windows (shot_0001)."""
|
|
|
|
@pytest.fixture
|
|
def screenshot(self):
|
|
return _load_screenshot("shot_0001_full.png")
|
|
|
|
def test_trouver_rechercher_taskbar(self, screenshot):
|
|
"""Trouver 'Rechercher' dans la barre des tâches."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Rechercher",
|
|
"vlm_description": "La barre de recherche Windows dans la barre des tâches, en bas de l'écran",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.20, "x_max": 0.50,
|
|
"y_min": 0.90, "y_max": 1.00,
|
|
}, "Rechercher (taskbar)")
|
|
|
|
def test_trouver_bouton_fermer_explorateur(self, screenshot):
|
|
"""Trouver le bouton X (fermer) de l'Explorateur."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton fermer (X) de la fenêtre Explorateur de fichiers, en haut à droite",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.90, "x_max": 1.00,
|
|
"y_min": 0.00, "y_max": 0.05,
|
|
}, "Bouton fermer (X)")
|
|
|
|
def test_trouver_bouton_reduire(self, screenshot):
|
|
"""Trouver le bouton réduire (-) de l'Explorateur."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton réduire (minimize, -) de la fenêtre, en haut à droite à gauche du X",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.85, "x_max": 0.95,
|
|
"y_min": 0.00, "y_max": 0.05,
|
|
}, "Bouton réduire (-)")
|
|
|
|
def test_trouver_dossier_agent_v1(self, screenshot):
|
|
"""Trouver le dossier 'agent_v1' dans la liste des fichiers."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "agent_v1",
|
|
"vlm_description": "Le dossier agent_v1 dans la liste des fichiers de l'Explorateur",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.05, "x_max": 0.50,
|
|
"y_min": 0.10, "y_max": 0.30,
|
|
}, "Dossier agent_v1")
|
|
|
|
def test_trouver_bouton_demarrer(self, screenshot):
|
|
"""Trouver le bouton Démarrer (Windows) dans la barre des tâches."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton Démarrer (logo Windows) dans la barre des tâches, en bas",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.18, "x_max": 0.30,
|
|
"y_min": 0.90, "y_max": 1.00,
|
|
}, "Bouton Démarrer")
|
|
|
|
def test_trouver_ce_pc(self, screenshot):
|
|
"""Trouver 'Ce PC' dans le panneau latéral de l'Explorateur."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Ce PC",
|
|
"vlm_description": "L'élément 'Ce PC' dans le panneau de navigation gauche de l'Explorateur",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.00, "x_max": 0.12,
|
|
"y_min": 0.40, "y_max": 0.55,
|
|
}, "Ce PC")
|
|
|
|
|
|
# =========================================================================
|
|
# shot_0004 : Bloc-notes avec onglets + Explorateur derrière
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.visual
|
|
class TestBlocNotesOnglets:
|
|
"""Tests sur le Bloc-notes avec plusieurs onglets (shot_0004)."""
|
|
|
|
@pytest.fixture
|
|
def screenshot(self):
|
|
return _load_screenshot("shot_0004_full.png")
|
|
|
|
def test_trouver_menu_fichier(self, screenshot):
|
|
"""Trouver le menu 'Fichier' du Bloc-notes."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Fichier",
|
|
"vlm_description": "Le menu Fichier dans la barre de menus du Bloc-notes",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.02, "x_max": 0.10,
|
|
"y_min": 0.08, "y_max": 0.15,
|
|
}, "Menu Fichier")
|
|
|
|
def test_trouver_onglet_ceci_est_un_test(self, screenshot):
|
|
"""Trouver l'onglet 'Ceci est un test.txt' dans le Bloc-notes."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Ceci est un test",
|
|
"vlm_description": "L'onglet 'Ceci est un test.txt' dans le Bloc-notes",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.40, "x_max": 0.70,
|
|
"y_min": 0.03, "y_max": 0.10,
|
|
}, "Onglet 'Ceci est un test.txt'")
|
|
|
|
def test_trouver_nouvel_onglet_plus(self, screenshot):
|
|
"""Trouver le bouton '+' pour ajouter un nouvel onglet."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton + (plus) pour ajouter un nouvel onglet dans le Bloc-notes, à droite des onglets",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.55, "x_max": 0.70,
|
|
"y_min": 0.03, "y_max": 0.10,
|
|
}, "Bouton + (nouvel onglet)")
|
|
|
|
def test_trouver_bouton_fermer_onglet(self, screenshot):
|
|
"""Trouver le X de fermeture de l'onglet actif."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton X pour fermer l'onglet actif 'Ceci est un test.txt' dans le Bloc-notes",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.50, "x_max": 0.65,
|
|
"y_min": 0.03, "y_max": 0.10,
|
|
}, "Fermer onglet (X)")
|
|
|
|
def test_trouver_menu_modifier(self, screenshot):
|
|
"""Trouver le menu 'Modifier' du Bloc-notes."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Modifier",
|
|
"vlm_description": "Le menu Modifier dans la barre de menus du Bloc-notes",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.07, "x_max": 0.16,
|
|
"y_min": 0.08, "y_max": 0.15,
|
|
}, "Menu Modifier")
|
|
|
|
def test_trouver_encodage_utf8(self, screenshot):
|
|
"""Trouver l'indicateur d'encodage UTF-8 dans la barre de statut."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "UTF-8",
|
|
"vlm_description": "L'indicateur d'encodage UTF-8 dans la barre de statut en bas du Bloc-notes",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.60, "x_max": 0.80,
|
|
"y_min": 0.90, "y_max": 1.00,
|
|
}, "UTF-8 (barre de statut)")
|
|
|
|
|
|
# =========================================================================
|
|
# shot_0014 : Google Chrome page d'accueil
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.visual
|
|
class TestGoogleChrome:
|
|
"""Tests sur Google Chrome avec page d'accueil (shot_0014)."""
|
|
|
|
@pytest.fixture
|
|
def screenshot(self):
|
|
return _load_screenshot("shot_0014_full.png")
|
|
|
|
def test_trouver_barre_recherche_google(self, screenshot):
|
|
"""Trouver la barre de recherche Google au centre."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Rechercher sur Google",
|
|
"vlm_description": "La barre de recherche Google au centre de la page d'accueil",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.10, "x_max": 0.60,
|
|
"y_min": 0.30, "y_max": 0.50,
|
|
}, "Barre recherche Google")
|
|
|
|
def test_trouver_barre_adresse_chrome(self, screenshot):
|
|
"""Trouver la barre d'adresse de Chrome en haut."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "La barre d'adresse URL de Google Chrome, en haut du navigateur",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.10, "x_max": 0.60,
|
|
"y_min": 0.05, "y_max": 0.15,
|
|
}, "Barre d'adresse Chrome")
|
|
|
|
def test_trouver_nouvel_onglet_chrome(self, screenshot):
|
|
"""Trouver le bouton '+' pour un nouvel onglet Chrome."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton + pour ouvrir un nouvel onglet dans Google Chrome",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.15, "x_max": 0.25,
|
|
"y_min": 0.00, "y_max": 0.06,
|
|
}, "Nouvel onglet (+) Chrome")
|
|
|
|
def test_trouver_fermer_chrome(self, screenshot):
|
|
"""Trouver le bouton X pour fermer Chrome."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton fermer (X) de la fenêtre Google Chrome, en haut à droite",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.90, "x_max": 1.00,
|
|
"y_min": 0.00, "y_max": 0.06,
|
|
}, "Fermer Chrome (X)")
|
|
|
|
def test_trouver_gmail(self, screenshot):
|
|
"""Trouver le lien Gmail sur la page d'accueil Google."""
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Gmail",
|
|
"vlm_description": "Le lien Gmail en haut à droite de la page Google",
|
|
})
|
|
_assert_found_in_zone(result, {
|
|
"x_min": 0.50, "x_max": 0.80,
|
|
"y_min": 0.10, "y_max": 0.20,
|
|
}, "Gmail")
|
|
|
|
|
|
# =========================================================================
|
|
# Tests transversaux (connaissances de base Windows)
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.visual
|
|
class TestConnaissancesWindowsBase:
|
|
"""Connaissances de base Windows que tout utilisateur connaît."""
|
|
|
|
def test_rechercher_programme_depuis_explorateur(self):
|
|
"""Depuis l'Explorateur, trouver la barre de recherche Windows."""
|
|
screenshot = _load_screenshot("shot_0001_full.png")
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Rechercher",
|
|
"vlm_description": "La barre de recherche dans la barre des tâches Windows en bas de l'écran",
|
|
})
|
|
assert result and result.get("resolved"), "Rechercher non trouvé"
|
|
|
|
def test_fermer_programme_depuis_blocnotes(self):
|
|
"""Depuis le Bloc-notes, trouver le bouton fermer."""
|
|
screenshot = _load_screenshot("shot_0004_full.png")
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton X pour fermer la fenêtre du Bloc-notes, en haut à droite",
|
|
})
|
|
assert result and result.get("resolved"), "Bouton fermer non trouvé"
|
|
|
|
def test_ajouter_onglet_blocnotes(self):
|
|
"""Ajouter un nouvel onglet dans le Bloc-notes."""
|
|
screenshot = _load_screenshot("shot_0004_full.png")
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "",
|
|
"vlm_description": "Le bouton + pour ajouter un nouvel onglet dans le Bloc-notes",
|
|
})
|
|
assert result and result.get("resolved"), "Bouton + non trouvé"
|
|
|
|
def test_rechercher_sur_google(self):
|
|
"""Taper dans la barre de recherche Google."""
|
|
screenshot = _load_screenshot("shot_0014_full.png")
|
|
result = _resolve_via_server(screenshot, {
|
|
"by_text": "Rechercher sur Google",
|
|
"vlm_description": "Le champ de recherche Google",
|
|
})
|
|
assert result and result.get("resolved"), "Recherche Google non trouvée"
|