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,864 @@
"""
Tests de robustesse visuelle — Grounding VLM qwen2.5vl:7b.
Objectifs :
1. Reproductibilité : même screenshot + même cible → même résultat 10 fois
2. Robustesse Citrix : screenshots compressés JPEG qualité 15-25 → ça marche
3. Mesure de variance : coordonnées stables à < 5% de l'écran
Architecture des coordonnées qwen2.5vl :
- Format bbox_2d : [x1, y1, x2, y2] en pixels relatifs à l'image envoyée
- Pour une image 1280x800, X va de 0 à 1280 et Y de 0 à 800
- Normalisation : diviser par les dimensions de l'image (pas par 1000)
Calibration mesurée (5 avril 2026) sur screenshots 1280x800 :
- shot_0001/Rechercher (taskbar) : cx=0.458, cy=0.789
- shot_0001/agent_v1 (dossier) : cx=0.247, cy=0.201
- shot_0004/Fichier (menu) : cx=0.095, cy=0.086
- shot_0004/Modifier (menu) : cx=0.142, cy=0.085
- shot_0004/Ceci est un test.txt (onglet): cx=0.694, cy=0.053
- shot_0004/Close X (Bloc-notes) : cx=0.990, cy=0.041
- shot_0014/Google search (centre) : cx=0.539, cy=0.389
- shot_0014/Gmail (haut-droite) : cx=0.913, cy=0.130
"""
import base64
import io
import json
import re
import statistics
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
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
# Nombre de répétitions pour les tests de reproductibilité
_N_REPEATS = 10
# Tolérance de variance maximale (en fraction de l'écran, 0.05 = 5%)
_MAX_VARIANCE = 0.05
# Taux de détection minimal (X sur _N_REPEATS)
_MIN_DETECTION_RATE = 8
# =========================================================================
# Utilitaires
# =========================================================================
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 _degrade_citrix(screenshot_b64: str, quality: int = 20) -> str:
"""Simuler compression Citrix : JPEG qualité basse puis retour PNG b64."""
from PIL import Image
raw = base64.b64decode(screenshot_b64)
img = Image.open(io.BytesIO(raw))
# Compression JPEG qualité basse (simulation Citrix)
buf_jpg = io.BytesIO()
img.save(buf_jpg, "JPEG", quality=quality)
buf_jpg.seek(0)
citrix_img = Image.open(buf_jpg)
# Re-encoder en PNG pour l'envoi au VLM
buf_png = io.BytesIO()
citrix_img.save(buf_png, "PNG")
return base64.b64encode(buf_png.getvalue()).decode()
def _grounding_vlm(
screenshot_b64: str,
element_description: str,
timeout: int = 60,
) -> Tuple[Optional[float], Optional[float], Optional[List[int]], str]:
"""Appeler qwen2.5vl pour localiser un élément.
Retourne (cx, cy, [x1,y1,x2,y2], raw_content).
cx et cy sont les centres normalisés sur la grille 1000.
"""
import requests
try:
resp = requests.post(
"http://localhost:11434/api/chat",
json={
"model": "qwen2.5vl:7b",
"messages": [
{
"role": "user",
"content": (
f"Detect the element '{element_description}' "
f"with a bounding box."
),
"images": [screenshot_b64],
}
],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 100},
},
timeout=timeout,
)
except requests.ConnectionError:
pytest.skip("Ollama non disponible (localhost:11434)")
except requests.Timeout:
pytest.skip("qwen2.5vl timeout — modèle en cours de chargement ?")
content = resp.json().get("message", {}).get("content", "")
# Parser bbox_2d depuis la réponse JSON
# qwen2.5vl retourne des coordonnées en pixels relatifs à l'image envoyée,
# PAS sur une grille 1000x1000.
bbox_match = re.search(
r'"bbox_2d"\s*:\s*\[(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\]',
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
return cx, cy, [x1, y1, x2, y2], content
return None, None, None, content
def _run_n_times(
screenshot_b64: str,
description: str,
n: int = _N_REPEATS,
delay: float = 0.2,
) -> List[Dict]:
"""Exécuter le grounding N fois et collecter les résultats."""
results = []
for i in range(n):
cx, cy, bbox, raw = _grounding_vlm(screenshot_b64, description)
results.append({
"run": i + 1,
"cx": cx,
"cy": cy,
"bbox": bbox,
"detected": cx is not None,
"raw": raw,
})
if i < n - 1:
time.sleep(delay)
return results
def _compute_stats(results: List[Dict]) -> Dict:
"""Calculer les statistiques de détection et de variance."""
detected = [r for r in results if r["detected"]]
n_total = len(results)
n_detected = len(detected)
stats = {
"total": n_total,
"detected": n_detected,
"rate": n_detected / n_total if n_total > 0 else 0,
"rate_str": f"{n_detected}/{n_total}",
}
if n_detected >= 2:
xs = [r["cx"] for r in detected]
ys = [r["cy"] for r in detected]
stats.update({
"x_min": min(xs),
"x_max": max(xs),
"x_mean": statistics.mean(xs),
"x_range": max(xs) - min(xs),
"x_stdev": statistics.stdev(xs) if n_detected >= 2 else 0,
"y_min": min(ys),
"y_max": max(ys),
"y_mean": statistics.mean(ys),
"y_range": max(ys) - min(ys),
"y_stdev": statistics.stdev(ys) if n_detected >= 2 else 0,
})
elif n_detected == 1:
stats.update({
"x_min": detected[0]["cx"],
"x_max": detected[0]["cx"],
"x_mean": detected[0]["cx"],
"x_range": 0,
"x_stdev": 0,
"y_min": detected[0]["cy"],
"y_max": detected[0]["cy"],
"y_mean": detected[0]["cy"],
"y_range": 0,
"y_stdev": 0,
})
return stats
def _assert_reproducible(
stats: Dict,
element_name: str,
min_rate: int = _MIN_DETECTION_RATE,
max_var: float = _MAX_VARIANCE,
):
"""Vérifier la reproductibilité : taux de détection + variance faible."""
assert stats["detected"] >= min_rate, (
f"{element_name}: seulement {stats['rate_str']} détections "
f"(minimum requis: {min_rate}/{stats['total']})"
)
if stats["detected"] >= 2:
assert stats["x_range"] < max_var, (
f"{element_name}: variance X trop élevée: "
f"{stats['x_range']:.4f} (max={max_var})"
)
assert stats["y_range"] < max_var, (
f"{element_name}: variance Y trop élevée: "
f"{stats['y_range']:.4f} (max={max_var})"
)
def _assert_in_zone(
stats: Dict,
zone: Dict[str, float],
element_name: str,
):
"""Vérifier que la position moyenne est dans la zone attendue."""
assert stats["detected"] >= 1, f"{element_name}: aucune détection"
cx = stats["x_mean"]
cy = stats["y_mean"]
assert zone["x_min"] <= cx <= zone["x_max"], (
f"{element_name}: X moyen {cx:.4f} hors zone "
f"[{zone['x_min']:.2f}-{zone['x_max']:.2f}]"
)
assert zone["y_min"] <= cy <= zone["y_max"], (
f"{element_name}: Y moyen {cy:.4f} hors zone "
f"[{zone['y_min']:.2f}-{zone['y_max']:.2f}]"
)
# =========================================================================
# Zones calibrées (mesurées le 5 avril 2026)
# =========================================================================
CALIBRATED_ZONES = {
# shot_0001 — Explorateur de fichiers Windows
"rechercher_taskbar": {
"x_min": 0.40, "x_max": 0.60,
"y_min": 0.74, "y_max": 0.84,
},
"agent_v1_folder": {
"x_min": 0.18, "x_max": 0.30,
"y_min": 0.16, "y_max": 0.26,
},
# shot_0004 — Bloc-notes avec onglets
"fichier_menu": {
"x_min": 0.06, "x_max": 0.13,
"y_min": 0.06, "y_max": 0.12,
},
"modifier_menu": {
"x_min": 0.11, "x_max": 0.18,
"y_min": 0.06, "y_max": 0.12,
},
"ceci_est_un_test_tab": {
"x_min": 0.65, "x_max": 0.75,
"y_min": 0.03, "y_max": 0.08,
},
"close_x_notepad": {
"x_min": 0.95, "x_max": 1.02,
"y_min": 0.02, "y_max": 0.06,
},
# shot_0014 — Google Chrome
"google_search_bar": {
"x_min": 0.48, "x_max": 0.60,
"y_min": 0.35, "y_max": 0.43,
},
"gmail_link": {
"x_min": 0.87, "x_max": 0.95,
"y_min": 0.10, "y_max": 0.16,
},
}
# =========================================================================
# Tests de reproductibilité — 10 appels consécutifs
# =========================================================================
@pytest.mark.visual
class TestReproductibilite:
"""Chaque test appelle le VLM 10 fois et vérifie la cohérence.
Critères de réussite :
- Au moins 8/10 détections
- Variance des coordonnées < 5% de l'écran sur chaque axe
- Position moyenne dans la zone calibrée
"""
# -- shot_0001 : Explorateur de fichiers --
@pytest.fixture(scope="class")
def shot_0001(self):
return _load_screenshot("shot_0001_full.png")
def test_rechercher_10_fois(self, shot_0001):
"""Le VLM trouve 'Rechercher' au même endroit 10 fois de suite."""
results = _run_n_times(
shot_0001,
"the 'Rechercher' search text in the Windows taskbar at the bottom",
)
stats = _compute_stats(results)
_assert_reproducible(stats, "Rechercher (taskbar)")
_assert_in_zone(stats, CALIBRATED_ZONES["rechercher_taskbar"], "Rechercher")
# Afficher le résumé pour le rapport
print(f"\n [Rechercher] {stats['rate_str']} détections, "
f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], "
f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]")
def test_agent_v1_10_fois(self, shot_0001):
"""Le VLM trouve le dossier 'agent_v1' au même endroit 10 fois."""
results = _run_n_times(
shot_0001,
"the folder named 'agent_v1' in the file list",
)
stats = _compute_stats(results)
_assert_reproducible(stats, "agent_v1 (dossier)")
_assert_in_zone(stats, CALIBRATED_ZONES["agent_v1_folder"], "agent_v1")
print(f"\n [agent_v1] {stats['rate_str']} détections, "
f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], "
f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]")
def test_close_x_explorateur_10_fois(self, shot_0001):
"""Le bouton X de la fenêtre maximisée : overflow X attendu.
Ce test vérifie que le VLM détecte bien le bouton X de façon cohérente.
Sur les fenêtres maximisées (1280px de large), les coordonnées X
dépassent la grille 1000 normalisée (cx > 1.0).
Note : le VLM peut parfois confondre le bouton X de la fenêtre avec
celui de l'onglet (ambiguïté multiple close buttons). On vérifie
que la majorité des détections ciblent le bon bouton.
"""
results = _run_n_times(
shot_0001,
"the X close button of the 'Lea' window",
)
# Vérifier que le VLM détecte bien quelque chose
detected = [r for r in results if r["detected"]]
assert len(detected) >= _MIN_DETECTION_RATE, (
f"Close X: seulement {len(detected)}/{len(results)} détections"
)
# Classer les détections : overflow (bouton fenêtre) vs non-overflow (bouton onglet)
overflows = [r for r in detected if r["cx"] > 1.0]
non_overflows = [r for r in detected if r["cx"] <= 1.0]
# Au moins 60% des détections doivent viser le bouton fenêtre (overflow)
assert len(overflows) >= len(detected) * 0.6, (
f"Close X: seulement {len(overflows)}/{len(detected)} en overflow. "
f"Ambiguïté avec bouton onglet ({len(non_overflows)} non-overflow)."
)
# Vérifier la cohérence des détections overflow (le cluster principal)
if len(overflows) >= 2:
bboxes = [r["bbox"] for r in overflows]
x1s = [b[0] for b in bboxes]
y1s = [b[1] for b in bboxes]
assert max(x1s) - min(x1s) < 20, (
f"Close X overflow: x1 trop variable: {min(x1s)}-{max(x1s)}"
)
assert max(y1s) - min(y1s) < 20, (
f"Close X overflow: y1 trop variable: {min(y1s)}-{max(y1s)}"
)
print(f"\n [Close X Explorer] {len(detected)}/{len(results)} détections, "
f"{len(overflows)} overflow (fenêtre), {len(non_overflows)} non-overflow (onglet). "
f"cx_mean_overflow={statistics.mean([r['cx'] for r in overflows]):.4f}" if overflows else "")
# -- shot_0004 : Bloc-notes --
@pytest.fixture(scope="class")
def shot_0004(self):
return _load_screenshot("shot_0004_full.png")
def test_fichier_10_fois(self, shot_0004):
"""Le VLM trouve le menu 'Fichier' au même endroit 10 fois."""
results = _run_n_times(
shot_0004,
"the 'Fichier' menu item in the menu bar",
)
stats = _compute_stats(results)
_assert_reproducible(stats, "Fichier (menu)")
_assert_in_zone(stats, CALIBRATED_ZONES["fichier_menu"], "Fichier")
print(f"\n [Fichier] {stats['rate_str']} détections, "
f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], "
f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]")
def test_modifier_10_fois(self, shot_0004):
"""Le VLM trouve le menu 'Modifier' au même endroit 10 fois."""
results = _run_n_times(
shot_0004,
"the 'Modifier' menu item in the menu bar",
)
stats = _compute_stats(results)
_assert_reproducible(stats, "Modifier (menu)")
_assert_in_zone(stats, CALIBRATED_ZONES["modifier_menu"], "Modifier")
print(f"\n [Modifier] {stats['rate_str']} détections, "
f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], "
f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]")
def test_ceci_est_un_test_10_fois(self, shot_0004):
"""Le VLM trouve l'onglet 'Ceci est un test.txt' au même endroit 10 fois."""
results = _run_n_times(
shot_0004,
"the tab labeled 'Ceci est un test.txt'",
)
stats = _compute_stats(results)
_assert_reproducible(stats, "Ceci est un test.txt (onglet)")
_assert_in_zone(stats, CALIBRATED_ZONES["ceci_est_un_test_tab"], "Ceci est un test.txt")
print(f"\n [Ceci est un test.txt] {stats['rate_str']} détections, "
f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], "
f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]")
# -- shot_0014 : Google Chrome --
@pytest.fixture(scope="class")
def shot_0014(self):
return _load_screenshot("shot_0014_full.png")
def test_google_search_10_fois(self, shot_0014):
"""Le VLM trouve la barre de recherche Google au même endroit 10 fois."""
results = _run_n_times(
shot_0014,
"the Google search bar 'Rechercher sur Google ou saisir une URL'",
)
stats = _compute_stats(results)
_assert_reproducible(stats, "Recherche Google")
_assert_in_zone(stats, CALIBRATED_ZONES["google_search_bar"], "Recherche Google")
print(f"\n [Google search] {stats['rate_str']} détections, "
f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], "
f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]")
def test_gmail_10_fois(self, shot_0014):
"""Le VLM trouve le lien Gmail au même endroit 10 fois."""
results = _run_n_times(
shot_0014,
"the 'Gmail' link at the top of the page",
)
stats = _compute_stats(results)
_assert_reproducible(stats, "Gmail")
_assert_in_zone(stats, CALIBRATED_ZONES["gmail_link"], "Gmail")
print(f"\n [Gmail] {stats['rate_str']} détections, "
f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], "
f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]")
# =========================================================================
# Tests de robustesse Citrix — JPEG dégradé
# =========================================================================
@pytest.mark.visual
class TestCitrixRobustesse:
"""Vérifier que le grounding fonctionne sur des images compressées.
Simule un environnement Citrix/RDP avec compression JPEG qualité 15-25.
Compare les résultats original vs dégradé.
"""
@pytest.fixture(scope="class")
def shots_original(self):
return {
"shot_0001": _load_screenshot("shot_0001_full.png"),
"shot_0004": _load_screenshot("shot_0004_full.png"),
"shot_0014": _load_screenshot("shot_0014_full.png"),
}
@pytest.fixture(scope="class")
def shots_citrix(self, shots_original):
return {
name: _degrade_citrix(b64, quality=20)
for name, b64 in shots_original.items()
}
def _compare_original_vs_citrix(
self,
original_b64: str,
citrix_b64: str,
description: str,
element_name: str,
zone: Dict,
n_runs: int = 5,
) -> Dict:
"""Comparer les résultats original vs Citrix."""
# 5 runs sur l'original
results_orig = _run_n_times(original_b64, description, n=n_runs, delay=0.2)
stats_orig = _compute_stats(results_orig)
# 5 runs sur le Citrix
results_citrix = _run_n_times(citrix_b64, description, n=n_runs, delay=0.2)
stats_citrix = _compute_stats(results_citrix)
return {
"original": stats_orig,
"citrix": stats_citrix,
}
def test_rechercher_citrix(self, shots_original, shots_citrix):
"""'Rechercher' détecté malgré compression JPEG Q20."""
comp = self._compare_original_vs_citrix(
shots_original["shot_0001"],
shots_citrix["shot_0001"],
"the 'Rechercher' search text in the Windows taskbar at the bottom",
"Rechercher",
CALIBRATED_ZONES["rechercher_taskbar"],
)
# Au moins 3/5 détections sur Citrix
assert comp["citrix"]["detected"] >= 3, (
f"Citrix Rechercher: seulement {comp['citrix']['rate_str']} détections"
)
# Position dans la zone calibrée
if comp["citrix"]["detected"] >= 1:
_assert_in_zone(comp["citrix"], CALIBRATED_ZONES["rechercher_taskbar"], "Rechercher (Citrix)")
print(f"\n [Rechercher Citrix] orig={comp['original']['rate_str']}, "
f"citrix={comp['citrix']['rate_str']}")
def test_fichier_citrix(self, shots_original, shots_citrix):
"""Menu 'Fichier' détecté malgré compression JPEG Q20."""
comp = self._compare_original_vs_citrix(
shots_original["shot_0004"],
shots_citrix["shot_0004"],
"the 'Fichier' menu item in the menu bar",
"Fichier",
CALIBRATED_ZONES["fichier_menu"],
)
assert comp["citrix"]["detected"] >= 3, (
f"Citrix Fichier: seulement {comp['citrix']['rate_str']} détections"
)
if comp["citrix"]["detected"] >= 1:
_assert_in_zone(comp["citrix"], CALIBRATED_ZONES["fichier_menu"], "Fichier (Citrix)")
print(f"\n [Fichier Citrix] orig={comp['original']['rate_str']}, "
f"citrix={comp['citrix']['rate_str']}")
def test_ceci_est_un_test_citrix(self, shots_original, shots_citrix):
"""Onglet 'Ceci est un test.txt' détecté malgré compression JPEG Q20."""
comp = self._compare_original_vs_citrix(
shots_original["shot_0004"],
shots_citrix["shot_0004"],
"the tab labeled 'Ceci est un test.txt'",
"Ceci est un test.txt",
CALIBRATED_ZONES["ceci_est_un_test_tab"],
)
assert comp["citrix"]["detected"] >= 3, (
f"Citrix tab: seulement {comp['citrix']['rate_str']} détections"
)
if comp["citrix"]["detected"] >= 1:
_assert_in_zone(
comp["citrix"],
CALIBRATED_ZONES["ceci_est_un_test_tab"],
"Ceci est un test.txt (Citrix)",
)
print(f"\n [Ceci est un test.txt Citrix] orig={comp['original']['rate_str']}, "
f"citrix={comp['citrix']['rate_str']}")
def test_google_search_citrix(self, shots_original, shots_citrix):
"""Barre de recherche Google détectée malgré compression JPEG Q20."""
comp = self._compare_original_vs_citrix(
shots_original["shot_0014"],
shots_citrix["shot_0014"],
"the Google search bar 'Rechercher sur Google ou saisir une URL'",
"Recherche Google",
CALIBRATED_ZONES["google_search_bar"],
)
assert comp["citrix"]["detected"] >= 3, (
f"Citrix Google: seulement {comp['citrix']['rate_str']} détections"
)
if comp["citrix"]["detected"] >= 1:
_assert_in_zone(
comp["citrix"],
CALIBRATED_ZONES["google_search_bar"],
"Recherche Google (Citrix)",
)
print(f"\n [Google search Citrix] orig={comp['original']['rate_str']}, "
f"citrix={comp['citrix']['rate_str']}")
def test_gmail_citrix(self, shots_original, shots_citrix):
"""Lien Gmail détecté malgré compression JPEG Q20."""
comp = self._compare_original_vs_citrix(
shots_original["shot_0014"],
shots_citrix["shot_0014"],
"the 'Gmail' link at the top of the page",
"Gmail",
CALIBRATED_ZONES["gmail_link"],
)
assert comp["citrix"]["detected"] >= 3, (
f"Citrix Gmail: seulement {comp['citrix']['rate_str']} détections"
)
if comp["citrix"]["detected"] >= 1:
_assert_in_zone(comp["citrix"], CALIBRATED_ZONES["gmail_link"], "Gmail (Citrix)")
print(f"\n [Gmail Citrix] orig={comp['original']['rate_str']}, "
f"citrix={comp['citrix']['rate_str']}")
# =========================================================================
# Tests de dégradation progressive — qualité JPEG 50 → 15 → 5
# =========================================================================
@pytest.mark.visual
class TestDegradationProgressive:
"""Mesurer à partir de quelle qualité JPEG le grounding échoue."""
@pytest.fixture(scope="class")
def shot_0004(self):
return _load_screenshot("shot_0004_full.png")
def test_fichier_degradation_progressive(self, shot_0004):
"""Fichier menu : tester JPEG Q50, Q25, Q15, Q10, Q5."""
qualities = [50, 25, 15, 10, 5]
results_by_quality = {}
for q in qualities:
degraded = _degrade_citrix(shot_0004, quality=q)
results = _run_n_times(
degraded,
"the 'Fichier' menu item in the menu bar",
n=3,
delay=0.2,
)
stats = _compute_stats(results)
results_by_quality[q] = stats
# Afficher le rapport de dégradation
print("\n === Dégradation progressive : Fichier menu ===")
for q in qualities:
s = results_by_quality[q]
zone_ok = ""
if s["detected"] >= 1:
cx = s["x_mean"]
cy = s["y_mean"]
z = CALIBRATED_ZONES["fichier_menu"]
in_zone = z["x_min"] <= cx <= z["x_max"] and z["y_min"] <= cy <= z["y_max"]
zone_ok = " (in zone)" if in_zone else f" (HORS zone: {cx:.3f},{cy:.3f})"
print(f" Q{q:>2}: {s['rate_str']} détections{zone_ok}")
# Au moins Q50 et Q25 doivent fonctionner
assert results_by_quality[50]["detected"] >= 2, "Q50 devrait fonctionner"
assert results_by_quality[25]["detected"] >= 2, "Q25 devrait fonctionner"
# =========================================================================
# Rapport final — exécuté en dernier, résume tout
# =========================================================================
@pytest.mark.visual
class TestRapportFinal:
"""Rapport complet des capacités de grounding VLM.
Ce test exécute une batterie de détections et produit un rapport
structuré avec taux de détection, variance, et comparaison Citrix.
"""
def test_rapport_complet(self):
"""Génère le rapport final de robustesse du grounding VLM."""
from PIL import Image
shots = {
"shot_0001": _load_screenshot("shot_0001_full.png"),
"shot_0004": _load_screenshot("shot_0004_full.png"),
"shot_0014": _load_screenshot("shot_0014_full.png"),
}
targets = [
("shot_0001", "Rechercher (taskbar)",
"the 'Rechercher' search text in the Windows taskbar at the bottom",
CALIBRATED_ZONES["rechercher_taskbar"]),
("shot_0001", "agent_v1 (dossier)",
"the folder named 'agent_v1' in the file list",
CALIBRATED_ZONES["agent_v1_folder"]),
("shot_0004", "Fichier (menu)",
"the 'Fichier' menu item in the menu bar",
CALIBRATED_ZONES["fichier_menu"]),
("shot_0004", "Modifier (menu)",
"the 'Modifier' menu item in the menu bar",
CALIBRATED_ZONES["modifier_menu"]),
("shot_0004", "Ceci est un test.txt (onglet)",
"the tab labeled 'Ceci est un test.txt'",
CALIBRATED_ZONES["ceci_est_un_test_tab"]),
("shot_0004", "Close X (Bloc-notes)",
"the close button X of the Notepad window at the top right",
CALIBRATED_ZONES["close_x_notepad"]),
("shot_0014", "Recherche Google (barre)",
"the Google search bar 'Rechercher sur Google ou saisir une URL'",
CALIBRATED_ZONES["google_search_bar"]),
("shot_0014", "Gmail (lien)",
"the 'Gmail' link at the top of the page",
CALIBRATED_ZONES["gmail_link"]),
]
report_lines = [
"",
"=" * 80,
"RAPPORT DE ROBUSTESSE — Grounding VLM qwen2.5vl:7b",
f"Date: {time.strftime('%Y-%m-%d %H:%M:%S')}",
f"Screenshots: 1280x800 (3 images, {len(targets)} cibles)",
f"Répétitions: 5 par cible (original + Citrix Q20)",
"=" * 80,
"",
"--- ORIGINAL (PNG) ---",
f"{'Élément':<35} {'Taux':>6} {'X moy':>8} {'Y moy':>8} "
f"{'Var X':>8} {'Var Y':>8} {'Zone':>6}",
"-" * 80,
]
all_original_stats = []
all_citrix_stats = []
for shot_name, label, desc, zone in targets:
# Original : 5 runs
results_orig = _run_n_times(shots[shot_name], desc, n=5, delay=0.2)
stats_orig = _compute_stats(results_orig)
all_original_stats.append((label, stats_orig, zone))
in_zone = "?"
if stats_orig["detected"] >= 1:
cx, cy = stats_orig["x_mean"], stats_orig["y_mean"]
ok = (zone["x_min"] <= cx <= zone["x_max"]
and zone["y_min"] <= cy <= zone["y_max"])
in_zone = "OK" if ok else "HORS"
report_lines.append(
f"{label:<35} {stats_orig['rate_str']:>6} "
f"{stats_orig.get('x_mean', 0):>8.4f} "
f"{stats_orig.get('y_mean', 0):>8.4f} "
f"{stats_orig.get('x_range', 0):>8.4f} "
f"{stats_orig.get('y_range', 0):>8.4f} "
f"{in_zone:>6}"
)
report_lines.extend([
"",
"--- CITRIX (JPEG Q20) ---",
f"{'Élément':<35} {'Taux':>6} {'X moy':>8} {'Y moy':>8} "
f"{'Var X':>8} {'Var Y':>8} {'Zone':>6} {'Écart orig':>10}",
"-" * 90,
])
for i, (shot_name, label, desc, zone) in enumerate(targets):
citrix_b64 = _degrade_citrix(shots[shot_name], quality=20)
results_citrix = _run_n_times(citrix_b64, desc, n=5, delay=0.2)
stats_citrix = _compute_stats(results_citrix)
all_citrix_stats.append((label, stats_citrix, zone))
in_zone = "?"
ecart = "N/A"
if stats_citrix["detected"] >= 1:
cx, cy = stats_citrix["x_mean"], stats_citrix["y_mean"]
ok = (zone["x_min"] <= cx <= zone["x_max"]
and zone["y_min"] <= cy <= zone["y_max"])
in_zone = "OK" if ok else "HORS"
# Calculer l'écart avec l'original
orig_stats = all_original_stats[i][1]
if orig_stats["detected"] >= 1:
dx = abs(cx - orig_stats["x_mean"])
dy = abs(cy - orig_stats["y_mean"])
ecart = f"{dx:.4f}/{dy:.4f}"
report_lines.append(
f"{label:<35} {stats_citrix['rate_str']:>6} "
f"{stats_citrix.get('x_mean', 0):>8.4f} "
f"{stats_citrix.get('y_mean', 0):>8.4f} "
f"{stats_citrix.get('x_range', 0):>8.4f} "
f"{stats_citrix.get('y_range', 0):>8.4f} "
f"{in_zone:>6} {ecart:>10}"
)
# Résumé
orig_total = sum(s["detected"] for _, s, _ in all_original_stats)
orig_max = sum(s["total"] for _, s, _ in all_original_stats)
citrix_total = sum(s["detected"] for _, s, _ in all_citrix_stats)
citrix_max = sum(s["total"] for _, s, _ in all_citrix_stats)
orig_in_zone = sum(
1 for _, s, z in all_original_stats
if s["detected"] >= 1
and z["x_min"] <= s["x_mean"] <= z["x_max"]
and z["y_min"] <= s["y_mean"] <= z["y_max"]
)
citrix_in_zone = sum(
1 for _, s, z in all_citrix_stats
if s["detected"] >= 1
and z["x_min"] <= s["x_mean"] <= z["x_max"]
and z["y_min"] <= s["y_mean"] <= z["y_max"]
)
# Éléments non fiables
unreliable = []
for label, s, _ in all_original_stats:
if s["detected"] < 3:
unreliable.append(f"{label} (taux {s['rate_str']})")
elif s.get("x_range", 0) >= _MAX_VARIANCE or s.get("y_range", 0) >= _MAX_VARIANCE:
unreliable.append(
f"{label} (variance X={s.get('x_range', 0):.4f} "
f"Y={s.get('y_range', 0):.4f})"
)
report_lines.extend([
"",
"=" * 80,
"RÉSUMÉ",
"=" * 80,
f" Détection original : {orig_total}/{orig_max} "
f"({orig_total/orig_max*100:.0f}%)",
f" Détection Citrix Q20: {citrix_total}/{citrix_max} "
f"({citrix_total/citrix_max*100:.0f}%)",
f" Positionnement correct (original) : {orig_in_zone}/{len(all_original_stats)}",
f" Positionnement correct (Citrix) : {citrix_in_zone}/{len(all_citrix_stats)}",
"",
])
if unreliable:
report_lines.append(" ÉLÉMENTS NON FIABLES :")
for u in unreliable:
report_lines.append(f" - {u}")
else:
report_lines.append(" Tous les éléments sont fiables.")
report_lines.extend([
"",
" NOTES TECHNIQUES :",
" - qwen2.5vl bbox_2d retourne des pixels relatifs à l'image envoyée",
" - Normalisation : diviser par les dimensions de l'image (W, H)",
" - temperature=0.1 donne une variance < 0.003 typiquement",
"=" * 80,
])
report = "\n".join(report_lines)
print(report)
# Le test réussit si au moins 80% des détections originales fonctionnent
assert orig_total / orig_max >= 0.80, (
f"Taux de détection global trop bas: {orig_total}/{orig_max}"
)