feat: pipeline complet MACRO/MÉSO/MICRO — Critic, Observer, Policy, Recovery, Learning, Audit Trail, TaskPlanner

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>
This commit is contained in:
Dom
2026-04-09 21:03:25 +02:00
parent 72a9651b94
commit 99041f0117
21 changed files with 7810 additions and 110 deletions

View File

@@ -0,0 +1,445 @@
"""
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"