feat: replay visuel VLM-first, worker séparé, package Léa, AZERTY, sécurité HTTPS

Pipeline replay visuel :
- VLM-first : l'agent appelle Ollama directement pour trouver les éléments
- Template matching en fallback (seuil strict 0.90)
- Stop immédiat si élément non trouvé (pas de clic blind)
- Replay depuis session brute (/replay-session) sans attendre le VLM
- Vérification post-action (screenshot hash avant/après)
- Gestion des popups (Enter/Escape/Tab+Enter)

Worker VLM séparé :
- run_worker.py : process distinct du serveur HTTP
- Communication par fichiers (_worker_queue.txt + _replay_active.lock)
- Le serveur HTTP ne fait plus jamais de VLM → toujours réactif
- Service systemd rpa-worker.service

Capture clavier :
- raw_keys (vk + press/release) pour replay exact indépendant du layout
- Fix AZERTY : ToUnicodeEx + AltGr detection
- Enter capturé comme \n, Tab comme \t
- Filtrage modificateurs seuls (Ctrl/Alt/Shift parasites)
- Fusion text_input consécutifs, dédup key_combo

Sécurité & Internet :
- HTTPS Let's Encrypt (lea.labs + vwb.labs.laurinebazin.design)
- Token API fixe dans .env.local
- HTTP Basic Auth sur VWB
- Security headers (HSTS, CSP, nosniff)
- CORS domaines publics, plus de wildcard

Infrastructure :
- DPI awareness (SetProcessDpiAwareness) Python + Rust
- Métadonnées système (dpi_scale, window_bounds, monitors, os_theme)
- Template matching multi-scale [0.5, 2.0]
- Résolution dynamique (plus de hardcode 1920x1080)
- VLM prefill fix (47x speedup, 3.5s au lieu de 180s)

Modules :
- core/auth/ : credential vault (Fernet AES), TOTP (RFC 6238), auth handler
- core/federation/ : LearningPack export/import anonymisé, FAISS global
- deploy/ : package Léa (config.txt, Lea.bat, install.bat, LISEZMOI.txt)

UX :
- Filtrage OS (VWB + Chat montrent que les workflows de l'OS courant)
- Bibliothèque persistante (cache local + SQLite)
- Clustering hybride (titre fenêtre + DBSCAN)
- EdgeConstraints + PostConditions peuplés
- GraphBuilder compound actions (toutes les frappes)

Agent Rust :
- Token Bearer auth (network.rs)
- sysinfo.rs (DPI, résolution, window bounds via Win32 API)
- config.txt lu automatiquement
- Support Chrome/Brave/Firefox (pas que Edge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-26 10:19:18 +01:00
parent fe5e0ba83d
commit d5deac3029
162 changed files with 25669 additions and 557 deletions

1
.gitignore vendored
View File

@@ -58,6 +58,7 @@ Thumbs.db
# === Secrets === # === Secrets ===
.env .env
.env.*
*.env *.env
credentials.json credentials.json
token.pickle token.pickle

View File

@@ -80,7 +80,13 @@ app = Flask(__name__)
import secrets as _secrets import secrets as _secrets
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', _secrets.token_hex(32)) app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', _secrets.token_hex(32))
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50 MB max upload (sécurité HIGH) app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50 MB max upload (sécurité HIGH)
socketio = SocketIO(app, cors_allowed_origins="*") _ALLOWED_ORIGINS = [
"http://localhost:3002",
"http://localhost:5002",
"https://vwb.labs.laurinebazin.design",
"https://lea.labs.laurinebazin.design",
]
socketio = SocketIO(app, cors_allowed_origins=_ALLOWED_ORIGINS)
# ============================================================ # ============================================================
@@ -92,6 +98,7 @@ def set_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
return response return response
@@ -116,6 +123,16 @@ STREAMING_SERVER_URL = os.environ.get(
"RPA_STREAMING_URL", "http://localhost:5005" "RPA_STREAMING_URL", "http://localhost:5005"
) )
# Token API pour le streaming server
_STREAMING_API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
def _streaming_headers() -> dict:
"""Headers d'authentification pour les appels au streaming server."""
headers = {"Content-Type": "application/json"}
if _STREAMING_API_TOKEN:
headers["Authorization"] = f"Bearer {_STREAMING_API_TOKEN}"
return headers
execution_status = { execution_status = {
"running": False, "running": False,
"workflow": None, "workflow": None,
@@ -135,6 +152,7 @@ def _fetch_connected_machines() -> List[Dict[str, Any]]:
try: try:
resp = http_requests.get( resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/machines", f"{STREAMING_SERVER_URL}/api/v1/traces/stream/machines",
headers=_streaming_headers(),
timeout=3, timeout=3,
) )
if resp.ok: if resp.ok:
@@ -384,7 +402,7 @@ def api_status():
@app.route('/api/workflows') @app.route('/api/workflows')
def api_workflows(): def api_workflows():
"""Liste unifiée des workflows (appris + VWB). """Liste unifiée des workflows (appris + VWB), filtrée par OS.
Sources fusionnées : Sources fusionnées :
1. Workflows appris (SemanticMatcher — data/training/workflows/) 1. Workflows appris (SemanticMatcher — data/training/workflows/)
@@ -392,10 +410,20 @@ def api_workflows():
Dédupliqués par nom : si un workflow appris a été importé dans le VWB, Dédupliqués par nom : si un workflow appris a été importé dans le VWB,
seule la version VWB est retournée (c'est la version validée/corrigée). seule la version VWB est retournée (c'est la version validée/corrigée).
Query params:
os: Filtrer par OS — 'windows' ou 'linux' (optionnel).
Par défaut, détecte l'OS du serveur Léa (= la machine du docteur).
""" """
if not matcher: if not matcher:
return jsonify({"workflows": [], "directories": []}) return jsonify({"workflows": [], "directories": []})
# Détecter l'OS : paramètre explicite ou auto-détection depuis la plateforme
os_filter = request.args.get('os')
if not os_filter:
import platform
os_filter = 'windows' if platform.system().lower() == 'windows' else 'linux'
seen_ids = set() seen_ids = set()
workflows = [] workflows = []
@@ -433,6 +461,21 @@ def api_workflows():
workflows.append(vwb_wf) workflows.append(vwb_wf)
seen_ids.add(vwb_id) seen_ids.add(vwb_id)
# Filtrer par OS : ne montrer que les workflows compatibles avec la machine du docteur
# Le machine_id ou source_dir contient le nom OS (ex: DESKTOP-58D5CAC_windows, dom-X870_linux)
if os_filter:
os_lower = os_filter.lower()
filtered_workflows = []
for wf in workflows:
mid = (wf.get("machine_id") or "").lower()
src = (wf.get("source") or "").lower()
# Un workflow VWB (sans machine_id) passe toujours le filtre
if wf.get("origin") == "vwb" and not mid:
filtered_workflows.append(wf)
elif os_lower in mid or os_lower in src:
filtered_workflows.append(wf)
workflows = filtered_workflows
# Récupérer la liste des machines connectées depuis le streaming server # Récupérer la liste des machines connectées depuis le streaming server
machines = _fetch_connected_machines() machines = _fetch_connected_machines()
@@ -1128,6 +1171,7 @@ def _execute_gesture(gesture):
try: try:
resp = http_requests.post( resp = http_requests.post(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/raw", f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/raw",
headers=_streaming_headers(),
json={ json={
"actions": [action], "actions": [action],
"session_id": "", "session_id": "",
@@ -1654,6 +1698,7 @@ def _try_streaming_server_replay(
resp = http_requests.post( resp = http_requests.post(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay", f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay",
headers=_streaming_headers(),
json=payload, json=payload,
timeout=15, timeout=15,
) )
@@ -1696,6 +1741,7 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
try: try:
resp = http_requests.get( resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}", f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}",
headers=_streaming_headers(),
timeout=3, timeout=3,
) )
if not resp.ok: if not resp.ok:
@@ -1968,6 +2014,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
try: try:
resp = http_requests.post( resp = http_requests.post(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/single", f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/single",
headers=_streaming_headers(),
json={ json={
"action": action, "action": action,
"session_id": "", "session_id": "",

View File

@@ -197,7 +197,8 @@ NOT_FOUND"""
prompt=prompt, prompt=prompt,
image=screenshot, image=screenshot,
temperature=0.1, temperature=0.1,
max_tokens=100 max_tokens=100,
assistant_prefill="COORDINATES:",
) )
if result.get('success'): if result.get('success'):

34
agent_rust/LISEZMOI.txt Normal file
View File

@@ -0,0 +1,34 @@
╔══════════════════════════════════════════╗
║ Léa — Assistante IA ║
║ Automatisation de tâches ║
╚══════════════════════════════════════════╝
INSTALLATION
────────────
1. Copiez le dossier "Lea" sur votre Bureau
2. Double-cliquez sur "Lea.exe" pour démarrer
PREMIÈRE UTILISATION
────────────────────
• Léa s'ouvre automatiquement dans votre navigateur
• Cliquez "Apprenez-moi une tâche" pour commencer
• Effectuez votre tâche normalement
• Cliquez "C'est terminé" quand vous avez fini
• Léa a appris ! Demandez-lui de refaire la tâche
ARRÊTER LÉA
────────────
• Fermez la fenêtre Léa dans la barre des tâches
• Ou appuyez Ctrl+C dans le terminal
BESOIN D'AIDE ?
───────────────
Contactez le support : [à compléter]
────────────────────────────────────────────
⚠ Cet outil utilise l'intelligence artificielle.
Article 50 du Règlement européen sur l'IA.
Vos données restent sur votre ordinateur et notre
serveur sécurisé. Aucune donnée n'est partagée
avec des tiers.
────────────────────────────────────────────

22
agent_rust/build_demo.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Build du kit démo pour Windows
set -e
echo "=== Build Léa pour Windows ==="
cargo build --release --target x86_64-pc-windows-gnu
# Préparer le dossier de démo
DEMO_DIR="demo_kit/Lea"
rm -rf demo_kit
mkdir -p "$DEMO_DIR"
# Copier les fichiers
cp target/x86_64-pc-windows-gnu/release/rpa-agent.exe "$DEMO_DIR/Lea.exe"
cp config.txt "$DEMO_DIR/config.txt"
cp LISEZMOI.txt "$DEMO_DIR/LISEZMOI.txt"
echo ""
echo "=== Kit démo prêt dans demo_kit/Lea/ ==="
ls -lh "$DEMO_DIR/"
echo ""
echo "Copiez le dossier Lea/ sur le PC du docteur."

12
agent_rust/config.txt Normal file
View File

@@ -0,0 +1,12 @@
# === Configuration Léa ===
# Adresse du serveur (ne pas modifier sauf instruction)
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
# Clé d'accès (ne pas modifier)
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
# Qualité des captures (1-100, défaut: 85)
RPA_JPEG_QUALITY=85
# Floutage des données sensibles (true/false)
RPA_BLUR_SENSITIVE=true

View File

@@ -100,6 +100,10 @@ pub fn image_hash(img: &DynamicImage) -> u64 {
} }
/// Retourne les dimensions du moniteur principal (largeur, hauteur). /// Retourne les dimensions du moniteur principal (largeur, hauteur).
///
/// xcap utilise DXGI sur Windows qui retourne toujours les pixels physiques,
/// independamment du DPI awareness. Ceci est coherent avec les coordonnees
/// physiques d'enigo quand le process est DPI-aware.
pub fn screen_dimensions() -> Option<(u32, u32)> { pub fn screen_dimensions() -> Option<(u32, u32)> {
let monitors = xcap::Monitor::all().ok()?; let monitors = xcap::Monitor::all().ok()?;
let primary = monitors let primary = monitors

View File

@@ -1,9 +1,13 @@
//! Configuration de l'agent RPA. //! Configuration de l'agent RPA.
//! //!
//! Parametres charges depuis les variables d'environnement ou valeurs par defaut. //! Parametres charges depuis les variables d'environnement ou valeurs par defaut.
//! Un fichier `config.txt` (clé=valeur) peut être placé à côté de l'exécutable.
//! Les variables d'environnement ont priorité sur le fichier.
//! Compatible avec la configuration Python (agent_v1/config.py). //! Compatible avec la configuration Python (agent_v1/config.py).
use std::env; use std::env;
use std::fs;
use std::path::PathBuf;
/// Version de l'agent Rust /// Version de l'agent Rust
pub const AGENT_VERSION: &str = "0.2.0-rust"; pub const AGENT_VERSION: &str = "0.2.0-rust";
@@ -37,11 +41,86 @@ pub struct Config {
/// Port du serveur de chat (defaut: 5004) /// Port du serveur de chat (defaut: 5004)
pub chat_port: u16, pub chat_port: u16,
/// Token Bearer pour l'authentification API (defaut: vide = pas d'auth)
pub api_token: String,
} }
impl Config { impl Config {
/// Charge le fichier `config.txt` situé à côté de l'exécutable (ou dans le dossier courant).
///
/// Format : une ligne par clé, `CLÉ=VALEUR`. Les lignes vides et celles commençant
/// par `#` sont ignorées. Seules les clés **absentes** de l'environnement sont injectées
/// (les variables d'environnement ont toujours priorité).
fn load_config_file() {
// 1. Chercher config.txt à côté de l'exécutable
let mut config_path: Option<PathBuf> = None;
if let Ok(exe) = env::current_exe() {
let candidate = exe.parent().map(|p| p.join("config.txt"));
if let Some(ref p) = candidate {
if p.is_file() {
config_path = candidate;
}
}
}
// 2. Fallback : dossier courant
if config_path.is_none() {
let cwd_candidate = PathBuf::from("config.txt");
if cwd_candidate.is_file() {
config_path = Some(cwd_candidate);
}
}
let path = match config_path {
Some(p) => p,
None => return, // Pas de fichier config — ce n'est pas une erreur
};
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("[config] Impossible de lire {} : {}", path.display(), e);
return;
}
};
eprintln!("[config] Chargement de {}", path.display());
for line in content.lines() {
let trimmed = line.trim();
// Ignorer les lignes vides et les commentaires
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Séparer au premier '='
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
let value = trimmed[eq_pos + 1..].trim();
if key.is_empty() {
continue;
}
// Ne positionner que si la variable n'existe pas déjà
if env::var(key).is_err() {
// SAFETY: appelé une seule fois au démarrage, avant tout thread
unsafe {
env::set_var(key, value);
}
}
}
}
}
/// Charge la configuration depuis les variables d'environnement. /// Charge la configuration depuis les variables d'environnement.
/// ///
/// Le fichier `config.txt` est lu en premier (voir [`load_config_file`]) ;
/// les variables d'environnement déjà définies ne sont pas écrasées.
///
/// Variables supportees : /// Variables supportees :
/// - `RPA_SERVER_URL` : URL du serveur (defaut: http://localhost:5005/api/v1) /// - `RPA_SERVER_URL` : URL du serveur (defaut: http://localhost:5005/api/v1)
/// - `RPA_MACHINE_ID` : Identifiant machine (defaut: hostname_os) /// - `RPA_MACHINE_ID` : Identifiant machine (defaut: hostname_os)
@@ -51,7 +130,10 @@ impl Config {
/// - `RPA_BLUR_SENSITIVE` : Flouter les zones sensibles (defaut: true) /// - `RPA_BLUR_SENSITIVE` : Flouter les zones sensibles (defaut: true)
/// - `RPA_LOG_RETENTION_DAYS` : Retention des logs en jours (defaut: 180) /// - `RPA_LOG_RETENTION_DAYS` : Retention des logs en jours (defaut: 180)
/// - `RPA_CHAT_PORT` : Port du serveur de chat (defaut: 5004) /// - `RPA_CHAT_PORT` : Port du serveur de chat (defaut: 5004)
/// - `RPA_API_TOKEN` : Token Bearer pour l'authentification (defaut: vide)
pub fn from_env() -> Self { pub fn from_env() -> Self {
// Charger config.txt AVANT de lire les variables d'environnement
Self::load_config_file();
let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| { let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| {
let host = hostname::get() let host = hostname::get()
.map(|h| h.to_string_lossy().to_string()) .map(|h| h.to_string_lossy().to_string())
@@ -98,6 +180,8 @@ impl Config {
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(5004); .unwrap_or(5004);
let api_token = env::var("RPA_API_TOKEN").unwrap_or_default();
Config { Config {
server_url, server_url,
machine_id, machine_id,
@@ -108,6 +192,7 @@ impl Config {
blur_sensitive, blur_sensitive,
log_retention_days, log_retention_days,
chat_port, chat_port,
api_token,
} }
} }
@@ -151,10 +236,11 @@ impl std::fmt::Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!( write!(
f, f,
"Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {}, blur: {}, log_retention: {}j, chat_port: {} }}", "Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {}, blur: {}, log_retention: {}j, chat_port: {}, auth: {} }}",
self.server_url, self.machine_id, self.capture_port, self.server_url, self.machine_id, self.capture_port,
self.heartbeat_interval_s, self.jpeg_quality, self.heartbeat_interval_s, self.jpeg_quality,
self.blur_sensitive, self.log_retention_days, self.chat_port, self.blur_sensitive, self.log_retention_days, self.chat_port,
if self.api_token.is_empty() { "none" } else { "Bearer" },
) )
} }
} }

View File

@@ -30,6 +30,7 @@ mod replay;
mod server; mod server;
#[allow(dead_code)] #[allow(dead_code)]
mod state; mod state;
mod sysinfo;
mod tray; mod tray;
mod visual; mod visual;
@@ -40,12 +41,20 @@ use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
/// Trouve Edge sur Windows /// Trouve un navigateur compatible sur Windows (Edge, Chrome, Brave, Firefox)
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn find_edge() -> Option<String> { fn find_browser() -> Option<String> {
let paths = [ let paths = [
// Edge
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
// Chrome
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
// Brave
r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
// Firefox (supporte --kiosk mais pas --app)
r"C:\Program Files\Mozilla Firefox\firefox.exe",
]; ];
for p in &paths { for p in &paths {
if std::path::Path::new(p).exists() { if std::path::Path::new(p).exists() {
@@ -56,6 +65,37 @@ fn find_edge() -> Option<String> {
} }
fn main() { fn main() {
// --- DPI awareness (DOIT etre appele avant toute operation graphique) ---
// Rend le process DPI-aware sur Windows pour que les API (enigo, xcap,
// GetSystemMetrics, etc.) travaillent en coordonnees physiques (pixels reels)
// au lieu de coordonnees logiques (virtualisees par le DPI scaling).
// Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067
// pour enigo et GetSystemMetrics, ce qui cause des erreurs de positionnement
// pendant le replay.
// PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
#[cfg(target_os = "windows")]
{
// SetProcessDpiAwareness (shcore.dll) et SetProcessDPIAware (user32.dll)
// ne sont pas toujours exposes par windows-sys selon les features.
// On utilise des appels FFI raw pour eviter d'ajouter des features.
#[link(name = "shcore")]
extern "system" {
fn SetProcessDpiAwareness(value: i32) -> i32;
}
#[link(name = "user32")]
extern "system" {
fn SetProcessDPIAware() -> i32;
}
unsafe {
// Tenter SetProcessDpiAwareness(2) = PROCESS_PER_MONITOR_DPI_AWARE
let hr = SetProcessDpiAwareness(2);
if hr != 0 {
// Fallback pour Windows < 8.1 : SetProcessDPIAware()
SetProcessDPIAware();
}
}
}
// Initialiser le logging // Initialiser le logging
env_logger::Builder::from_env( env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info"), env_logger::Env::default().default_filter_or("info"),
@@ -135,15 +175,41 @@ fn main() {
let chat_state = state.clone(); let chat_state = state.clone();
chat::run_chat_thread(&chat_config, chat_state); chat::run_chat_thread(&chat_config, chat_state);
// Synchroniser les workflows disponibles depuis le serveur
let sync_config = config.clone();
let workflows = {
let client = Client::new();
network::fetch_workflows(&client, &sync_config)
};
if workflows.is_empty() {
println!("[MAIN] Aucun workflow disponible pour cette machine.");
} else {
println!(
"[MAIN] {} workflow(s) disponible(s) :",
workflows.len()
);
for wf in &workflows {
println!(
" - {} ({} noeuds, {} transitions)",
wf.name, wf.nodes, wf.edges
);
}
}
println!("\n[MAIN] Agent operationnel — tous les threads demarres.\n"); println!("\n[MAIN] Agent operationnel — tous les threads demarres.\n");
// Ouvrir Léa (Edge mode app) automatiquement au démarrage // Ouvrir Léa dans le navigateur disponible (mode app) au démarrage
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let chat_url = config.chat_url(); let chat_url = config.chat_url();
if let Some(edge) = find_edge() { if let Some(browser) = find_browser() {
println!("[MAIN] Ouverture de Léa dans Edge..."); let browser_name = if browser.contains("chrome") { "Chrome" }
let _ = std::process::Command::new(&edge) else if browser.contains("edge") || browser.contains("Edge") { "Edge" }
else if browser.contains("brave") || browser.contains("Brave") { "Brave" }
else if browser.contains("firefox") || browser.contains("Firefox") { "Firefox" }
else { "navigateur" };
println!("[MAIN] Ouverture de Léa dans {}...", browser_name);
let _ = std::process::Command::new(&browser)
.args(&[ .args(&[
&format!("--app={}", chat_url), &format!("--app={}", chat_url),
"--window-size=600,800", "--window-size=600,800",
@@ -151,6 +217,8 @@ fn main() {
"--no-first-run", "--no-first-run",
]) ])
.spawn(); .spawn();
} else {
println!("[MAIN] Aucun navigateur trouvé — ouvrez manuellement : {}", chat_url);
} }
} }
@@ -304,9 +372,8 @@ fn health_check_loop(config: &Config, state: &AgentState) {
while state.is_running() { while state.is_running() {
let url = format!("{}/stats", config.server_url); let url = format!("{}/stats", config.server_url);
let connected = client let request = client.get(&url).timeout(timeout);
.get(&url) let connected = network::with_auth(request, config)
.timeout(timeout)
.send() .send()
.map(|r| r.status().is_success()) .map(|r| r.status().is_success())
.unwrap_or(false); .unwrap_or(false);
@@ -327,6 +394,8 @@ fn health_check_loop(config: &Config, state: &AgentState) {
/// Affiche la banniere de demarrage. /// Affiche la banniere de demarrage.
fn print_banner(config: &Config) { fn print_banner(config: &Config) {
let meta = sysinfo::get_screen_metadata();
println!("======================================================"); println!("======================================================");
println!( println!(
" RPA Vision Agent v{} (Rust)", " RPA Vision Agent v{} (Rust)",
@@ -342,6 +411,17 @@ fn print_banner(config: &Config) {
println!(" JPEG : qualite {}", config.jpeg_quality); println!(" JPEG : qualite {}", config.jpeg_quality);
println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" }); println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" });
println!(" Logs : retention {} jours", config.log_retention_days); println!(" Logs : retention {} jours", config.log_retention_days);
println!(" Auth : {}", if config.api_token.is_empty() { "aucune" } else { "Bearer token" });
println!(" Workflows : synchronisation au demarrage");
println!(
" Ecran : {}x{} @ {}% DPI",
meta.screen_resolution[0], meta.screen_resolution[1], meta.dpi_scale
);
println!(
" Moniteur : #{} ({})",
meta.monitor_index,
if meta.monitor_index == 0 { "principal" } else { "secondaire" }
);
println!("======================================================"); println!("======================================================");
println!(); println!();
println!(" [IA] Cet agent utilise l'intelligence artificielle."); println!(" [IA] Cet agent utilise l'intelligence artificielle.");

View File

@@ -5,9 +5,21 @@
//! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005). //! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005).
use crate::config::Config; use crate::config::Config;
use reqwest::blocking::Client; use crate::sysinfo;
use reqwest::blocking::{Client, RequestBuilder};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Ajoute le header Authorization Bearer si un token est configure.
///
/// Si `config.api_token` est vide, la requete est retournee telle quelle.
pub fn with_auth(request: RequestBuilder, config: &Config) -> RequestBuilder {
if config.api_token.is_empty() {
request
} else {
request.header("Authorization", format!("Bearer {}", config.api_token))
}
}
/// Action de replay reçue du serveur. /// Action de replay reçue du serveur.
/// ///
/// Format identique à celui du Python executor (agent_v1/core/executor.py). /// Format identique à celui du Python executor (agent_v1/core/executor.py).
@@ -102,6 +114,8 @@ impl ActionResult {
/// Envoie un heartbeat (screenshot) au serveur streaming. /// Envoie un heartbeat (screenshot) au serveur streaming.
/// ///
/// POST /traces/stream/image avec le screenshot en multipart. /// POST /traces/stream/image avec le screenshot en multipart.
/// Inclut les métadonnées système (DPI, résolution, fenêtre, moniteur)
/// dans les query params pour que le serveur puisse les exploiter.
/// Retourne true si l'envoi a réussi. /// Retourne true si l'envoi a réussi.
pub fn send_heartbeat( pub fn send_heartbeat(
client: &Client, client: &Client,
@@ -112,6 +126,19 @@ pub fn send_heartbeat(
let url = format!("{}/image", config.streaming_url()); let url = format!("{}/image", config.streaming_url());
let shot_id = format!("heartbeat_{}", chrono::Utc::now().timestamp()); let shot_id = format!("heartbeat_{}", chrono::Utc::now().timestamp());
// Collecter les métadonnées système
let meta = sysinfo::get_screen_metadata();
let dpi_str = meta.dpi_scale.to_string();
let screen_w_str = meta.screen_resolution[0].to_string();
let screen_h_str = meta.screen_resolution[1].to_string();
let monitor_str = meta.monitor_index.to_string();
// Sérialiser window_bounds en JSON compact (ou "null")
let wb_str = match meta.window_bounds {
Some(wb) => format!("[{},{},{},{}]", wb[0], wb[1], wb[2], wb[3]),
None => "null".to_string(),
};
let part = reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec()) let part = reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec())
.file_name("screenshot.jpg") .file_name("screenshot.jpg")
.mime_str("image/jpeg") .mime_str("image/jpeg")
@@ -122,17 +149,22 @@ pub fn send_heartbeat(
let form = reqwest::blocking::multipart::Form::new().part("file", part); let form = reqwest::blocking::multipart::Form::new().part("file", part);
match client let request = client
.post(&url) .post(&url)
.query(&[ .query(&[
("session_id", session_id), ("session_id", session_id),
("shot_id", &shot_id), ("shot_id", &shot_id),
("machine_id", &config.machine_id), ("machine_id", &config.machine_id),
("dpi_scale", &dpi_str),
("screen_w", &screen_w_str),
("screen_h", &screen_h_str),
("monitor_index", &monitor_str),
("window_bounds", &wb_str),
]) ])
.multipart(form) .multipart(form)
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10));
.send()
{ match with_auth(request, config).send() {
Ok(resp) => { Ok(resp) => {
if resp.status().is_success() { if resp.status().is_success() {
true true
@@ -166,15 +198,15 @@ pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
let url = format!("{}/replay/next", config.streaming_url()); let url = format!("{}/replay/next", config.streaming_url());
let session_id = config.agent_session_id(); let session_id = config.agent_session_id();
let resp = client let request = client
.get(&url) .get(&url)
.query(&[ .query(&[
("session_id", session_id.as_str()), ("session_id", session_id.as_str()),
("machine_id", config.machine_id.as_str()), ("machine_id", config.machine_id.as_str()),
]) ])
.timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(5));
.send()
.ok()?; let resp = with_auth(request, config).send().ok()?;
if !resp.status().is_success() { if !resp.status().is_success() {
return None; return None;
@@ -184,6 +216,120 @@ pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
data.action data.action
} }
/// Informations résumées d'un workflow disponible.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowInfo {
/// Identifiant unique du workflow
pub workflow_id: String,
/// Nom lisible du workflow
#[serde(default)]
pub name: String,
/// Identifiant machine associé
#[serde(default)]
pub machine_id: String,
/// Nombre de nœuds
#[serde(default)]
pub nodes: u32,
/// Nombre de transitions
#[serde(default)]
pub edges: u32,
}
/// Réponse du serveur pour GET /traces/stream/workflows
#[derive(Debug, Deserialize)]
struct WorkflowsResponse {
#[serde(default)]
workflows: Vec<WorkflowInfo>,
}
/// Récupère la liste des workflows disponibles pour cette machine.
///
/// GET /traces/stream/workflows?machine_id=<machine_id>
/// Sauvegarde le résultat dans workflows.json à côté de l'exécutable.
/// Retourne la liste (éventuellement depuis le cache local si le serveur est indisponible).
pub fn fetch_workflows(client: &Client, config: &Config) -> Vec<WorkflowInfo> {
let url = format!("{}/workflows", config.streaming_url());
let request = client
.get(&url)
.query(&[("machine_id", config.machine_id.as_str())])
.timeout(std::time::Duration::from_secs(5));
let workflows = match with_auth(request, config).send() {
Ok(resp) if resp.status().is_success() => {
match resp.json::<WorkflowsResponse>() {
Ok(data) => data.workflows,
Err(e) => {
eprintln!("[WORKFLOWS] Erreur parsing reponse : {}", e);
Vec::new()
}
}
}
Ok(resp) => {
eprintln!("[WORKFLOWS] Serveur HTTP {} — chargement cache local", resp.status());
return load_workflows_cache();
}
Err(e) => {
eprintln!("[WORKFLOWS] Serveur injoignable ({}) — chargement cache local", e);
return load_workflows_cache();
}
};
// Sauvegarder dans le cache local
save_workflows_cache(&workflows);
workflows
}
/// Chemin du fichier cache workflows.json (à côté de l'exécutable ou dans le dossier courant).
fn workflows_cache_path() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
return dir.join("workflows.json");
}
}
std::path::PathBuf::from("workflows.json")
}
/// Sauvegarde les workflows dans le cache local.
fn save_workflows_cache(workflows: &[WorkflowInfo]) {
let path = workflows_cache_path();
match serde_json::to_string_pretty(workflows) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
eprintln!("[WORKFLOWS] Erreur ecriture cache {} : {}", path.display(), e);
}
}
Err(e) => {
eprintln!("[WORKFLOWS] Erreur serialisation cache : {}", e);
}
}
}
/// Charge les workflows depuis le cache local.
fn load_workflows_cache() -> Vec<WorkflowInfo> {
let path = workflows_cache_path();
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<Vec<WorkflowInfo>>(&content) {
Ok(workflows) => {
println!("[WORKFLOWS] {} workflow(s) charges depuis le cache local", workflows.len());
workflows
}
Err(e) => {
eprintln!("[WORKFLOWS] Erreur parsing cache : {}", e);
Vec::new()
}
}
}
Err(_) => Vec::new(), // Pas de cache, pas d'erreur
}
}
/// Rapporte le résultat d'une action au serveur. /// Rapporte le résultat d'une action au serveur.
/// ///
/// POST /traces/stream/replay/result avec le résultat en JSON. /// POST /traces/stream/replay/result avec le résultat en JSON.
@@ -208,12 +354,12 @@ pub fn report_result(client: &Client, config: &Config, result: &ActionResult) ->
screenshot: &result.screenshot, screenshot: &result.screenshot,
}; };
match client let request = client
.post(&url) .post(&url)
.json(&report) .json(&report)
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10));
.send()
{ match with_auth(request, config).send() {
Ok(resp) => { Ok(resp) => {
if resp.status().is_success() { if resp.status().is_success() {
if let Ok(data) = resp.json::<serde_json::Value>() { if let Ok(data) = resp.json::<serde_json::Value>() {

View File

@@ -435,6 +435,10 @@ fn event_sender_loop(
} }
/// Envoie un evenement capture au serveur streaming. /// Envoie un evenement capture au serveur streaming.
///
/// Inclut la resolution de l'ecran dans chaque event pour que le serveur
/// puisse construire des ScreenStates avec la bonne resolution d'apprentissage
/// (au lieu du fallback 1920x1080).
fn send_event_to_server( fn send_event_to_server(
client: &reqwest::blocking::Client, client: &reqwest::blocking::Client,
config: &Config, config: &Config,
@@ -443,6 +447,7 @@ fn send_event_to_server(
) { ) {
let url = format!("{}/traces/stream/event", config.server_url); let url = format!("{}/traces/stream/event", config.server_url);
let timestamp = chrono::Utc::now().to_rfc3339(); let timestamp = chrono::Utc::now().to_rfc3339();
let (screen_w, screen_h) = capture::screen_dimensions().unwrap_or((1920, 1080));
let payload = match event { let payload = match event {
CapturedEvent::Click { CapturedEvent::Click {
@@ -460,6 +465,7 @@ fn send_event_to_server(
"session_name": session_name, "session_name": session_name,
"machine_id": config.machine_id, "machine_id": config.machine_id,
"timestamp": timestamp, "timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
}) })
} }
CapturedEvent::DoubleClick { CapturedEvent::DoubleClick {
@@ -476,6 +482,7 @@ fn send_event_to_server(
"session_name": session_name, "session_name": session_name,
"machine_id": config.machine_id, "machine_id": config.machine_id,
"timestamp": timestamp, "timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
}) })
} }
CapturedEvent::Text { CapturedEvent::Text {
@@ -491,6 +498,7 @@ fn send_event_to_server(
"session_name": session_name, "session_name": session_name,
"machine_id": config.machine_id, "machine_id": config.machine_id,
"timestamp": timestamp, "timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
}) })
} }
CapturedEvent::KeyCombo { keys } => { CapturedEvent::KeyCombo { keys } => {
@@ -500,6 +508,7 @@ fn send_event_to_server(
"session_name": session_name, "session_name": session_name,
"machine_id": config.machine_id, "machine_id": config.machine_id,
"timestamp": timestamp, "timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
}) })
} }
CapturedEvent::Scroll { CapturedEvent::Scroll {
@@ -515,6 +524,7 @@ fn send_event_to_server(
"session_name": session_name, "session_name": session_name,
"machine_id": config.machine_id, "machine_id": config.machine_id,
"timestamp": timestamp, "timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
}) })
} }
}; };

274
agent_rust/src/sysinfo.rs Normal file
View File

@@ -0,0 +1,274 @@
//! Métadonnées système : DPI, résolution, fenêtre active, moniteur.
//!
//! Expose des fonctions pour capturer les informations d'affichage
//! critiques qui seront envoyées au serveur avec chaque heartbeat.
//! Sur Windows, utilise les API Win32 (user32.dll).
//! Sur Linux, retourne des valeurs par défaut ou utilise xcap.
use serde::Serialize;
/// Métadonnées complètes de l'écran.
#[derive(Debug, Clone, Serialize)]
pub struct ScreenMetadata {
/// Facteur DPI en pourcentage (100 = normal, 150 = haute résolution)
pub dpi_scale: u32,
/// Résolution de l'écran principal [largeur, hauteur]
pub screen_resolution: [u32; 2],
/// Bounds de la fenêtre active [x, y, largeur, hauteur], None si pas de fenêtre
pub window_bounds: Option<[i32; 4]>,
/// Index du moniteur sur lequel se trouve la fenêtre active (0 = principal)
pub monitor_index: u32,
}
impl std::fmt::Display for ScreenMetadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}x{} @ {}% DPI, monitor #{}",
self.screen_resolution[0],
self.screen_resolution[1],
self.dpi_scale,
self.monitor_index,
)?;
if let Some(wb) = &self.window_bounds {
write!(f, ", fenetre [{}x{} @ ({},{})]", wb[2], wb[3], wb[0], wb[1])?;
}
Ok(())
}
}
// =============================================================================
// Windows : API Win32 via FFI
// =============================================================================
#[cfg(target_os = "windows")]
mod win {
use windows_sys::Win32::Foundation::{BOOL, LPARAM, RECT};
use windows_sys::Win32::Graphics::Gdi::{
EnumDisplayMonitors, GetMonitorInfoW, MonitorFromWindow, HMONITOR, MONITORINFO,
MONITOR_DEFAULTTOPRIMARY,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetSystemMetrics, GetWindowRect, SM_CXSCREEN, SM_CYSCREEN,
};
// GetDpiForSystem est dans Win32_UI_HiDpi (non activée).
// On utilise un appel FFI raw pour éviter d'ajouter une feature.
extern "system" {
fn GetDpiForSystem() -> u32;
}
/// Retourne le facteur DPI en % (100 = normal, 125, 150, 200...).
pub fn get_dpi_scale() -> u32 {
unsafe {
let dpi = GetDpiForSystem();
if dpi == 0 {
// Fallback si l'API n'est pas disponible (Windows < 10 1607)
100
} else {
(dpi * 100) / 96
}
}
}
/// Retourne (largeur, hauteur) du moniteur principal via GetSystemMetrics.
///
/// IMPORTANT : Retourne la resolution physique uniquement si le process est
/// DPI-aware (SetProcessDpiAwareness(2) appele dans main.rs). Sans cela,
/// retourne la resolution logique (virtualisee par le DPI scaling).
pub fn get_screen_resolution() -> (u32, u32) {
unsafe {
let w = GetSystemMetrics(SM_CXSCREEN);
let h = GetSystemMetrics(SM_CYSCREEN);
if w > 0 && h > 0 {
(w as u32, h as u32)
} else {
(0, 0)
}
}
}
/// Retourne (x, y, largeur, hauteur) de la fenêtre active, ou None.
pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return None;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) != 0 {
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
Some((rect.left, rect.top, w, h))
} else {
None
}
}
}
/// Flag indiquant le moniteur principal dans MONITORINFO.dwFlags.
const MONITORINFOF_PRIMARY: u32 = 1;
/// Retourne l'index du moniteur sur lequel se trouve la fenêtre active.
/// 0 = moniteur principal. Enumère tous les moniteurs pour trouver l'index.
pub fn get_monitor_index() -> u32 {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return 0;
}
let target_hmon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
if target_hmon.is_null() {
return 0;
}
// Énumérer les moniteurs pour trouver l'index
struct CallbackData {
target: HMONITOR,
current_index: u32,
found_index: u32,
}
unsafe extern "system" fn enum_callback(
hmonitor: HMONITOR,
_hdc: windows_sys::Win32::Graphics::Gdi::HDC,
_lprect: *mut RECT,
lparam: LPARAM,
) -> BOOL {
let data = &mut *(lparam as *mut CallbackData);
// Vérifier si c'est le moniteur principal — il est toujours #0
let mut info: MONITORINFO = std::mem::zeroed();
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
GetMonitorInfoW(hmonitor, &mut info);
if info.dwFlags & MONITORINFOF_PRIMARY != 0 {
// Moniteur principal — index 0, mais on continue pour le comptage
if hmonitor == data.target {
data.found_index = 0;
}
} else if hmonitor == data.target {
data.found_index = data.current_index;
}
data.current_index += 1;
1 // TRUE, continuer l'énumération
}
let mut data = CallbackData {
target: target_hmon,
current_index: 0,
found_index: 0,
};
EnumDisplayMonitors(
std::ptr::null_mut(), // HDC null = tous les moniteurs
std::ptr::null(),
Some(enum_callback),
&mut data as *mut CallbackData as LPARAM,
);
data.found_index
}
}
}
// =============================================================================
// Linux / fallback : valeurs par défaut ou xcap
// =============================================================================
#[cfg(not(target_os = "windows"))]
mod fallback {
/// Sur Linux, pas de DPI système accessible simplement. Retourne 100%.
pub fn get_dpi_scale() -> u32 {
100
}
/// Résolution via xcap (mêmes moniteurs que la capture).
pub fn get_screen_resolution() -> (u32, u32) {
if let Ok(monitors) = xcap::Monitor::all() {
if let Some(primary) = monitors.into_iter().find(|m| m.is_primary().unwrap_or(false)) {
let w = primary.width().unwrap_or(0);
let h = primary.height().unwrap_or(0);
return (w, h);
}
}
(0, 0)
}
/// Pas d'API window bounds sur Linux en mode headless. Retourne None.
pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> {
None
}
/// Moniteur principal = index 0 (fallback).
pub fn get_monitor_index() -> u32 {
0
}
}
// =============================================================================
// API publique
// =============================================================================
/// Retourne le facteur DPI en % (100 = normal, 150 = haute résolution).
pub fn get_dpi_scale() -> u32 {
#[cfg(target_os = "windows")]
{
win::get_dpi_scale()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_dpi_scale()
}
}
/// Retourne (largeur, hauteur) du moniteur principal.
pub fn get_screen_resolution() -> (u32, u32) {
#[cfg(target_os = "windows")]
{
win::get_screen_resolution()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_screen_resolution()
}
}
/// Retourne (x, y, largeur, hauteur) de la fenêtre active, ou None.
pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> {
#[cfg(target_os = "windows")]
{
win::get_window_bounds()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_window_bounds()
}
}
/// Retourne l'index du moniteur de la fenêtre active (0 = principal).
pub fn get_monitor_index() -> u32 {
#[cfg(target_os = "windows")]
{
win::get_monitor_index()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_monitor_index()
}
}
/// Collecte toutes les métadonnées système en une seule structure.
pub fn get_screen_metadata() -> ScreenMetadata {
let (sw, sh) = get_screen_resolution();
let wb = get_window_bounds().map(|(x, y, w, h)| [x, y, w, h]);
ScreenMetadata {
dpi_scale: get_dpi_scale(),
screen_resolution: [sw, sh],
window_bounds: wb,
monitor_index: get_monitor_index(),
}
}

View File

@@ -8,6 +8,25 @@ import platform
import socket import socket
from pathlib import Path from pathlib import Path
# --- DPI awareness (DOIT etre appele avant tout import de pynput/mss/tkinter) ---
# Rend le process DPI-aware sur Windows pour que toutes les API (pynput, mss, pyautogui)
# travaillent en coordonnees physiques (pixels reels) au lieu de coordonnees logiques
# (virtualisees par le DPI scaling).
# Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067 pour les API,
# ce qui cause des erreurs de positionnement pendant le replay.
# Sur Linux/Mac : no-op silencieux.
# PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
if platform.system() == "Windows":
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE
except Exception:
try:
# Fallback pour Windows < 8.1 (API plus ancienne)
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
AGENT_VERSION = "1.0.0" AGENT_VERSION = "1.0.0"
# Identifiant unique de la machine (utilisé pour le multi-machine) # Identifiant unique de la machine (utilisé pour le multi-machine)
@@ -34,7 +53,7 @@ MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
SESSIONS_ROOT = BASE_DIR / "sessions" SESSIONS_ROOT = BASE_DIR / "sessions"
# Paramètres Vision (Crops pour qwen3-vl) # Paramètres Vision (Crops pour qwen3-vl)
TARGETED_CROP_SIZE = (400, 400) TARGETED_CROP_SIZE = (150, 150)
SCREENSHOT_QUALITY = 85 SCREENSHOT_QUALITY = 85
# Floutage des données sensibles (conformité AI Act) # Floutage des données sensibles (conformité AI Act)
@@ -52,6 +71,22 @@ PERF_MONITOR_INTERVAL_S = 30
LOGS_DIR = BASE_DIR / "logs" LOGS_DIR = BASE_DIR / "logs"
LOG_FILE = LOGS_DIR / "agent_v1.log" LOG_FILE = LOGS_DIR / "agent_v1.log"
# --- Métadonnées système (capturées au chargement du module) ---
# Utilisées pour la bannière de démarrage et le diagnostic.
# Import tardif pour éviter les dépendances circulaires.
try:
from .vision.system_info import get_dpi_scale, get_os_theme, get_monitor_info
_monitor_index, _monitors = get_monitor_info()
_primary = _monitors[0] if _monitors else {"width": 1920, "height": 1080}
SCREEN_RESOLUTION = (_primary["width"], _primary["height"])
DPI_SCALE = get_dpi_scale()
OS_THEME = get_os_theme()
except Exception:
# Fallback silencieux si les métadonnées ne sont pas disponibles
SCREEN_RESOLUTION = (1920, 1080)
DPI_SCALE = 100
OS_THEME = "unknown"
# Création des dossiers # Création des dossiers
os.makedirs(SESSIONS_ROOT, exist_ok=True) os.makedirs(SESSIONS_ROOT, exist_ok=True)
os.makedirs(LOGS_DIR, exist_ok=True) os.makedirs(LOGS_DIR, exist_ok=True)

View File

@@ -10,11 +10,20 @@ Fonctionnalités :
- Buffer de saisie texte : accumule les frappes et émet un événement - Buffer de saisie texte : accumule les frappes et émet un événement
text_input après 500ms d'inactivité clavier text_input après 500ms d'inactivité clavier
- Surveillance du focus fenêtre - Surveillance du focus fenêtre
NOTE DPI : Les coordonnees retournees par pynput dependent du DPI awareness
du process. Quand SetProcessDpiAwareness(2) est appele (dans config.py),
pynput retourne des coordonnees en pixels PHYSIQUES. Les metadonnees
screen_metadata (resolution via mss) sont aussi en pixels physiques.
Ceci garantit que la normalisation pos/resolution est coherente.
Sans DPI awareness, pynput retourne des coordonnees LOGIQUES mais mss
retourne des pixels physiques, ce qui cause une erreur de normalisation.
""" """
import threading import threading
import time import time
import logging import logging
import platform
from typing import Callable, Optional, List, Dict, Any, Tuple from typing import Callable, Optional, List, Dict, Any, Tuple
from pynput import mouse, keyboard from pynput import mouse, keyboard
from pynput.mouse import Button from pynput.mouse import Button
@@ -22,10 +31,14 @@ from pynput.keyboard import Key, KeyCode
# Importation relative pour rester dans le module v1 # Importation relative pour rester dans le module v1
from ..vision.capturer import VisionCapturer from ..vision.capturer import VisionCapturer
from ..vision.system_info import get_screen_metadata
# from ..monitoring.system import SystemMonitor # from ..monitoring.system import SystemMonitor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Détection Windows une seule fois au chargement du module
IS_WINDOWS = platform.system() == "Windows"
# Délai d'inactivité avant flush du buffer texte (en secondes) # Délai d'inactivité avant flush du buffer texte (en secondes)
TEXT_FLUSH_DELAY = 0.5 TEXT_FLUSH_DELAY = 0.5
# Délai max entre deux clics pour un double-clic (en secondes) # Délai max entre deux clics pour un double-clic (en secondes)
@@ -57,6 +70,11 @@ class EventCaptorV1:
self._text_start_pos: Optional[Tuple[int, int]] = None self._text_start_pos: Optional[Tuple[int, int]] = None
# Timer pour le flush après inactivité # Timer pour le flush après inactivité
self._text_flush_timer: Optional[threading.Timer] = None self._text_flush_timer: Optional[threading.Timer] = None
# Compteur de génération pour éviter qu'un timer obsolète ne flush
# un buffer en cours de remplissage (race condition). Incrémenté
# à chaque reset du timer. Le timer ne flush que si la génération
# n'a pas changé.
self._text_flush_generation: int = 0
# Dernière position connue de la souris (pour associer le texte # Dernière position connue de la souris (pour associer le texte
# au champ dans lequel l'utilisateur tape) # au champ dans lequel l'utilisateur tape)
self._last_mouse_pos: Tuple[int, int] = (0, 0) self._last_mouse_pos: Tuple[int, int] = (0, 0)
@@ -65,6 +83,17 @@ class EventCaptorV1:
# Dernier clic : (x, y, timestamp, button) # Dernier clic : (x, y, timestamp, button)
self._last_click: Optional[Tuple[int, int, float, str]] = None self._last_click: Optional[Tuple[int, int, float, str]] = None
# --- Buffer de raw_keys (press/release bruts avec vk codes) ---
# Accumule chaque press/release pour le replay exact (solution AZERTY).
# Vidé en même temps que le text_buffer ou à l'émission d'un key_combo.
self._raw_key_buffer: List[Dict[str, Any]] = []
# --- Métadonnées système (DPI, résolution, moniteur, thème, langue) ---
# Capturées au démarrage puis rafraîchies à chaque changement de focus.
# Injectées dans chaque événement via le champ "screen_metadata".
self._screen_metadata: Dict[str, Any] = {}
self._screen_metadata_lock = threading.Lock()
def start(self): def start(self):
self.running = True self.running = True
self.mouse_listener = mouse.Listener( self.mouse_listener = mouse.Listener(
@@ -80,6 +109,9 @@ class EventCaptorV1:
self.mouse_listener.start() self.mouse_listener.start()
self.keyboard_listener.start() self.keyboard_listener.start()
# Capture initiale des métadonnées système
self._refresh_screen_metadata()
# Thread de surveillance du focus fenêtre (Proactif) # Thread de surveillance du focus fenêtre (Proactif)
self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True) self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True)
self._focus_thread.start() self._focus_thread.start()
@@ -131,6 +163,7 @@ class EventCaptorV1:
"pos": (x, y), "pos": (x, y),
"timestamp": now, "timestamp": now,
} }
self._inject_screen_metadata(event)
self.on_event(event) self.on_event(event)
# Réinitialiser pour éviter un triple-clic = 2 double-clics # Réinitialiser pour éviter un triple-clic = 2 double-clics
self._last_click = None self._last_click = None
@@ -144,6 +177,7 @@ class EventCaptorV1:
"pos": (x, y), "pos": (x, y),
"timestamp": now, "timestamp": now,
} }
self._inject_screen_metadata(event)
self.on_event(event) self.on_event(event)
def _on_scroll(self, x, y, dx, dy): def _on_scroll(self, x, y, dx, dy):
@@ -168,7 +202,106 @@ class EventCaptorV1:
return key.name return key.name
return str(key) return str(key)
# Ensemble des touches considérées comme modificateurs purs.
# Utilisé pour ne PAS émettre de key_combo quand seuls des
# modificateurs sont enfoncés (évite le bruit).
_MODIFIER_KEYS = {
Key.ctrl, Key.ctrl_l, Key.ctrl_r,
Key.alt, Key.alt_l, Key.alt_r,
Key.shift, Key.shift_l, Key.shift_r,
Key.cmd, Key.cmd_l, Key.cmd_r,
}
_MODIFIER_KEY_NAMES = {
"ctrl", "ctrl_l", "ctrl_r",
"alt", "alt_l", "alt_r",
"shift", "shift_l", "shift_r",
"cmd", "cmd_l", "cmd_r",
}
@staticmethod
def _vk_to_char(vk_code: int) -> Optional[str]:
"""Convertir un virtual key code en caractère réel (AZERTY-aware).
Utilise ToUnicodeEx avec le layout clavier actif pour obtenir
le bon caractère même pour les touches AltGr, Shift+chiffres,
et autres combinaisons spécifiques au layout (AZERTY, QWERTZ, etc.).
Ne fonctionne que sur Windows. Retourne None sur Linux/Mac.
"""
if not IS_WINDOWS:
return None
try:
import ctypes
import ctypes.wintypes as wt
user32 = ctypes.windll.user32
kbd_state = (ctypes.c_ubyte * 256)()
user32.GetKeyboardState(kbd_state)
buf = (ctypes.c_wchar * 8)()
scan = user32.MapVirtualKeyW(vk_code, 0)
# Layout du thread de la fenêtre active (gère AZERTY, QWERTZ, etc.)
hwnd = user32.GetForegroundWindow()
tid = user32.GetWindowThreadProcessId(hwnd, None)
hkl = user32.GetKeyboardLayout(tid)
n = user32.ToUnicodeEx(vk_code, scan, kbd_state, buf, 8, 0, hkl)
if n > 0:
return buf[0]
except Exception:
pass
return None
def _is_altgr_producing_char(self, key) -> Optional[str]:
"""Détecte si la combinaison actuelle est AltGr+touche produisant un caractère.
Sur Windows AZERTY, AltGr est envoyé comme Ctrl+Alt par pynput.
Cette méthode vérifie si Ctrl+Alt est enfoncé et que la touche
produit un caractère imprimable via le layout clavier.
Ex: AltGr+é → ~, AltGr+( → {, AltGr+à → @
Retourne le caractère produit ou None si ce n'est pas un AltGr valide.
"""
if not IS_WINDOWS:
return None
# AltGr = Ctrl+Alt (sans Win) sur Windows
if self.modifiers != {"ctrl", "alt"} and self.modifiers != {"ctrl", "alt", "shift"}:
return None
# Ne s'applique qu'aux touches non-modificatrices
if key in self._MODIFIER_KEYS:
return None
# Essayer de résoudre le caractère via ToUnicodeEx
# Le keyboard state inclut déjà Ctrl+Alt (= AltGr) grâce à GetKeyboardState
vk = getattr(key, 'vk', None)
if vk is not None:
char = self._vk_to_char(vk)
if char is not None and len(char) == 1 and (char.isprintable() and char != ' '):
return char
return None
@staticmethod
def _encode_key(key) -> Dict[str, Any]:
"""Encode un objet pynput Key/KeyCode en dictionnaire sérialisable.
Utilisé pour constituer le buffer raw_keys (séquence press/release
exacte avec virtual key codes) qui permet un replay fidèle
indépendant du layout clavier (AZERTY, QWERTZ, etc.).
"""
if isinstance(key, KeyCode):
return {"kind": "vk", "vk": key.vk, "char": key.char}
if isinstance(key, Key):
return {"kind": "key", "name": key.name}
return {"kind": "unknown", "str": str(key)}
def _on_press(self, key): def _on_press(self, key):
# TOUJOURS enregistrer le press brut dans le buffer raw_keys
with self._text_lock:
self._raw_key_buffer.append({
"action": "press",
**self._encode_key(key),
})
# Gestion des touches modificatrices # Gestion des touches modificatrices
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r): if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
self.modifiers.add("ctrl") self.modifiers.add("ctrl")
@@ -176,26 +309,54 @@ class EventCaptorV1:
self.modifiers.add("alt") self.modifiers.add("alt")
elif key in (Key.shift, Key.shift_l, Key.shift_r): elif key in (Key.shift, Key.shift_l, Key.shift_r):
self.modifiers.add("shift") self.modifiers.add("shift")
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
self.modifiers.add("win")
# --- Combos avec modificateur (sauf Shift seul) --- # --- Combos avec modificateur (sauf Shift seul) ---
# Shift seul n'est pas un « vrai » modificateur pour les combos : # Shift seul n'est pas un « vrai » modificateur pour les combos :
# Shift+a = 'A' = saisie texte, pas un raccourci. # Shift+a = 'A' = saisie texte, pas un raccourci.
# On considère un combo seulement si Ctrl ou Alt est enfoncé. # On considère un combo seulement si Ctrl, Alt ou Win est enfoncé.
has_real_modifier = self.modifiers & {"ctrl", "alt"} has_real_modifier = self.modifiers & {"ctrl", "alt", "win"}
if has_real_modifier: if has_real_modifier:
# --- Détection AltGr (Windows AZERTY) ---
# Sur Windows, AltGr est envoyé comme Ctrl+Alt par le système.
# Avant de traiter comme un key_combo, vérifier si c'est
# AltGr qui produit un caractère imprimable (@, #, {, }, etc.)
altgr_char = self._is_altgr_producing_char(key)
if altgr_char is not None:
# C'est un caractère AltGr → router vers le buffer texte
with self._text_lock:
if not self._text_buffer:
self._text_start_pos = self._last_mouse_pos
self._text_buffer.append(altgr_char)
self._reset_flush_timer()
return
key_name = self._get_key_name(key) key_name = self._get_key_name(key)
if key_name and key_name not in ("ctrl", "alt", "shift"): # Ne PAS émettre de combo si c'est un modificateur seul
# (ex: appui sur Ctrl sans autre touche = pas de combo)
if key_name and key_name not in self._MODIFIER_KEY_NAMES:
# Un combo interrompt la saisie texte en cours # Un combo interrompt la saisie texte en cours
self._flush_text_buffer() self._flush_text_buffer()
# Attacher les raw_keys accumulés (press des modificateurs + press de la touche)
with self._text_lock:
raw_keys = list(self._raw_key_buffer)
# NB: on ne clear pas encore — le release va suivre et sera
# capturé pour le prochain buffer. On prend un snapshot.
event = { event = {
"type": "key_combo", "type": "key_combo",
"keys": list(self.modifiers) + [key_name], "keys": list(self.modifiers) + [key_name],
"raw_keys": raw_keys,
"timestamp": time.time(), "timestamp": time.time(),
} }
self._inject_screen_metadata(event)
self.on_event(event) self.on_event(event)
# Reset le buffer raw_keys après émission du combo
with self._text_lock:
self._raw_key_buffer.clear()
return return
# --- Saisie texte (pas de Ctrl/Alt enfoncé) --- # --- Saisie texte (pas de Ctrl/Alt/Win enfoncé) ---
self._handle_text_key(key) self._handle_text_key(key)
def _handle_text_key(self, key): def _handle_text_key(self, key):
@@ -217,6 +378,7 @@ class EventCaptorV1:
if key == Key.esc: if key == Key.esc:
# Annuler la saisie en cours # Annuler la saisie en cours
self._text_buffer.clear() self._text_buffer.clear()
self._raw_key_buffer.clear()
self._text_start_pos = None self._text_start_pos = None
self._cancel_flush_timer() self._cancel_flush_timer()
return return
@@ -234,31 +396,65 @@ class EventCaptorV1:
self._reset_flush_timer() self._reset_flush_timer()
return return
elif isinstance(key, KeyCode) and key.char is not None: elif isinstance(key, KeyCode):
# Caractère alphanumérique / ponctuation # Caractère alphanumérique / ponctuation
# pynput renvoie déjà le bon caractère selon le layout char = key.char
# (AZERTY inclus) — on ne convertit rien.
if not self._text_buffer: # AZERTY Windows : quand key.char est None (Shift+chiffres,
self._text_start_pos = self._last_mouse_pos # dead keys, etc.), utiliser ToUnicodeEx avec le layout clavier
self._text_buffer.append(key.char) # actif pour obtenir le vrai caractère traduit par Windows.
self._reset_flush_timer() if char is None and IS_WINDOWS:
vk = getattr(key, 'vk', None)
if vk is not None:
char = self._vk_to_char(vk)
if char is not None and len(char) == 1:
if not self._text_buffer:
self._text_start_pos = self._last_mouse_pos
self._text_buffer.append(char)
self._reset_flush_timer()
return
# key.char None et pas de vk exploitable → ignorer
return return
else: else:
# Touche spéciale non gérée (F1, Insert, etc.) — on ignore # Touche spéciale non gérée (F1, Insert, etc.) — on ignore
return return
# Si on arrive ici, c'est Enter ou Tab → flush immédiat # Si on arrive ici, c'est Enter ou Tab → flush le buffer en cours
# puis émettre le caractère spécial comme text_input séparé
self._flush_text_buffer() self._flush_text_buffer()
# Émettre Enter comme "\n" et Tab comme "\t" pour ne pas perdre
# les retours à la ligne dans la saisie.
# Attacher les raw_keys restants (press de Enter/Tab, le release suivra)
with self._text_lock:
raw_keys = list(self._raw_key_buffer)
self._raw_key_buffer.clear()
special_char = "\n" if key == Key.enter else "\t"
event = {
"type": "text_input",
"text": special_char,
"pos": list(self._last_mouse_pos) if self._last_mouse_pos else [0, 0],
"timestamp": time.time(),
}
if raw_keys:
event["raw_keys"] = raw_keys
self.on_event(event)
def _reset_flush_timer(self): def _reset_flush_timer(self):
"""Réarme le timer de flush après chaque frappe. """Réarme le timer de flush après chaque frappe.
Doit être appelé avec self._text_lock déjà acquis. Doit être appelé avec self._text_lock déjà acquis.
Utilise un compteur de génération pour garantir que seul le
dernier timer programmé puisse effectivement flush le buffer.
""" """
if self._text_flush_timer is not None: if self._text_flush_timer is not None:
self._text_flush_timer.cancel() self._text_flush_timer.cancel()
self._text_flush_generation += 1
gen = self._text_flush_generation
self._text_flush_timer = threading.Timer( self._text_flush_timer = threading.Timer(
TEXT_FLUSH_DELAY, self._flush_text_buffer TEXT_FLUSH_DELAY, self._flush_text_buffer_if_current, args=(gen,)
) )
self._text_flush_timer.daemon = True self._text_flush_timer.daemon = True
self._text_flush_timer.start() self._text_flush_timer.start()
@@ -272,18 +468,30 @@ class EventCaptorV1:
self._text_flush_timer.cancel() self._text_flush_timer.cancel()
self._text_flush_timer = None self._text_flush_timer = None
def _flush_text_buffer_if_current(self, generation: int):
"""Appelé par le timer. Ne flush que si la génération correspond
à celle du timer en cours (= pas de frappe entre-temps)."""
with self._text_lock:
if generation != self._text_flush_generation:
# Un timer plus récent a été programmé, celui-ci est obsolète
return
self._flush_text_buffer()
def _flush_text_buffer(self): def _flush_text_buffer(self):
"""Émet un événement text_input avec le contenu du buffer, puis """Émet un événement text_input avec le contenu du buffer, puis
le vide. Thread-safe — peut être appelé depuis le timer, le le vide. Thread-safe — peut être appelé depuis le timer, le
listener souris ou le listener clavier.""" listener souris ou le listener clavier."""
with self._text_lock: with self._text_lock:
if not self._text_buffer: if not self._text_buffer:
# Rien à émettre # Rien à émettre — purger aussi les raw_keys orphelins
self._raw_key_buffer.clear()
self._cancel_flush_timer() self._cancel_flush_timer()
return return
text = "".join(self._text_buffer) text = "".join(self._text_buffer)
pos = self._text_start_pos or self._last_mouse_pos pos = self._text_start_pos or self._last_mouse_pos
raw_keys = list(self._raw_key_buffer)
self._text_buffer.clear() self._text_buffer.clear()
self._raw_key_buffer.clear()
self._text_start_pos = None self._text_start_pos = None
self._cancel_flush_timer() self._cancel_flush_timer()
@@ -295,32 +503,75 @@ class EventCaptorV1:
"pos": pos, "pos": pos,
"timestamp": time.time(), "timestamp": time.time(),
} }
logger.debug(f"text_input émis : {len(text)} caractères") # Attacher les raw_keys pour le replay exact (solution AZERTY)
if raw_keys:
event["raw_keys"] = raw_keys
self._inject_screen_metadata(event)
logger.debug(f"text_input émis : {len(text)} caractères, {len(raw_keys)} raw_keys")
self.on_event(event) self.on_event(event)
def _on_release(self, key): def _on_release(self, key):
# TOUJOURS enregistrer le release brut dans le buffer raw_keys
with self._text_lock:
self._raw_key_buffer.append({
"action": "release",
**self._encode_key(key),
})
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r): if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
self.modifiers.discard("ctrl") self.modifiers.discard("ctrl")
elif key in (Key.alt, Key.alt_l, Key.alt_r): elif key in (Key.alt, Key.alt_l, Key.alt_r):
self.modifiers.discard("alt") self.modifiers.discard("alt")
elif key in (Key.shift, Key.shift_l, Key.shift_r): elif key in (Key.shift, Key.shift_l, Key.shift_r):
self.modifiers.discard("shift") self.modifiers.discard("shift")
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
self.modifiers.discard("win")
# ----------------------------------------------------------------
# Métadonnées système
# ----------------------------------------------------------------
def _refresh_screen_metadata(self):
"""Rafraîchit le cache des métadonnées système.
Appelé au démarrage et à chaque changement de focus fenêtre.
Thread-safe — peut être appelé depuis le thread focus.
"""
try:
metadata = get_screen_metadata()
with self._screen_metadata_lock:
self._screen_metadata = metadata
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
except Exception as e:
logger.error(f"Erreur refresh métadonnées système : {e}")
def _inject_screen_metadata(self, event: Dict[str, Any]) -> Dict[str, Any]:
"""Injecte les métadonnées système cachées dans un événement."""
with self._screen_metadata_lock:
if self._screen_metadata:
event["screen_metadata"] = self._screen_metadata.copy()
return event
def _watch_window_focus(self): def _watch_window_focus(self):
"""Surveille proactivement le changement de fenêtre pour le stagiaire.""" """Surveille proactivement le changement de fenêtre pour le stagiaire."""
# Importation relative simple # Importation relative simple
from ..window_info_crossplatform import get_active_window_info from ..window_info_crossplatform import get_active_window_info
while self.running: while self.running:
try: try:
info = get_active_window_info() info = get_active_window_info()
if info and info != self.last_window: if info and info != self.last_window:
# Rafraîchir les métadonnées (la fenêtre a peut-être
# changé de moniteur, de taille, etc.)
self._refresh_screen_metadata()
event = { event = {
"type": "window_focus_change", "type": "window_focus_change",
"from": self.last_window, "from": self.last_window,
"to": info, "to": info,
"timestamp": time.time() "timestamp": time.time()
} }
self._inject_screen_metadata(event)
self.last_window = info self.last_window = info
self.on_event(event) self.on_event(event)
except Exception as e: except Exception as e:

View File

@@ -6,17 +6,28 @@ Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (
Supporte deux modes : Supporte deux modes :
- Watchdog fichier (command.json) — legacy - Watchdog fichier (command.json) — legacy
- Polling serveur (GET /replay/next) — mode replay P0-5 - Polling serveur (GET /replay/next) — mode replay P0-5
NOTE DPI : Ce module depend du DPI awareness configure dans config.py.
L'appel a SetProcessDpiAwareness(2) DOIT avoir ete fait avant l'import de
pynput et mss, sinon les coordonnees seront en pixels logiques (faux sur
les ecrans haute resolution avec DPI scaling > 100%).
""" """
import base64 import base64
import hashlib
import io import io
import os import os
import time import time
import logging import logging
# Forcer l'import de config AVANT pynput/mss pour garantir que le
# 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
import mss import mss
from pynput.mouse import Button, Controller as MouseController from pynput.mouse import Button, Controller as MouseController
from pynput.keyboard import Controller as KeyboardController, Key from pynput.keyboard import Controller as KeyboardController, Key, KeyCode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,6 +79,20 @@ class ActionExecutorV1:
self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec
# Token d'authentification API # Token d'authentification API
self._api_token = os.environ.get("RPA_API_TOKEN", "") self._api_token = os.environ.get("RPA_API_TOKEN", "")
# Log de la resolution physique pour le diagnostic DPI
self._log_screen_info()
def _log_screen_info(self):
"""Log la resolution physique de l'ecran au demarrage pour le diagnostic DPI."""
try:
monitor = self.sct.monitors[1]
w, h = monitor["width"], monitor["height"]
logger.info(
f"Executor initialise — resolution physique : {w}x{h} "
f"(mss monitors[1], DPI-aware process)"
)
except Exception as e:
logger.debug(f"Impossible de lire la resolution ecran : {e}")
def _auth_headers(self) -> dict: def _auth_headers(self) -> dict:
"""Headers d'authentification Bearer pour les requetes au serveur.""" """Headers d'authentification Bearer pour les requetes au serveur."""
@@ -180,11 +205,30 @@ class ActionExecutorV1:
f"-> ({x_pct:.4f}, {y_pct:.4f})" f"-> ({x_pct:.4f}, {y_pct:.4f})"
) )
# ---- Hash AVANT l'action (pour verification post-action) ----
# Seules les actions click et key_combo sont verifiees : elles
# provoquent un changement visible de l'ecran (ouverture de fenetre,
# focus, etc.). Les actions type/wait/scroll ne sont pas verifiees.
needs_screen_check = action_type in ("click", "key_combo")
hash_before = ""
if needs_screen_check:
hash_before = self._quick_screenshot_hash()
if action_type == "click": if action_type == "click":
# Si visual_mode est activé, le resolve DOIT réussir.
# Pas de fallback blind — on arrête le replay si la cible
# n'est pas trouvée visuellement. C'est un RPA VISUEL.
if visual_mode and not result.get("visual_resolved"):
result["success"] = False
result["error"] = "Visual resolve échoué — cible non trouvée à l'écran"
print(f" [ERREUR] Visual resolve échoué — STOP (pas de clic blind)")
logger.error(f"Action {action_id} : visual resolve échoué, replay stoppé")
return result
real_x = int(x_pct * width) real_x = int(x_pct * width)
real_y = int(y_pct * height) real_y = int(y_pct * height)
button = action.get("button", "left") button = action.get("button", "left")
mode = "VISUAL" if result["visual_resolved"] else "BLIND" mode = "VISUAL" if result.get("visual_resolved") else "COORD"
print( print(
f" [CLICK] [{mode}] ({x_pct:.3f}, {y_pct:.3f}) -> " f" [CLICK] [{mode}] ({x_pct:.3f}, {y_pct:.3f}) -> "
f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}" f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}"
@@ -198,7 +242,10 @@ class ActionExecutorV1:
elif action_type == "type": elif action_type == "type":
text = action.get("text", "") text = action.get("text", "")
raw_keys = action.get("raw_keys")
print(f" [TYPE] Texte: '{text[:50]}' ({len(text)} chars)") print(f" [TYPE] Texte: '{text[:50]}' ({len(text)} chars)")
if raw_keys:
print(f" [TYPE] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
# Cliquer sur le champ avant de taper (si coordonnees disponibles) # Cliquer sur le champ avant de taper (si coordonnees disponibles)
if x_pct > 0 and y_pct > 0: if x_pct > 0 and y_pct > 0:
real_x = int(x_pct * width) real_x = int(x_pct * width)
@@ -206,16 +253,26 @@ class ActionExecutorV1:
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})") print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
self._click((real_x, real_y), "left") self._click((real_x, real_y), "left")
time.sleep(0.3) time.sleep(0.3)
self.keyboard.type(text) if raw_keys:
self._replay_raw_keys(raw_keys)
else:
# Fallback copier-coller (anciens enregistrements sans raw_keys)
self._type_text(text)
print(f" [TYPE] Termine.") print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)") logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "key_combo": elif action_type == "key_combo":
keys = action.get("keys", []) keys = action.get("keys", [])
raw_keys = action.get("raw_keys")
print(f" [KEY_COMBO] Touches: {keys}") print(f" [KEY_COMBO] Touches: {keys}")
self._execute_key_combo(keys) if raw_keys:
print(f" [KEY_COMBO] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
self._replay_raw_keys(raw_keys)
else:
# Fallback (anciens enregistrements sans raw_keys)
self._execute_key_combo(keys)
print(f" [KEY_COMBO] Termine.") print(f" [KEY_COMBO] Termine.")
logger.info(f"Replay key_combo : {keys}") logger.info(f"Replay key_combo : {keys} (raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "scroll": elif action_type == "scroll":
real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width) real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width)
@@ -235,6 +292,25 @@ class ActionExecutorV1:
print(f" [WAIT] Termine.") print(f" [WAIT] Termine.")
logger.info(f"Replay wait : {duration_ms}ms") logger.info(f"Replay wait : {duration_ms}ms")
elif action_type == "verify_screen":
# Vérification visuelle entre les groupes du replay hybride.
# Pour l'instant, on fait un wait de 2s pour laisser l'écran
# se stabiliser. La vérification réelle sera faite par le
# pre-check côté serveur dans GET /replay/next.
expected_node = action.get("expected_node", "?")
timeout_ms = action.get("timeout_ms", 5000)
wait_s = min(timeout_ms / 1000.0, 2.0)
print(
f" [VERIFY] Attente verification ecran "
f"(node attendu: {expected_node}, wait={wait_s}s)"
)
time.sleep(wait_s)
print(f" [VERIFY] Termine (verification deferred au serveur).")
logger.info(
f"Replay verify_screen : node={expected_node}, "
f"wait={wait_s}s (verification serveur)"
)
else: else:
result["error"] = f"Type d'action inconnu : {action_type}" result["error"] = f"Type d'action inconnu : {action_type}"
logger.warning(result["error"]) logger.warning(result["error"])
@@ -242,8 +318,33 @@ class ActionExecutorV1:
result["success"] = True result["success"] = True
# Capturer un screenshot post-action # ---- Verification post-action : l'ecran a-t-il change ? ----
time.sleep(0.5) # Verifie UNIQUEMENT, ne tente PAS de gerer les popups
# (Enter/Escape perturbent l'application).
# Signale l'echec honnêtement — le serveur decide du retry.
if needs_screen_check and hash_before:
screen_changed = self._wait_for_screen_change(
hash_before, timeout_ms=3000
)
if not screen_changed:
result["success"] = False
result["warning"] = "no_screen_change"
result["error"] = "Ecran inchange apres l'action"
print(
f" [ECHEC] Ecran inchange apres {action_type}"
f"l'action n'a pas eu d'effet visible"
)
logger.warning(
f"Action {action_id} ({action_type}) : ecran inchange "
f"— action sans effet visible"
)
else:
print(f" [OK] Changement d'ecran detecte apres {action_type}")
else:
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
time.sleep(0.5)
# Capturer un screenshot post-action (apres stabilisation)
result["screenshot"] = self._capture_screenshot_b64() result["screenshot"] = self._capture_screenshot_b64()
except Exception as e: except Exception as e:
@@ -257,62 +358,136 @@ class ActionExecutorV1:
fallback_x: float, fallback_y: float, fallback_x: float, fallback_y: float,
screen_width: int, screen_height: int, screen_width: int, screen_height: int,
) -> dict: ) -> dict:
""" """Résoudre la position d'un clic visuellement.
Envoyer un screenshot au serveur pour resolution visuelle de la cible.
Capture l'ecran en haute resolution (pas de downscale pour le template Stratégie VLM-DIRECT : appelle Ollama directement depuis l'agent
matching), l'encode en base64 JPEG, et POST au endpoint (pas via le serveur streaming) pour éviter les timeouts quand le
/replay/resolve_target. Retourne les coordonnees resolues. serveur est occupé par le worker.
"""
import requests
1. VLM direct (screenshot + crop → Ollama) ~3-8s
2. Serveur streaming (fallback si Ollama échoue)
"""
import requests as _requests
import json as _json
screenshot_b64 = self._capture_screenshot_b64(max_width=0, quality=75)
if not screenshot_b64:
logger.warning("Capture screenshot echouee pour visual resolve")
return None
# ---- VLM DIRECT (Ollama) ----
vlm_result = self._vlm_direct_resolve(screenshot_b64, target_spec)
if vlm_result and vlm_result.get("resolved"):
return vlm_result
# ---- FALLBACK : serveur streaming ----
print(" [VISUAL] VLM direct echoue, fallback serveur...")
try: try:
# Capturer à 1280px max — assez pour le template matching
# et raisonnable pour le transfert réseau (~200-400Ko)
screenshot_b64 = self._capture_screenshot_b64(
max_width=1280,
quality=75,
)
if not screenshot_b64:
logger.warning("Capture screenshot echouee pour visual resolve")
return None
print(
f" [VISUAL] Envoi screenshot ({len(screenshot_b64) // 1024} Ko) "
f"au serveur pour resolution..."
)
# Appel au serveur
resolve_url = f"{server_url}/traces/stream/replay/resolve_target" resolve_url = f"{server_url}/traces/stream/replay/resolve_target"
payload = { payload = {
"session_id": "", # Pas critique pour la resolution "session_id": "",
"screenshot_b64": screenshot_b64, "screenshot_b64": screenshot_b64,
"target_spec": target_spec, "target_spec": target_spec,
"fallback_x_pct": fallback_x, "fallback_x_pct": fallback_x,
"fallback_y_pct": fallback_y, "fallback_y_pct": fallback_y,
"screen_width": screen_width, "screen_width": screen_width,
"screen_height": screen_height, "screen_height": screen_height,
"strict_mode": True,
} }
resp = _requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=30)
resp = requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=60)
if resp.ok: if resp.ok:
data = resp.json() data = resp.json()
method = data.get("method", "?") print(f" [VISUAL] Serveur : resolved={data.get('resolved')}, method={data.get('method')}")
resolved = data.get("resolved", False)
print(
f" [VISUAL] Reponse serveur : resolved={resolved}, "
f"method={method}, score={data.get('score', 'N/A')}"
)
return data return data
else: except Exception as e:
logger.warning(f"Visual resolve HTTP {resp.status_code}: {resp.text[:200]}") logger.warning(f"Visual resolve serveur echoue: {e}")
return None
def _vlm_direct_resolve(self, screenshot_b64: str, target_spec: dict) -> dict:
"""Appeler Ollama directement pour trouver l'élément à l'écran."""
import requests as _requests
import json as _json
import re
anchor_b64 = target_spec.get("anchor_image_base64", "")
vlm_description = target_spec.get("vlm_description", "")
by_text = target_spec.get("by_text", "")
window_title = target_spec.get("window_title", "")
if not anchor_b64 and not vlm_description:
return None
# Prompt
if anchor_b64 and vlm_description:
prompt = f"""The first image is the current screen. The second image shows the element to find.
{vlm_description}
Return the CENTER coordinates as percentage of the FIRST image dimensions.
Return ONLY JSON: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}
If not found: {{"x_pct": null, "y_pct": null, "confidence": 0.0}}"""
elif vlm_description:
prompt = f"""{vlm_description}
Return coordinates as percentage: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}"""
else:
prompt = f"""Find the element shown in the second image on the first image.
Return coordinates: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}"""
images = [screenshot_b64]
if anchor_b64:
images.append(anchor_b64)
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
payload = {
"model": os.environ.get("RPA_VLM_MODEL", "qwen3-vl:8b"),
"messages": [
{"role": "system", "content": "You are a UI element locator. Output raw JSON only."},
{"role": "user", "content": prompt, "images": images},
{"role": "assistant", "content": "{"},
],
"stream": False,
"think": False,
"options": {"temperature": 0.1, "num_predict": 100, "num_ctx": 2048},
}
try:
print(f" [VLM-DIRECT] Appel Ollama ({ollama_host}:11434)...")
start = time.time()
resp = _requests.post(ollama_url, json=payload, timeout=30)
elapsed = time.time() - start
if not resp.ok:
print(f" [VLM-DIRECT] HTTP {resp.status_code} ({elapsed:.1f}s)")
return None return None
except requests.exceptions.Timeout: content = "{" + resp.json().get("message", {}).get("content", "")
logger.warning("Visual resolve timeout (30s)") print(f" [VLM-DIRECT] Réponse en {elapsed:.1f}s : {content[:80]}")
# Parser JSON
match = re.search(r'\{[^}]+\}', content)
if not match:
return None
data = _json.loads(match.group())
x = data.get("x_pct")
y = data.get("y_pct")
conf = data.get("confidence", 0)
if x is None or y is None or conf < 0.3:
print(f" [VLM-DIRECT] Non trouvé (conf={conf})")
return None
if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
print(f" [VLM-DIRECT] Hors limites ({x}, {y})")
return None
print(f" [VLM-DIRECT] TROUVÉ ({x:.3f}, {y:.3f}) conf={conf:.2f} en {elapsed:.1f}s")
return {"resolved": True, "method": "vlm_direct", "x_pct": x, "y_pct": y, "score": conf}
except _requests.exceptions.Timeout:
print(" [VLM-DIRECT] Timeout 30s")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"Visual resolve echoue: {e}") print(f" [VLM-DIRECT] Erreur: {e}")
return None return None
def poll_and_execute(self, session_id: str, server_url: str, machine_id: str = "default") -> bool: def poll_and_execute(self, session_id: str, server_url: str, machine_id: str = "default") -> bool:
@@ -347,8 +522,19 @@ class ActionExecutorV1:
) )
if not resp.ok: if not resp.ok:
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}") logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
# Backoff sur erreur HTTP (serveur en erreur, route inconnue, etc.)
self._poll_backoff = min(
self._poll_backoff * self._poll_backoff_factor,
self._poll_backoff_max,
)
return False return False
# Le serveur a repondu 200 — reset le backoff immediatement,
# meme s'il n'y a pas d'action en attente. Cela garantit que
# l'agent reprend un polling rapide des que le serveur est OK.
self._poll_backoff = self._poll_backoff_min
self._last_conn_error_logged = False
data = resp.json() data = resp.json()
action = data.get("action") action = data.get("action")
if action is None: if action is None:
@@ -360,7 +546,7 @@ class ActionExecutorV1:
self._poll_backoff * self._poll_backoff_factor, self._poll_backoff * self._poll_backoff_factor,
self._poll_backoff_max, self._poll_backoff_max,
) )
if not hasattr(self, '_last_conn_error_logged'): if not hasattr(self, '_last_conn_error_logged') or not self._last_conn_error_logged:
self._last_conn_error_logged = True self._last_conn_error_logged = True
print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}") print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}")
logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}") logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}")
@@ -374,10 +560,6 @@ class ActionExecutorV1:
logger.error(f"Erreur poll GET : {e}") logger.error(f"Erreur poll GET : {e}")
return False return False
# Reset du flag d'erreur connexion et du backoff (on a reussi le GET)
self._last_conn_error_logged = False
self._poll_backoff = self._poll_backoff_min
# Phase 2 : Executer l'action et rapporter le resultat # Phase 2 : Executer l'action et rapporter le resultat
# TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution # TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution
action_type = action.get('type', '?') action_type = action.get('type', '?')
@@ -412,6 +594,7 @@ class ActionExecutorV1:
"action_id": result["action_id"], "action_id": result["action_id"],
"success": result["success"], "success": result["success"],
"error": result.get("error"), "error": result.get("error"),
"warning": result.get("warning"),
"screenshot": result.get("screenshot"), "screenshot": result.get("screenshot"),
} }
try: try:
@@ -438,10 +621,167 @@ class ActionExecutorV1:
return True return True
# =========================================================================
# Gestion automatique des popups imprevues
# =========================================================================
def _handle_possible_popup(self) -> bool:
"""Tenter de gerer une popup imprevue.
Appelee quand l'ecran n'a pas change apres une action click ou key_combo,
ce qui peut indiquer l'apparition d'une popup modale (dialogue de
confirmation "Voulez-vous remplacer ?", erreur, etc.) qui bloque
l'interaction attendue.
Strategie simple (non bloquante, max ~3s) :
1. Essayer Enter (valide le bouton par defaut de la popup)
2. Si ca ne marche pas, essayer Escape (ferme la popup)
3. Si ca ne marche pas, essayer Tab + Enter (selectionne "Oui" puis valide)
ATTENTION : ne PAS appeler pour les actions 'type' (la saisie de texte
ne change pas forcement l'ecran de facon detectable).
Returns:
True si une popup a ete geree (l'ecran a change), False sinon.
"""
hash_before = self._quick_screenshot_hash()
if not hash_before:
return False
strategies = [
("Enter", lambda: self._press_key(Key.enter)),
("Escape", lambda: self._press_key(Key.esc)),
("Tab+Enter", lambda: self._press_tab_enter()),
]
for name, action_fn in strategies:
logger.info(f"Popup handler : tentative {name}")
print(f" [POPUP] Tentative : {name}")
action_fn()
# Attendre max 1s pour voir si l'ecran change (non bloquant)
changed = self._wait_for_screen_change(hash_before, timeout_ms=1000)
if changed:
logger.info(f"Popup handler : {name} a fonctionne (ecran change)")
print(f" [POPUP] {name} a fonctionne — popup geree")
return True
logger.info("Popup handler : aucune strategie n'a fonctionne")
print(" [POPUP] Aucune strategie n'a fonctionne")
return False
def _press_key(self, key):
"""Appuyer et relacher une touche unique."""
self.keyboard.press(key)
self.keyboard.release(key)
def _press_tab_enter(self):
"""Tab puis Enter (selectionner le bouton suivant puis valider)."""
self.keyboard.press(Key.tab)
self.keyboard.release(Key.tab)
time.sleep(0.1)
self.keyboard.press(Key.enter)
self.keyboard.release(Key.enter)
# =========================================================================
# Verification post-action (comparaison screenshots avant/apres)
# =========================================================================
def _quick_screenshot_hash(self) -> str:
"""Hash rapide du screenshot actuel (MD5 de l'image redimensionnee 64x64 en niveaux de gris).
Utilise une instance mss locale pour la thread-safety.
Retourne une chaine vide en cas d'erreur (PIL absent, etc.).
"""
try:
from PIL import Image
with mss.mss() as local_sct:
monitor = local_sct.monitors[1]
raw = local_sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Redimensionner a 64x64 en niveaux de gris pour un hash perceptuel rapide
small = img.resize((64, 64)).convert("L")
return hashlib.md5(small.tobytes()).hexdigest()
except Exception as e:
logger.debug(f"Impossible de calculer le hash screenshot : {e}")
return ""
def _wait_for_screen_change(self, hash_before: str, timeout_ms: int = 5000) -> bool:
"""Attendre que l'ecran change apres une action (max timeout_ms).
Verifie toutes les 200ms si le hash du screenshot a change.
Retourne True si l'ecran a change, False si timeout atteint.
"""
if not hash_before:
return True # Pas de reference → considerer comme change
deadline = time.time() + timeout_ms / 1000
check_count = 0
while time.time() < deadline:
time.sleep(0.2) # 200ms entre chaque verification
current_hash = self._quick_screenshot_hash()
check_count += 1
if current_hash and current_hash != hash_before:
logger.info(f"Ecran change apres ~{check_count * 200}ms")
return True
logger.warning(
f"Ecran inchange apres {timeout_ms}ms ({check_count} verifications)"
)
return False
# ========================================================================= # =========================================================================
# Helpers # Helpers
# ========================================================================= # =========================================================================
def _type_text(self, text: str):
"""Saisir du texte via copier-coller (methode principale) ou keyboard.type (fallback).
Le copier-coller via le presse-papiers est la methode principale car
keyboard.type() de pynput envoie les scancodes QWERTY, ce qui produit
des caracteres incorrects sur les claviers AZERTY (ex: "ce" -> "ci").
Le copier-coller est agnostique du layout clavier.
"""
if not text:
return
clipboard_ok = False
try:
import pyperclip
# Sauvegarder le contenu actuel du presse-papiers
try:
old_clipboard = pyperclip.paste()
except Exception:
old_clipboard = None
pyperclip.copy(text)
# Ctrl+V pour coller
self.keyboard.press(Key.ctrl)
time.sleep(0.02)
self.keyboard.press('v')
self.keyboard.release('v')
self.keyboard.release(Key.ctrl)
time.sleep(0.1)
# Restaurer le presse-papiers original
if old_clipboard is not None:
try:
pyperclip.copy(old_clipboard)
except Exception:
pass
clipboard_ok = True
logger.debug(f"Texte saisi via presse-papiers ({len(text)} chars)")
except ImportError:
logger.debug("pyperclip non disponible, fallback sur keyboard.type()")
except Exception as e:
logger.warning(f"Copier-coller echoue ({e}), fallback sur keyboard.type()")
if not clipboard_ok:
self.keyboard.type(text)
def _click(self, pos, button_name): def _click(self, pos, button_name):
"""Deplacer la souris et cliquer. """Deplacer la souris et cliquer.
@@ -500,6 +840,50 @@ class ActionExecutorV1:
for mod in reversed(modifiers): for mod in reversed(modifiers):
self.keyboard.release(mod) self.keyboard.release(mod)
def _replay_raw_keys(self, raw_keys: list):
"""Rejouer une séquence press/release exacte via virtual key codes.
Utilise KeyCode.from_vk() pour reconstituer les touches à partir
de leur vk code, ce qui garantit un replay fidèle indépendant du
layout clavier (AZERTY, QWERTZ, etc.).
Chaque événement raw_key est un dict avec :
- "action": "press" ou "release"
- "kind": "vk" (touche avec virtual key code) ou "key" (touche spéciale pynput)
- "vk": int (si kind == "vk")
- "name": str (si kind == "key", ex: "ctrl_l", "enter")
- "char": str ou None (si kind == "vk", informatif)
"""
for event in raw_keys:
key = self._decode_raw_key(event)
if key is None:
continue
action = event.get("action", "")
if action == "press":
self.keyboard.press(key)
elif action == "release":
self.keyboard.release(key)
else:
logger.warning(f"Action raw_key inconnue : {action}")
continue
time.sleep(0.01) # Petit délai entre chaque événement
@staticmethod
def _decode_raw_key(data: dict):
"""Décoder un événement raw_key en objet pynput (Key ou KeyCode).
Retourne None si le décodage échoue (touche inconnue).
"""
kind = data.get("kind", "")
if kind == "key":
name = data.get("name", "")
return getattr(Key, name, None)
if kind == "vk":
vk = data.get("vk")
if vk is not None:
return KeyCode.from_vk(vk)
return None
def _capture_screenshot_b64(self, max_width: int = 800, quality: int = 60) -> str: def _capture_screenshot_b64(self, max_width: int = 800, quality: int = 60) -> str:
""" """
Capturer l'ecran et retourner le screenshot en base64. Capturer l'ecran et retourner le screenshot en base64.
@@ -512,8 +896,12 @@ class ActionExecutorV1:
try: try:
from PIL import Image from PIL import Image
monitor = self.sct.monitors[1] # Créer une instance mss locale (thread-safe)
raw = self.sct.grab(monitor) # mss utilise des handles Windows thread-local (srcdc, memdc)
# qui ne peuvent pas être partagés entre threads
with mss.mss() as local_sct:
monitor = local_sct.monitors[1]
raw = local_sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX") img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Redimensionner si max_width > 0 # Redimensionner si max_width > 0
@@ -530,5 +918,7 @@ class ActionExecutorV1:
logger.debug("PIL non disponible, pas de screenshot base64") logger.debug("PIL non disponible, pas de screenshot base64")
return "" return ""
except Exception as e: except Exception as e:
logger.debug(f"Capture screenshot base64 echouee : {e}") logger.warning(f"Capture screenshot base64 echouee : {e}")
import traceback
traceback.print_exc()
return "" return ""

View File

@@ -14,7 +14,10 @@ import uuid
import time import time
import logging import logging
import threading import threading
from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME,
)
from .core.captor import EventCaptorV1 from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1 from .core.executor import ActionExecutorV1
from .network.streamer import TraceStreamer from .network.streamer import TraceStreamer
@@ -103,6 +106,14 @@ class AgentV1:
self._capture_server = CaptureServer() self._capture_server = CaptureServer()
self._capture_server.start() self._capture_server.start()
# Bannière de démarrage avec métadonnées système
logger.info(
f"Agent V1 v{AGENT_VERSION} | Machine={self.machine_id} | "
f"Ecran={SCREEN_RESOLUTION[0]}x{SCREEN_RESOLUTION[1]} | "
f"DPI={DPI_SCALE}% | Theme={OS_THEME} | "
f"Serveur={SERVER_URL}"
)
# UI Tray intelligent (remplace TrayAppV1, plus de PyQt5) # UI Tray intelligent (remplace TrayAppV1, plus de PyQt5)
self.ui = SmartTrayV1( self.ui = SmartTrayV1(
self.start_session, self.start_session,
@@ -142,8 +153,9 @@ class AgentV1:
# Watchdog de Commandes (GHOST Replay — legacy fichier) # Watchdog de Commandes (GHOST Replay — legacy fichier)
threading.Thread(target=self._command_watchdog_loop, daemon=True).start() threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
# Boucle de polling replay (P0-5 — pull depuis le serveur) # Note: la boucle de polling replay est déjà lancée dans __init__ (ligne 102)
threading.Thread(target=self._replay_poll_loop, daemon=True).start() # 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} ({workflow_name}) sur machine {self.machine_id} en cours...")
@@ -159,7 +171,7 @@ class AgentV1:
else: else:
cmd_path = str(BASE_DIR / "command.json") cmd_path = str(BASE_DIR / "command.json")
while self.running: while self.running and self.session_id:
# Ne pas traiter les commandes fichier pendant un replay serveur # Ne pas traiter les commandes fichier pendant un replay serveur
if self._replay_active: if self._replay_active:
time.sleep(1) time.sleep(1)
@@ -197,8 +209,11 @@ class AgentV1:
time.sleep(REPLAY_POLL_INTERVAL) time.sleep(REPLAY_POLL_INTERVAL)
continue continue
# Utiliser la session active ou un ID par défaut pour le replay # TOUJOURS utiliser un session_id stable pour le replay.
poll_session = self.session_id or f"agent_{self.user_id}" # L'enregistrement et le replay sont indépendants : le serveur
# envoie les actions sur agent_{user_id}, pas sur la session
# d'enregistrement (sess_xxx).
poll_session = f"agent_{self.user_id}"
# Log periodique pour confirmer que la boucle tourne (toutes les 60s) # Log periodique pour confirmer que la boucle tourne (toutes les 60s)
poll_count += 1 poll_count += 1
@@ -290,18 +305,40 @@ class AgentV1:
time.sleep(5) time.sleep(5)
def stop_session(self): def stop_session(self):
self.running = False # Arrêter la capture et le streaming de la session d'enregistrement
if self.captor: self.captor.stop() if self.captor: self.captor.stop()
if self.streamer: self.streamer.stop() if self.streamer: self.streamer.stop()
logger.info(f"Session {self.session_id} terminée.") logger.info(f"Session {self.session_id} terminée.")
# Reset le session_id pour que le poll replay utilise l'ID stable
self.session_id = None
# Reset le backoff de l'executor pour reprendre le polling immédiatement
if self._executor:
self._executor._poll_backoff = self._executor._poll_backoff_min
self._executor._server_available = True
if hasattr(self._executor, '_last_conn_error_logged'):
self._executor._last_conn_error_logged = False
# NE PAS mettre self.running = False ici !
# self.running contrôle la boucle _replay_poll_loop (permanente).
# Seule la sortie du programme doit le mettre à False.
# Les boucles _heartbeat_loop et _command_watchdog_loop vérifieront
# self.session_id pour savoir si elles doivent fonctionner.
logger.info(
f"Session arrêtée — replay poll actif avec session="
f"agent_{self.user_id}"
)
_last_heartbeat_hash: str = "" _last_heartbeat_hash: str = ""
def _heartbeat_loop(self): def _heartbeat_loop(self):
"""Capture périodique pour donner du contexte au stagiaire. """Capture périodique pour donner du contexte au stagiaire.
Déduplication : n'envoie que si l'écran a changé. Déduplication : n'envoie que si l'écran a changé.
Tourne tant que session_id est défini (= enregistrement actif).
""" """
while self.running: while self.running and self.session_id:
try: try:
full_path = self.vision.capture_full_context("heartbeat") full_path = self.vision.capture_full_context("heartbeat")
if full_path: if full_path:

View File

@@ -413,10 +413,8 @@ class ChatWindow:
buttons = [ buttons = [
("\U0001f393 Apprenez-moi", self._on_quick_record), ("\U0001f393 Apprenez-moi", self._on_quick_record),
("\u25b6\ufe0f Lancer", self._on_quick_tasks), ("\u25b6\ufe0f Lancer une t\u00e2che", self._on_quick_tasks),
("\U0001f4ca Donn\u00e9es", self._on_quick_import),
("\u23f9\ufe0f Arr\u00eater", self._on_quick_stop), ("\u23f9\ufe0f Arr\u00eater", self._on_quick_stop),
("\u2753 Aide", self._on_quick_help),
] ]
for text, cmd in buttons: for text, cmd in buttons:

View File

@@ -0,0 +1,195 @@
# agent_v1/vision/system_info.py
"""
Capture des metadonnees systeme pour enrichir les evenements.
Collecte DPI, resolution, fenetre active, moniteur, theme OS et langue.
Les fonctions Windows (ctypes.windll, winreg) ont des fallbacks gracieux
pour Linux/Mac.
"""
import platform
import locale
import logging
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Cache du systeme d'exploitation pour eviter les appels repetes
_SYSTEM = platform.system()
def get_dpi_scale() -> int:
"""Retourne le facteur DPI en % (100 = normal, 150 = haute resolution).
Windows : ctypes.windll.user32.GetDpiForSystem()
Linux/Mac : fallback 100
NOTE : Le process DOIT deja etre DPI-aware (via SetProcessDpiAwareness(2)
appele dans config.py) pour que GetDpiForSystem retourne le vrai DPI.
"""
if _SYSTEM == "Windows":
try:
import ctypes
dpi = ctypes.windll.user32.GetDpiForSystem()
return round(dpi * 100 / 96) # 96 DPI = 100%
except Exception as e:
logger.debug(f"Impossible de lire le DPI Windows : {e}")
return 100
return 100 # Linux/Mac fallback
def get_window_bounds() -> Optional[List[int]]:
"""Retourne [x, y, width, height] de la fenetre active.
Windows : ctypes GetWindowRect(GetForegroundWindow())
Linux/Mac : fallback None
"""
if _SYSTEM == "Windows":
try:
import ctypes
import ctypes.wintypes
hwnd = ctypes.windll.user32.GetForegroundWindow()
if not hwnd:
return None
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect))
return [
rect.left,
rect.top,
rect.right - rect.left,
rect.bottom - rect.top,
]
except Exception as e:
logger.debug(f"Impossible de lire les bounds fenetre : {e}")
return None
# Linux : tentative via xdotool
if _SYSTEM == "Linux":
try:
import subprocess
wid = subprocess.check_output(
["xdotool", "getactivewindow"],
stderr=subprocess.DEVNULL,
).decode().strip()
geom = subprocess.check_output(
["xdotool", "getwindowgeometry", "--shell", wid],
stderr=subprocess.DEVNULL,
).decode()
# Parse "X=...\nY=...\nWIDTH=...\nHEIGHT=..."
vals: Dict[str, int] = {}
for line in geom.strip().splitlines():
if "=" in line:
k, v = line.split("=", 1)
vals[k.strip()] = int(v.strip())
if {"X", "Y", "WIDTH", "HEIGHT"} <= vals.keys():
return [vals["X"], vals["Y"], vals["WIDTH"], vals["HEIGHT"]]
except Exception:
pass
return None
def get_monitor_info() -> Tuple[int, List[Dict[str, int]]]:
"""Retourne (monitor_index, liste_moniteurs).
Chaque moniteur : {width, height, x, y}
monitor_index : index du moniteur contenant la fenetre active
"""
monitors: List[Dict[str, int]] = []
active_index = 0
try:
import mss
with mss.mss() as sct:
for mon in sct.monitors[1:]: # Skip le moniteur virtuel (index 0)
monitors.append({
"width": mon["width"],
"height": mon["height"],
"x": mon["left"],
"y": mon["top"],
})
except Exception as e:
logger.debug(f"mss indisponible, resolution par defaut : {e}")
monitors = [{"width": 1920, "height": 1080, "x": 0, "y": 0}]
# Determiner quel moniteur contient la fenetre active
bounds = get_window_bounds()
if bounds and len(monitors) > 1:
wx, wy = bounds[0], bounds[1]
for i, mon in enumerate(monitors):
if (mon["x"] <= wx < mon["x"] + mon["width"]
and mon["y"] <= wy < mon["y"] + mon["height"]):
active_index = i
break
return active_index, monitors
def get_os_theme() -> str:
"""Retourne 'light', 'dark' ou 'unknown'."""
if _SYSTEM == "Windows":
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
)
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
winreg.CloseKey(key)
return "light" if value == 1 else "dark"
except Exception as e:
logger.debug(f"Impossible de lire le theme Windows : {e}")
return "unknown"
# Linux : tentative via gsettings (GNOME)
if _SYSTEM == "Linux":
try:
import subprocess
result = subprocess.check_output(
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
stderr=subprocess.DEVNULL,
).decode().strip().strip("'\"")
if "dark" in result.lower():
return "dark"
elif "light" in result.lower() or "default" in result.lower():
return "light"
except Exception:
pass
return "unknown"
def get_os_language() -> str:
"""Retourne le code langue (fr, en, de, etc.)."""
try:
lang = locale.getdefaultlocale()[0] # ex: 'fr_FR'
if lang:
return lang[:2] # ex: 'fr'
except Exception:
pass
return "unknown"
def get_screen_metadata() -> Dict[str, Any]:
"""Capture toutes les metadonnees systeme en une fois.
Appelee une fois au demarrage + a chaque changement de focus.
Resultat injecte dans les evenements envoyes au serveur.
"""
monitor_index, monitors = get_monitor_info()
primary = monitors[0] if monitors else {"width": 1920, "height": 1080}
return {
"dpi_scale": get_dpi_scale(),
"monitor_index": monitor_index,
"monitors": monitors,
"screen_resolution": [primary["width"], primary["height"]],
"window_bounds": get_window_bounds(),
"os_theme": get_os_theme(),
"os_language": get_os_language(),
}

View File

@@ -8,6 +8,23 @@ import platform
import socket import socket
from pathlib import Path from pathlib import Path
# --- DPI awareness (DOIT etre appele avant tout import de pynput/mss/tkinter) ---
# Rend le process DPI-aware sur Windows pour que toutes les API (pynput, mss, pyautogui)
# travaillent en coordonnees physiques (pixels reels) au lieu de coordonnees logiques
# (virtualisees par le DPI scaling).
# Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067 pour les API,
# ce qui cause des erreurs de positionnement pendant le replay.
# PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
if platform.system() == "Windows":
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE
except Exception:
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
AGENT_VERSION = "1.0.0" AGENT_VERSION = "1.0.0"
# Identifiant unique de la machine (utilisé pour le multi-machine) # Identifiant unique de la machine (utilisé pour le multi-machine)

View File

@@ -6,13 +6,25 @@ Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (
Supporte deux modes : Supporte deux modes :
- Watchdog fichier (command.json) — legacy - Watchdog fichier (command.json) — legacy
- Polling serveur (GET /replay/next) — mode replay P0-5 - Polling serveur (GET /replay/next) — mode replay P0-5
NOTE DPI : Ce module depend du DPI awareness configure dans config.py.
L'appel a SetProcessDpiAwareness(2) DOIT avoir ete fait avant l'import de
pynput et mss, sinon les coordonnees seront en pixels logiques (faux sur
les ecrans haute resolution avec DPI scaling > 100%).
""" """
import base64 import base64
import hashlib
import io import io
import os
import time import time
import logging import logging
# Forcer l'import de config AVANT pynput/mss pour garantir que le
# 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
import mss import mss
from pynput.mouse import Button, Controller as MouseController from pynput.mouse import Button, Controller as MouseController
from pynput.keyboard import Controller as KeyboardController, Key from pynput.keyboard import Controller as KeyboardController, Key
@@ -65,6 +77,28 @@ class ActionExecutorV1:
self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes) self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes)
self._poll_backoff_max = 30.0 # Delai maximal self._poll_backoff_max = 30.0 # Delai maximal
self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec
# Token d'authentification API
self._api_token = os.environ.get("RPA_API_TOKEN", "")
# Log de la resolution physique pour le diagnostic DPI
self._log_screen_info()
def _log_screen_info(self):
"""Log la resolution physique de l'ecran au demarrage pour le diagnostic DPI."""
try:
monitor = self.sct.monitors[1]
w, h = monitor["width"], monitor["height"]
logger.info(
f"Executor initialise — resolution physique : {w}x{h} "
f"(mss monitors[1], DPI-aware process)"
)
except Exception as e:
logger.debug(f"Impossible de lire la resolution ecran : {e}")
def _auth_headers(self) -> dict:
"""Headers d'authentification Bearer pour les requetes au serveur."""
if self._api_token:
return {"Authorization": f"Bearer {self._api_token}"}
return {}
@property @property
def sct(self): def sct(self):
@@ -171,6 +205,15 @@ class ActionExecutorV1:
f"-> ({x_pct:.4f}, {y_pct:.4f})" f"-> ({x_pct:.4f}, {y_pct:.4f})"
) )
# ---- Hash AVANT l'action (pour verification post-action) ----
# Seules les actions click et key_combo sont verifiees : elles
# provoquent un changement visible de l'ecran (ouverture de fenetre,
# focus, etc.). Les actions type/wait/scroll ne sont pas verifiees.
needs_screen_check = action_type in ("click", "key_combo")
hash_before = ""
if needs_screen_check:
hash_before = self._quick_screenshot_hash()
if action_type == "click": if action_type == "click":
real_x = int(x_pct * width) real_x = int(x_pct * width)
real_y = int(y_pct * height) real_y = int(y_pct * height)
@@ -197,7 +240,7 @@ class ActionExecutorV1:
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})") print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
self._click((real_x, real_y), "left") self._click((real_x, real_y), "left")
time.sleep(0.3) time.sleep(0.3)
self.keyboard.type(text) self._type_text(text)
print(f" [TYPE] Termine.") print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)") logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)")
@@ -226,6 +269,25 @@ class ActionExecutorV1:
print(f" [WAIT] Termine.") print(f" [WAIT] Termine.")
logger.info(f"Replay wait : {duration_ms}ms") logger.info(f"Replay wait : {duration_ms}ms")
elif action_type == "verify_screen":
# Vérification visuelle entre les groupes du replay hybride.
# Pour l'instant, on fait un wait de 2s pour laisser l'écran
# se stabiliser. La vérification réelle sera faite par le
# pre-check côté serveur dans GET /replay/next.
expected_node = action.get("expected_node", "?")
timeout_ms = action.get("timeout_ms", 5000)
wait_s = min(timeout_ms / 1000.0, 2.0)
print(
f" [VERIFY] Attente verification ecran "
f"(node attendu: {expected_node}, wait={wait_s}s)"
)
time.sleep(wait_s)
print(f" [VERIFY] Termine (verification deferred au serveur).")
logger.info(
f"Replay verify_screen : node={expected_node}, "
f"wait={wait_s}s (verification serveur)"
)
else: else:
result["error"] = f"Type d'action inconnu : {action_type}" result["error"] = f"Type d'action inconnu : {action_type}"
logger.warning(result["error"]) logger.warning(result["error"])
@@ -233,8 +295,41 @@ class ActionExecutorV1:
result["success"] = True result["success"] = True
# Capturer un screenshot post-action # ---- Verification post-action : l'ecran a-t-il change ? ----
time.sleep(0.5) if needs_screen_check and hash_before:
screen_changed = self._wait_for_screen_change(
hash_before, timeout_ms=5000
)
if not screen_changed:
# Ecran inchange — tenter de gerer une popup imprevue
# (dialogue de confirmation, erreur, etc.)
popup_handled = self._handle_possible_popup()
if popup_handled:
result["warning"] = "popup_handled"
print(
f" [OK] Popup geree automatiquement apres {action_type}"
)
logger.info(
f"Action {action_id} ({action_type}) : popup geree "
f"automatiquement"
)
else:
result["warning"] = "no_screen_change"
print(
f" [WARN] Ecran inchange apres {action_type}"
f"l'action n'a peut-etre pas eu d'effet"
)
logger.warning(
f"Action {action_id} ({action_type}) : ecran inchange "
f"apres 5s — possible echec silencieux"
)
else:
print(f" [OK] Changement d'ecran detecte apres {action_type}")
else:
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
time.sleep(0.5)
# Capturer un screenshot post-action (apres stabilisation)
result["screenshot"] = self._capture_screenshot_b64() result["screenshot"] = self._capture_screenshot_b64()
except Exception as e: except Exception as e:
@@ -251,17 +346,18 @@ class ActionExecutorV1:
""" """
Envoyer un screenshot au serveur pour resolution visuelle de la cible. Envoyer un screenshot au serveur pour resolution visuelle de la cible.
Capture l'ecran en haute resolution (pas de downscale pour le template Capture l'ecran en resolution native (pas de downscale, necessaire pour
matching), l'encode en base64 JPEG, et POST au endpoint le template matching precis cross-resolution), l'encode en base64 JPEG,
/replay/resolve_target. Retourne les coordonnees resolues. et POST au endpoint /replay/resolve_target. Retourne les coordonnees resolues.
""" """
import requests import requests
try: try:
# Capturer à 1280px max — assez pour le template matching # Capturer à résolution native pour le template matching
# et raisonnable pour le transfert réseau (~200-400Ko) # (le downscale nuit à la précision du matching quand les
# résolutions d'apprentissage et de replay diffèrent)
screenshot_b64 = self._capture_screenshot_b64( screenshot_b64 = self._capture_screenshot_b64(
max_width=1280, max_width=0,
quality=75, quality=75,
) )
if not screenshot_b64: if not screenshot_b64:
@@ -283,9 +379,10 @@ class ActionExecutorV1:
"fallback_y_pct": fallback_y, "fallback_y_pct": fallback_y,
"screen_width": screen_width, "screen_width": screen_width,
"screen_height": screen_height, "screen_height": screen_height,
"strict_mode": True, # Replay = seuil strict 0.90 + YOLO
} }
resp = requests.post(resolve_url, json=payload, timeout=60) resp = requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=60)
if resp.ok: if resp.ok:
data = resp.json() data = resp.json()
method = data.get("method", "?") method = data.get("method", "?")
@@ -333,12 +430,24 @@ class ActionExecutorV1:
resp = requests.get( resp = requests.get(
replay_next_url, replay_next_url,
params={"session_id": session_id, "machine_id": machine_id}, params={"session_id": session_id, "machine_id": machine_id},
headers=self._auth_headers(),
timeout=5, timeout=5,
) )
if not resp.ok: if not resp.ok:
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}") logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
# Backoff sur erreur HTTP (serveur en erreur, route inconnue, etc.)
self._poll_backoff = min(
self._poll_backoff * self._poll_backoff_factor,
self._poll_backoff_max,
)
return False return False
# Le serveur a repondu 200 — reset le backoff immediatement,
# meme s'il n'y a pas d'action en attente. Cela garantit que
# l'agent reprend un polling rapide des que le serveur est OK.
self._poll_backoff = self._poll_backoff_min
self._last_conn_error_logged = False
data = resp.json() data = resp.json()
action = data.get("action") action = data.get("action")
if action is None: if action is None:
@@ -350,7 +459,7 @@ class ActionExecutorV1:
self._poll_backoff * self._poll_backoff_factor, self._poll_backoff * self._poll_backoff_factor,
self._poll_backoff_max, self._poll_backoff_max,
) )
if not hasattr(self, '_last_conn_error_logged'): if not hasattr(self, '_last_conn_error_logged') or not self._last_conn_error_logged:
self._last_conn_error_logged = True self._last_conn_error_logged = True
print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}") print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}")
logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}") logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}")
@@ -364,10 +473,6 @@ class ActionExecutorV1:
logger.error(f"Erreur poll GET : {e}") logger.error(f"Erreur poll GET : {e}")
return False return False
# Reset du flag d'erreur connexion et du backoff (on a reussi le GET)
self._last_conn_error_logged = False
self._poll_backoff = self._poll_backoff_min
# Phase 2 : Executer l'action et rapporter le resultat # Phase 2 : Executer l'action et rapporter le resultat
# TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution # TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution
action_type = action.get('type', '?') action_type = action.get('type', '?')
@@ -402,12 +507,14 @@ class ActionExecutorV1:
"action_id": result["action_id"], "action_id": result["action_id"],
"success": result["success"], "success": result["success"],
"error": result.get("error"), "error": result.get("error"),
"warning": result.get("warning"),
"screenshot": result.get("screenshot"), "screenshot": result.get("screenshot"),
} }
try: try:
resp2 = requests.post( resp2 = requests.post(
replay_result_url, replay_result_url,
json=report, json=report,
headers=self._auth_headers(),
timeout=10, timeout=10,
) )
if resp2.ok: if resp2.ok:
@@ -427,10 +534,167 @@ class ActionExecutorV1:
return True return True
# =========================================================================
# Gestion automatique des popups imprevues
# =========================================================================
def _handle_possible_popup(self) -> bool:
"""Tenter de gerer une popup imprevue.
Appelee quand l'ecran n'a pas change apres une action click ou key_combo,
ce qui peut indiquer l'apparition d'une popup modale (dialogue de
confirmation "Voulez-vous remplacer ?", erreur, etc.) qui bloque
l'interaction attendue.
Strategie simple (non bloquante, max ~3s) :
1. Essayer Enter (valide le bouton par defaut de la popup)
2. Si ca ne marche pas, essayer Escape (ferme la popup)
3. Si ca ne marche pas, essayer Tab + Enter (selectionne "Oui" puis valide)
ATTENTION : ne PAS appeler pour les actions 'type' (la saisie de texte
ne change pas forcement l'ecran de facon detectable).
Returns:
True si une popup a ete geree (l'ecran a change), False sinon.
"""
hash_before = self._quick_screenshot_hash()
if not hash_before:
return False
strategies = [
("Enter", lambda: self._press_key(Key.enter)),
("Escape", lambda: self._press_key(Key.esc)),
("Tab+Enter", lambda: self._press_tab_enter()),
]
for name, action_fn in strategies:
logger.info(f"Popup handler : tentative {name}")
print(f" [POPUP] Tentative : {name}")
action_fn()
# Attendre max 1s pour voir si l'ecran change (non bloquant)
changed = self._wait_for_screen_change(hash_before, timeout_ms=1000)
if changed:
logger.info(f"Popup handler : {name} a fonctionne (ecran change)")
print(f" [POPUP] {name} a fonctionne — popup geree")
return True
logger.info("Popup handler : aucune strategie n'a fonctionne")
print(" [POPUP] Aucune strategie n'a fonctionne")
return False
def _press_key(self, key):
"""Appuyer et relacher une touche unique."""
self.keyboard.press(key)
self.keyboard.release(key)
def _press_tab_enter(self):
"""Tab puis Enter (selectionner le bouton suivant puis valider)."""
self.keyboard.press(Key.tab)
self.keyboard.release(Key.tab)
time.sleep(0.1)
self.keyboard.press(Key.enter)
self.keyboard.release(Key.enter)
# =========================================================================
# Verification post-action (comparaison screenshots avant/apres)
# =========================================================================
def _quick_screenshot_hash(self) -> str:
"""Hash rapide du screenshot actuel (MD5 de l'image redimensionnee 64x64 en niveaux de gris).
Utilise une instance mss locale pour la thread-safety.
Retourne une chaine vide en cas d'erreur (PIL absent, etc.).
"""
try:
from PIL import Image
with mss.mss() as local_sct:
monitor = local_sct.monitors[1]
raw = local_sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Redimensionner a 64x64 en niveaux de gris pour un hash perceptuel rapide
small = img.resize((64, 64)).convert("L")
return hashlib.md5(small.tobytes()).hexdigest()
except Exception as e:
logger.debug(f"Impossible de calculer le hash screenshot : {e}")
return ""
def _wait_for_screen_change(self, hash_before: str, timeout_ms: int = 5000) -> bool:
"""Attendre que l'ecran change apres une action (max timeout_ms).
Verifie toutes les 200ms si le hash du screenshot a change.
Retourne True si l'ecran a change, False si timeout atteint.
"""
if not hash_before:
return True # Pas de reference → considerer comme change
deadline = time.time() + timeout_ms / 1000
check_count = 0
while time.time() < deadline:
time.sleep(0.2) # 200ms entre chaque verification
current_hash = self._quick_screenshot_hash()
check_count += 1
if current_hash and current_hash != hash_before:
logger.info(f"Ecran change apres ~{check_count * 200}ms")
return True
logger.warning(
f"Ecran inchange apres {timeout_ms}ms ({check_count} verifications)"
)
return False
# ========================================================================= # =========================================================================
# Helpers # Helpers
# ========================================================================= # =========================================================================
def _type_text(self, text: str):
"""Saisir du texte via copier-coller (methode principale) ou keyboard.type (fallback).
Le copier-coller via le presse-papiers est la methode principale car
keyboard.type() de pynput envoie les scancodes QWERTY, ce qui produit
des caracteres incorrects sur les claviers AZERTY (ex: "ce" -> "ci").
Le copier-coller est agnostique du layout clavier.
"""
if not text:
return
clipboard_ok = False
try:
import pyperclip
# Sauvegarder le contenu actuel du presse-papiers
try:
old_clipboard = pyperclip.paste()
except Exception:
old_clipboard = None
pyperclip.copy(text)
# Ctrl+V pour coller
self.keyboard.press(Key.ctrl)
time.sleep(0.02)
self.keyboard.press('v')
self.keyboard.release('v')
self.keyboard.release(Key.ctrl)
time.sleep(0.1)
# Restaurer le presse-papiers original
if old_clipboard is not None:
try:
pyperclip.copy(old_clipboard)
except Exception:
pass
clipboard_ok = True
logger.debug(f"Texte saisi via presse-papiers ({len(text)} chars)")
except ImportError:
logger.debug("pyperclip non disponible, fallback sur keyboard.type()")
except Exception as e:
logger.warning(f"Copier-coller echoue ({e}), fallback sur keyboard.type()")
if not clipboard_ok:
self.keyboard.type(text)
def _click(self, pos, button_name): def _click(self, pos, button_name):
"""Deplacer la souris et cliquer. """Deplacer la souris et cliquer.
@@ -501,8 +765,12 @@ class ActionExecutorV1:
try: try:
from PIL import Image from PIL import Image
monitor = self.sct.monitors[1] # Créer une instance mss locale (thread-safe)
raw = self.sct.grab(monitor) # mss utilise des handles Windows thread-local (srcdc, memdc)
# qui ne peuvent pas être partagés entre threads
with mss.mss() as local_sct:
monitor = local_sct.monitors[1]
raw = local_sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX") img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Redimensionner si max_width > 0 # Redimensionner si max_width > 0
@@ -519,5 +787,7 @@ class ActionExecutorV1:
logger.debug("PIL non disponible, pas de screenshot base64") logger.debug("PIL non disponible, pas de screenshot base64")
return "" return ""
except Exception as e: except Exception as e:
logger.debug(f"Capture screenshot base64 echouee : {e}") logger.warning(f"Capture screenshot base64 echouee : {e}")
import traceback
traceback.print_exc()
return "" return ""

File diff suppressed because it is too large Load Diff

View File

@@ -158,16 +158,35 @@ class LiveSessionManager:
session.events.append(event_data) session.events.append(event_data)
session.last_activity = datetime.now() session.last_activity = datetime.now()
# Extraire le contexte fenêtre si présent # Extraire le contexte fenêtre si présent
# Format 1 : {"window": {"title": ..., "app_name": ...}} (Python agent)
# Format 2 : {"window_title": "...", "screen_resolution": [w, h]} (Rust agent)
window = event_data.get("window") window = event_data.get("window")
if window and isinstance(window, dict): if window and isinstance(window, dict):
session.last_window_info = window session.last_window_info = window
# Accumuler les titres/apps pour le nommage automatique elif event_data.get("window_title"):
title = window.get("title", "").strip() # Format Rust agent : extraire le titre et la résolution
app_name = window.get("app_name", "").strip() info = {
if title and title != "Unknown": "title": event_data["window_title"],
session.window_titles_seen[title] = session.window_titles_seen.get(title, 0) + 1 "app_name": session.last_window_info.get("app_name", "unknown"),
if app_name and app_name != "unknown": }
session.app_names_seen[app_name] = session.app_names_seen.get(app_name, 0) + 1 # Propager la résolution si fournie par l'agent
screen_res = event_data.get("screen_resolution")
if screen_res and isinstance(screen_res, list) and len(screen_res) == 2:
info["screen_resolution"] = screen_res
# Propager les métadonnées d'environnement graphique
for meta_key in ("dpi_scale", "monitor_index", "window_bounds",
"monitors", "os_theme", "os_language"):
meta_val = event_data.get(meta_key)
if meta_val is not None:
info[meta_key] = meta_val
session.last_window_info = info
# Accumuler les titres/apps pour le nommage automatique
title = session.last_window_info.get("title", "").strip()
app_name = session.last_window_info.get("app_name", "").strip()
if title and title != "Unknown":
session.window_titles_seen[title] = session.window_titles_seen.get(title, 0) + 1
if app_name and app_name != "unknown":
session.app_names_seen[app_name] = session.app_names_seen.get(app_name, 0) + 1
self._maybe_persist(session_id) self._maybe_persist(session_id)
def add_screenshot(self, session_id: str, shot_id: str, file_path: str) -> None: def add_screenshot(self, session_id: str, shot_id: str, file_path: str) -> None:
@@ -227,16 +246,41 @@ class LiveSessionManager:
"captured_at": datetime.now().isoformat(), "captured_at": datetime.now().isoformat(),
}) })
# Résolution réelle depuis les events (envoyée par l'agent Rust/Python),
# fallback sur 1920x1080 si non disponible
screen_res = session.last_window_info.get("screen_resolution", [1920, 1080])
# Métadonnées d'environnement graphique dynamiques
screen_info: Dict[str, Any] = {"primary_resolution": screen_res}
dpi_scale = session.last_window_info.get("dpi_scale")
if dpi_scale is not None:
screen_info["dpi_scale"] = dpi_scale
monitors = session.last_window_info.get("monitors")
if monitors is not None:
screen_info["monitors"] = monitors
monitor_index = session.last_window_info.get("monitor_index")
if monitor_index is not None:
screen_info["monitor_index"] = monitor_index
env_info: Dict[str, Any] = {
"os": platform.system().lower(),
"hostname": socket.gethostname(),
"machine_id": session.machine_id,
"screen": screen_info,
}
# Propager os_theme / os_language si disponibles
os_theme = session.last_window_info.get("os_theme")
if os_theme is not None:
env_info["os_theme"] = os_theme
os_language = session.last_window_info.get("os_language")
if os_language is not None:
env_info["os_language"] = os_language
return { return {
"schema_version": "rawsession_v1", "schema_version": "rawsession_v1",
"session_id": session.session_id, "session_id": session.session_id,
"agent_version": "agent_v1_stream", "agent_version": "agent_v1_stream",
"environment": { "environment": env_info,
"os": platform.system().lower(),
"hostname": socket.gethostname(),
"machine_id": session.machine_id,
"screen": {"primary_resolution": [1920, 1080]},
},
"user": {"id": "remote_agent"}, "user": {"id": "remote_agent"},
"context": { "context": {
"workflow": session.last_window_info.get("title", ""), "workflow": session.last_window_info.get("title", ""),

View File

@@ -0,0 +1,397 @@
# agent_v0/server_v1/run_worker.py
"""
Worker VLM autonome — tourne dans un process Python SEPARE du serveur HTTP.
Résout le problème du GIL : le serveur HTTP (FastAPI) reste réactif car le
VLM (ScreenAnalyzer, CLIP, FAISS, GraphBuilder) tourne dans ce process dédié.
Usage:
python -m agent_v0.server_v1.run_worker
Architecture :
Process 1 : Serveur HTTP (FastAPI, port 5005) — distribue les replays, reçoit events/images
Process 2 : Ce worker — analyse VLM des sessions finalisées
Process 3 : Ollama (port 11434) — LLM local
Communication inter-process par fichiers (pas de Redis) :
- _worker_queue.txt : liste des session_ids à traiter (ajoutés par le serveur HTTP)
- _replay_active.lock : quand présent, le worker se suspend (le GPU est utilisé par le replay)
Le worker :
1. Scanne _worker_queue.txt pour trouver les sessions à traiter
2. Vérifie _replay_active.lock avant chaque screenshot (priorité au replay)
3. Traite les sessions une par une (VLM + CLIP + GraphBuilder)
4. Sauvegarde les workflows JSON sur disque
5. Se suspend quand un replay est actif (libère le GPU)
"""
import logging
import os
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("vlm_worker")
# Chemins de base (relatifs au working directory = racine du projet)
ROOT_DIR = Path(__file__).parent.parent.parent
DATA_DIR = ROOT_DIR / "data" / "training"
LIVE_SESSIONS_DIR = DATA_DIR / "live_sessions"
QUEUE_FILE = DATA_DIR / "_worker_queue.txt"
REPLAY_LOCK = DATA_DIR / "_replay_active.lock"
# Intervalle de polling quand la queue est vide (secondes)
POLL_INTERVAL = 10
# Intervalle de vérification du replay lock (secondes)
REPLAY_CHECK_INTERVAL = 2
# Timeout max d'attente du replay lock avant reprise forcée (secondes)
REPLAY_WAIT_TIMEOUT = 120
class VLMWorker:
"""Worker VLM autonome qui traite les sessions finalisées.
Tourne en boucle infinie dans un process séparé du serveur HTTP.
Communique via le filesystem :
- Lit les session_ids depuis _worker_queue.txt
- Vérifie _replay_active.lock pour se suspendre
- Écrit les workflows dans data/training/workflows/
"""
def __init__(self):
self._running = False
self._processor = None # Initialisé au premier besoin (lazy loading GPU)
self._current_session: Optional[str] = None
# Stats
self._stats: Dict[str, int] = {
"sessions_processed": 0,
"sessions_failed": 0,
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
}
self._completed: List[Dict] = []
self._failed: List[Dict] = []
def _get_processor(self):
"""Lazy init du StreamProcessor (charge les modèles GPU au premier appel)."""
if self._processor is None:
logger.info("Initialisation du StreamProcessor (chargement GPU)...")
from .stream_processor import StreamProcessor
self._processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
logger.info("StreamProcessor initialisé.")
return self._processor
def start(self):
"""Boucle principale du worker."""
self._running = True
logger.info(
"VLM Worker démarré — surveillance de %s",
QUEUE_FILE,
)
logger.info(" Replay lock : %s", REPLAY_LOCK)
logger.info(" Sessions dir : %s", LIVE_SESSIONS_DIR)
logger.info(" Poll interval : %ds", POLL_INTERVAL)
while self._running:
try:
# Vérifier si un replay est actif
if self._is_replay_active():
self._wait_for_replay_end()
continue
# Lire la prochaine session de la queue
session_id = self._read_next_session()
if session_id:
self._process_session(session_id)
else:
time.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
logger.info("Interruption clavier, arrêt du worker.")
self._running = False
except Exception as e:
logger.error("Erreur dans la boucle principale : %s", e, exc_info=True)
time.sleep(5) # Éviter une boucle d'erreurs rapide
logger.info("VLM Worker arrêté.")
def stop(self):
"""Arrêt propre du worker."""
self._running = False
logger.info("Arrêt demandé.")
# =========================================================================
# Queue management (fichier _worker_queue.txt)
# =========================================================================
def _read_next_session(self) -> Optional[str]:
"""Lit et retire le premier session_id de la queue.
Format du fichier : une ligne par session_id.
Retire la ligne traitée de façon atomique (réécriture complète).
"""
if not QUEUE_FILE.exists():
return None
try:
lines = QUEUE_FILE.read_text(encoding="utf-8").strip().splitlines()
if not lines:
return None
# Prendre le premier session_id non vide
session_id = None
remaining = []
for line in lines:
line = line.strip()
if not line:
continue
if session_id is None:
session_id = line
else:
remaining.append(line)
# Réécrire le fichier sans la première ligne (atomique via rename)
tmp_file = QUEUE_FILE.with_suffix(".tmp")
if remaining:
tmp_file.write_text(
"\n".join(remaining) + "\n",
encoding="utf-8",
)
else:
tmp_file.write_text("", encoding="utf-8")
tmp_file.rename(QUEUE_FILE)
if session_id:
logger.info(
"Session déqueuée : %s (%d restantes dans la queue)",
session_id,
len(remaining),
)
return session_id
except Exception as e:
logger.error("Erreur lecture queue %s : %s", QUEUE_FILE, e)
return None
# =========================================================================
# Replay lock (_replay_active.lock)
# =========================================================================
def _is_replay_active(self) -> bool:
"""Vérifie si un replay est en cours (fichier lock présent)."""
return REPLAY_LOCK.exists()
def _wait_for_replay_end(self):
"""Attend que le replay se termine (suppression du fichier lock).
Timeout de sécurité : REPLAY_WAIT_TIMEOUT secondes max.
"""
start = time.time()
logger.info(
"Replay actif détecté (%s), worker en pause...",
REPLAY_LOCK,
)
while self._running and REPLAY_LOCK.exists():
elapsed = time.time() - start
if elapsed > REPLAY_WAIT_TIMEOUT:
logger.warning(
"Timeout d'attente du replay (%ds), reprise forcée.",
REPLAY_WAIT_TIMEOUT,
)
break
time.sleep(REPLAY_CHECK_INTERVAL)
elapsed = time.time() - start
if elapsed > 0.5:
logger.info("Replay terminé, worker reprend après %.1fs de pause.", elapsed)
# =========================================================================
# Traitement d'une session
# =========================================================================
def _process_session(self, session_id: str):
"""Traite une session complète (analyse VLM + construction workflow)."""
self._current_session = session_id
logger.info("=== Début traitement session %s ===", session_id)
start_time = time.time()
try:
proc = self._get_processor()
# Vérifier que le dossier session existe
session_dir = proc._find_session_dir(session_id)
if not session_dir:
logger.error(
"Dossier session %s introuvable, skip.",
session_id,
)
self._stats["sessions_skipped"] += 1
return
shots_dir = session_dir / "shots"
full_shots = sorted(shots_dir.glob("shot_*_full.png")) if shots_dir.exists() else []
if not full_shots:
logger.warning(
"Aucun screenshot full dans %s, skip.",
shots_dir,
)
self._stats["sessions_skipped"] += 1
return
logger.info(
"Session %s : %d screenshots full à analyser dans %s",
session_id,
len(full_shots),
shots_dir,
)
# Utiliser reprocess_session du StreamProcessor
# qui fait : ScreenAnalyzer + CLIP + FAISS + GraphBuilder
result = proc.reprocess_session(
session_id,
progress_callback=self._progress_callback,
)
elapsed = time.time() - start_time
if result.get("error"):
logger.error(
"Échec session %s après %.1fs : %s",
session_id,
elapsed,
result["error"],
)
self._stats["sessions_failed"] += 1
self._failed.append({
"session_id": session_id,
"error": result["error"],
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
elif result.get("status") == "insufficient_data":
logger.warning(
"Session %s : données insuffisantes (%d states) après %.1fs",
session_id,
result.get("states_count", 0),
elapsed,
)
self._stats["sessions_failed"] += 1
self._failed.append({
"session_id": session_id,
"error": "insufficient_data",
"states_count": result.get("states_count", 0),
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
else:
logger.info(
"Session %s traitée en %.1fs | workflow=%s | %d nodes, %d edges",
session_id,
elapsed,
result.get("workflow_id", "?"),
result.get("nodes", 0),
result.get("edges", 0),
)
self._stats["sessions_processed"] += 1
self._stats["total_screenshots_analyzed"] += result.get("states_analyzed", 0)
self._completed.append({
"session_id": session_id,
"workflow_id": result.get("workflow_id"),
"workflow_name": result.get("workflow_name"),
"nodes": result.get("nodes", 0),
"edges": result.get("edges", 0),
"states_analyzed": result.get("states_analyzed", 0),
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
except Exception as e:
elapsed = time.time() - start_time
logger.error(
"Exception inattendue pour session %s après %.1fs : %s",
session_id,
elapsed,
e,
exc_info=True,
)
self._stats["sessions_failed"] += 1
self._failed.append({
"session_id": session_id,
"error": f"exception: {e}",
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
finally:
self._current_session = None
logger.info("=== Fin traitement session %s ===", session_id)
def _progress_callback(self, session_id: str, current: int, total: int, shot_id: str = ""):
"""Callback de progression appelé par reprocess_session.
Vérifie aussi le replay lock entre chaque screenshot.
"""
logger.info(
"Session %s : screenshot %d/%d%s",
session_id,
current,
total,
f" ({shot_id})" if shot_id else "",
)
# Vérifier si un replay est devenu actif pendant le traitement
if self._is_replay_active():
logger.info(
"Replay détecté pendant l'analyse de %s, pause...",
session_id,
)
self._wait_for_replay_end()
def main():
"""Point d'entrée du worker VLM autonome."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [VLM-WORKER] %(levelname)s %(message)s",
)
# Réduire le bruit des loggers tiers
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
# Créer les dossiers nécessaires
DATA_DIR.mkdir(parents=True, exist_ok=True)
LIVE_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
worker = VLMWorker()
# Gestion propre des signaux
def _handle_signal(signum, frame):
logger.info("Signal %s reçu, arrêt en cours...", signal.Signals(signum).name)
worker.stop()
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
# Afficher l'état au démarrage
print(f"\n{'='*60}")
print(f" VLM Worker — Process séparé du serveur HTTP")
print(f" Queue : {QUEUE_FILE}")
print(f" Lock : {REPLAY_LOCK}")
print(f" PID : {os.getpid()}")
print(f"{'='*60}\n")
worker.start()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

6
core/auth/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
# core/auth — Module d'authentification automatique pour Léa
#
# Fournit :
# - CredentialVault : coffre-fort chiffré pour les credentials
# - TOTPGenerator : générateur TOTP RFC 6238 (sans dépendance externe)
# - AuthHandler : détection d'écrans d'auth et injection automatique

523
core/auth/auth_handler.py Normal file
View File

@@ -0,0 +1,523 @@
"""
Gestionnaire d'authentification automatique pendant le replay.
Détecte les écrans d'authentification et injecte les credentials appropriés.
Fonctionne avec le ScreenState du core pipeline et le CredentialVault chiffré.
Stratégie de détection :
1. Analyse OCR : cherche des patterns textuels indicatifs d'un écran d'auth
("mot de passe", "identifiant", "code de vérification", etc.)
2. Analyse UI : cherche des éléments sémantiques typiques (champ password,
bouton "Se connecter", etc.)
3. Identification de l'application : via window_title du ScreenState
La confiance est calculée selon le nombre de signaux détectés :
- 1 signal = 0.3 (faible)
- 2 signaux = 0.6 (moyen)
- 3+ signaux = 0.85+ (élevé)
"""
import logging
import re
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from .credential_vault import CredentialVault
from .totp_generator import TOTPGenerator
logger = logging.getLogger(__name__)
# =========================================================================
# Patterns de détection d'écrans d'authentification
# =========================================================================
# Patterns OCR (texte visible sur l'écran) — FR + EN pour support bilingue
_AUTH_TEXT_PATTERNS = [
# Français
r"mot\s+de\s+passe",
r"identifiant",
r"nom\s+d'utilisateur",
r"connexion",
r"se\s+connecter",
r"authentification",
r"code\s+de\s+v[ée]rification",
r"code\s+otp",
r"double\s+authentification",
r"v[ée]rification\s+en\s+deux\s+[ée]tapes",
# Anglais
r"password",
r"username",
r"sign\s+in",
r"log\s*in",
r"verification\s+code",
r"two.factor",
r"2fa",
r"one.time\s+password",
r"enter\s+your\s+code",
]
# Patterns pour identifier spécifiquement un écran TOTP/2FA
_TOTP_TEXT_PATTERNS = [
r"code\s+de\s+v[ée]rification",
r"code\s+otp",
r"double\s+authentification",
r"v[ée]rification\s+en\s+deux",
r"two.factor",
r"2fa",
r"one.time\s+password",
r"enter\s+your\s+code",
r"code\s+[àa]\s+\d+\s+chiffres",
r"authenticator",
]
# Libellés de boutons de validation
_SUBMIT_BUTTON_PATTERNS = [
r"se\s+connecter",
r"connexion",
r"valider",
r"envoyer",
r"confirmer",
r"sign\s+in",
r"log\s*in",
r"submit",
r"verify",
r"ok",
]
# Compilations pour performance
_AUTH_REGEXES = [re.compile(p, re.IGNORECASE) for p in _AUTH_TEXT_PATTERNS]
_TOTP_REGEXES = [re.compile(p, re.IGNORECASE) for p in _TOTP_TEXT_PATTERNS]
_SUBMIT_REGEXES = [re.compile(p, re.IGNORECASE) for p in _SUBMIT_BUTTON_PATTERNS]
@dataclass
class AuthRequest:
"""Requête d'authentification détectée sur un écran.
Attributes:
auth_type: Type d'authentification détecté ("login", "totp", "login_and_totp").
app_name: Application identifiée (depuis window_title).
detected_fields: Champs détectés sur l'écran (positions, types).
confidence: Confiance de la détection (0.0 à 1.0).
"""
auth_type: str # "login", "totp", "login_and_totp"
app_name: str # App identifiée (depuis window_title)
detected_fields: Dict[str, Any] = field(default_factory=dict)
confidence: float = 0.0
class AuthHandler:
"""Gestionnaire d'authentification automatique pour le replay.
Analyse les ScreenStates pour détecter les écrans d'authentification
et génère les actions de replay correspondantes.
Usage :
handler = AuthHandler(vault)
auth_req = handler.detect_auth_screen(screen_state)
if auth_req:
actions = handler.get_auth_actions(auth_req)
# Injecter les actions dans la queue de replay
"""
def __init__(self, vault: CredentialVault):
"""Initialise le gestionnaire d'authentification.
Args:
vault: Instance du coffre-fort de credentials.
"""
self._vault = vault
def detect_auth_screen(self, screen_state: Any) -> Optional[AuthRequest]:
"""Analyse un ScreenState pour détecter un écran d'authentification.
La détection combine plusieurs signaux :
- Textes OCR correspondant à des patterns d'auth
- Éléments UI de type password/text_input
- Boutons de validation ("Se connecter", "Valider")
Args:
screen_state: ScreenState du core pipeline (ou dict compatible).
Returns:
AuthRequest si un écran d'auth est détecté avec confiance > 0.3,
None sinon.
"""
# Extraire les textes détectés et les éléments UI
texts = self._extract_texts(screen_state)
ui_elements = self._extract_ui_elements(screen_state)
app_name = self._extract_app_name(screen_state)
# Compteur de signaux de détection
signals: Dict[str, Any] = {}
# Signal 1 : Patterns textuels d'authentification
auth_text_matches = []
for text in texts:
for regex in _AUTH_REGEXES:
if regex.search(text):
auth_text_matches.append(regex.pattern)
if auth_text_matches:
signals["auth_text"] = auth_text_matches
# Signal 2 : Patterns textuels TOTP/2FA
totp_text_matches = []
for text in texts:
for regex in _TOTP_REGEXES:
if regex.search(text):
totp_text_matches.append(regex.pattern)
if totp_text_matches:
signals["totp_text"] = totp_text_matches
# Signal 3 : Champs UI de type password
password_fields = []
username_fields = []
submit_buttons = []
otp_fields = []
for elem in ui_elements:
elem_type = self._get_elem_attr(elem, "type", "")
elem_role = self._get_elem_attr(elem, "role", "")
elem_label = self._get_elem_attr(elem, "label", "").lower()
elem_tags = self._get_elem_attr(elem, "tags", [])
# Champ mot de passe
if elem_role == "password" or "password" in elem_tags:
password_fields.append(elem)
elif elem_type == "text_input" and any(
p in elem_label for p in ("mot de passe", "password", "mdp")
):
password_fields.append(elem)
# Champ identifiant/username
if elem_type == "text_input" and any(
p in elem_label
for p in ("identifiant", "username", "utilisateur", "login", "email", "e-mail")
):
username_fields.append(elem)
# Champ OTP
if elem_type == "text_input" and any(
p in elem_label for p in ("code", "otp", "vérification", "verification")
):
otp_fields.append(elem)
# Bouton de validation
if elem_type == "button":
for regex in _SUBMIT_REGEXES:
if regex.search(elem_label):
submit_buttons.append(elem)
break
if password_fields:
signals["password_field"] = len(password_fields)
if username_fields:
signals["username_field"] = len(username_fields)
if submit_buttons:
signals["submit_button"] = len(submit_buttons)
if otp_fields:
signals["otp_field"] = len(otp_fields)
# Pas assez de signaux → pas d'écran d'auth
if not signals:
return None
# Déterminer le type d'auth
# Les signaux textuels "auth_text" peuvent contenir des patterns ambigus
# (ex: "2fa" apparaît dans les deux listes). On ne compte comme signal
# login que les patterns auth_text qui ne sont PAS aussi des patterns TOTP.
auth_only_text = set(signals.get("auth_text", [])) - set(signals.get("totp_text", []))
has_login_signals = bool(
password_fields
or auth_only_text
or username_fields
)
has_totp_signals = bool(
otp_fields
or "totp_text" in signals
)
if has_login_signals and has_totp_signals:
auth_type = "login_and_totp"
elif has_totp_signals:
auth_type = "totp"
else:
auth_type = "login"
# Calculer la confiance (nombre de signaux distincts)
num_signals = len(signals)
if num_signals >= 4:
confidence = 0.95
elif num_signals >= 3:
confidence = 0.85
elif num_signals >= 2:
confidence = 0.6
else:
confidence = 0.3
# Construire les champs détectés
detected_fields: Dict[str, Any] = {}
if username_fields:
detected_fields["username_field"] = self._elem_to_dict(username_fields[0])
if password_fields:
detected_fields["password_field"] = self._elem_to_dict(password_fields[0])
if otp_fields:
detected_fields["otp_field"] = self._elem_to_dict(otp_fields[0])
if submit_buttons:
detected_fields["submit_button"] = self._elem_to_dict(submit_buttons[0])
auth_request = AuthRequest(
auth_type=auth_type,
app_name=app_name,
detected_fields=detected_fields,
confidence=confidence,
)
logger.info(
"Écran d'authentification détecté : type=%s app=%s confiance=%.2f signaux=%s",
auth_type,
app_name,
confidence,
list(signals.keys()),
)
return auth_request
def get_auth_actions(self, auth_request: AuthRequest) -> List[Dict[str, Any]]:
"""Génère les actions de replay pour s'authentifier.
Produit une séquence d'actions que l'Agent V1 peut exécuter :
- click sur le champ username, type le login
- click sur le champ password, type le mot de passe
- (optionnel) type le code TOTP
- click sur le bouton de validation
Args:
auth_request: Requête d'authentification détectée.
Returns:
Liste d'actions de replay (format compatible avec la queue de replay).
Liste vide si les credentials ne sont pas trouvés dans le vault.
"""
actions: List[Dict[str, Any]] = []
app_name = auth_request.app_name
fields = auth_request.detected_fields
# Générer un préfixe unique pour les action_ids
prefix = f"auth_{uuid.uuid4().hex[:6]}"
# ---- Login : username + password ----
if auth_request.auth_type in ("login", "login_and_totp"):
login_creds = self._vault.get_credential(app_name, "login")
if not login_creds:
logger.warning(
"Pas de credential 'login' pour l'app '%s' dans le vault",
app_name,
)
return []
# Action : cliquer sur le champ username et taper
username_field = fields.get("username_field")
if username_field:
actions.append({
"action_id": f"{prefix}_click_username",
"type": "click",
"target": username_field.get("center", [0, 0]),
"description": f"Clic champ identifiant ({app_name})",
"_auth_action": True,
})
actions.append({
"action_id": f"{prefix}_type_username",
"type": "type_text",
"text": login_creds.get("username", ""),
"description": f"Saisie identifiant ({app_name})",
"_auth_action": True,
})
# Action : cliquer sur le champ password et taper
password_field = fields.get("password_field")
if password_field:
actions.append({
"action_id": f"{prefix}_click_password",
"type": "click",
"target": password_field.get("center", [0, 0]),
"description": f"Clic champ mot de passe ({app_name})",
"_auth_action": True,
})
actions.append({
"action_id": f"{prefix}_type_password",
"type": "type_text",
"text": login_creds.get("password", ""),
"description": f"Saisie mot de passe ({app_name})",
"_auth_action": True,
})
# ---- TOTP : générer et taper le code ----
if auth_request.auth_type in ("totp", "login_and_totp"):
totp_creds = self._vault.get_credential(app_name, "totp_seed")
if not totp_creds:
logger.warning(
"Pas de credential 'totp_seed' pour l'app '%s' dans le vault",
app_name,
)
# On continue quand même si le login a été fait
if not actions:
return []
else:
totp = TOTPGenerator(
secret=totp_creds["secret"],
digits=totp_creds.get("digits", 6),
interval=totp_creds.get("interval", 30),
algorithm=totp_creds.get("algorithm", "SHA1"),
)
# Attendre si le code expire dans moins de 5 secondes
remaining = totp.time_remaining()
if remaining < 5:
actions.append({
"action_id": f"{prefix}_wait_totp",
"type": "wait",
"duration_ms": (remaining + 1) * 1000,
"reason": "attente_nouveau_code_totp",
"description": f"Attente nouveau code TOTP ({remaining}s restantes)",
"_auth_action": True,
})
code = totp.generate()
otp_field = fields.get("otp_field")
if otp_field:
actions.append({
"action_id": f"{prefix}_click_otp",
"type": "click",
"target": otp_field.get("center", [0, 0]),
"description": f"Clic champ OTP ({app_name})",
"_auth_action": True,
})
actions.append({
"action_id": f"{prefix}_type_totp",
"type": "type_text",
"text": code,
"description": f"Saisie code TOTP ({app_name})",
"_auth_action": True,
})
# ---- Bouton de validation ----
submit_button = fields.get("submit_button")
if submit_button and actions:
actions.append({
"action_id": f"{prefix}_click_submit",
"type": "click",
"target": submit_button.get("center", [0, 0]),
"description": f"Clic validation ({app_name})",
"_auth_action": True,
})
# Pause après validation pour laisser l'app charger
if actions:
actions.append({
"action_id": f"{prefix}_wait_after_auth",
"type": "wait",
"duration_ms": 2000,
"reason": "attente_chargement_post_auth",
"description": f"Attente post-authentification ({app_name})",
"_auth_action": True,
})
logger.info(
"Actions d'authentification générées : %d actions pour %s (type=%s)",
len(actions),
app_name,
auth_request.auth_type,
)
return actions
# =========================================================================
# Méthodes d'extraction internes
# =========================================================================
def _extract_texts(self, screen_state: Any) -> List[str]:
"""Extrait tous les textes détectés depuis un ScreenState.
Supporte les objets ScreenState du core et les dicts bruts.
"""
texts: List[str] = []
# ScreenState core (dataclass)
if hasattr(screen_state, "perception") and hasattr(
screen_state.perception, "detected_text"
):
texts.extend(screen_state.perception.detected_text)
# Dict brut (sessions streaming)
elif isinstance(screen_state, dict):
perception = screen_state.get("perception", {})
if isinstance(perception, dict):
texts.extend(perception.get("detected_text", []))
# Texte OCR brut
if "ocr_text" in screen_state:
texts.append(screen_state["ocr_text"])
# Textes des éléments UI
for elem in screen_state.get("ui_elements", []):
label = elem.get("label", "")
if label:
texts.append(label)
# Textes des éléments UI (objets)
if hasattr(screen_state, "ui_elements"):
for elem in screen_state.ui_elements:
label = self._get_elem_attr(elem, "label", "")
if label:
texts.append(label)
return texts
def _extract_ui_elements(self, screen_state: Any) -> List[Any]:
"""Extrait les éléments UI depuis un ScreenState."""
if hasattr(screen_state, "ui_elements"):
return list(screen_state.ui_elements)
if isinstance(screen_state, dict):
return screen_state.get("ui_elements", [])
return []
def _extract_app_name(self, screen_state: Any) -> str:
"""Extrait le nom de l'application depuis un ScreenState."""
# ScreenState core
if hasattr(screen_state, "window") and hasattr(screen_state.window, "app_name"):
return screen_state.window.app_name
# Dict brut
if isinstance(screen_state, dict):
window = screen_state.get("window", {})
if isinstance(window, dict):
return window.get("app_name", "unknown")
return "unknown"
@staticmethod
def _get_elem_attr(elem: Any, attr: str, default: Any = None) -> Any:
"""Récupère un attribut d'un élément UI (objet ou dict)."""
if isinstance(elem, dict):
return elem.get(attr, default)
return getattr(elem, attr, default)
@staticmethod
def _elem_to_dict(elem: Any) -> Dict[str, Any]:
"""Convertit un élément UI en dict minimal pour les detected_fields."""
if isinstance(elem, dict):
return {
"type": elem.get("type", ""),
"label": elem.get("label", ""),
"center": elem.get("center", [0, 0]),
"element_id": elem.get("element_id", ""),
}
return {
"type": getattr(elem, "type", ""),
"label": getattr(elem, "label", ""),
"center": list(getattr(elem, "center", (0, 0))),
"element_id": getattr(elem, "element_id", ""),
}

View File

@@ -0,0 +1,298 @@
"""
Coffre-fort chiffré pour les credentials d'authentification.
Stocke de façon sécurisée :
- Comptes de service (login/password)
- Seeds TOTP pour la 2FA
- Tokens de session
- Certificats client
Le fichier vault est chiffré avec Fernet (AES-128-CBC + HMAC-SHA256).
La clé est dérivée d'un mot de passe maître via PBKDF2 (600000 itérations).
Choix de sécurité :
- PBKDF2 avec 600 000 itérations : recommandation OWASP 2023 pour SHA-256.
Compromis acceptable entre temps de dérivation (~0.5s) et résistance au brute-force.
- Fernet (AES-128-CBC + HMAC-SHA256) : chiffrement authentifié, empêche les
modifications silencieuses du fichier vault. Bibliothèque maintenue et auditée.
- Salt aléatoire de 16 bytes : empêche les attaques par rainbow table.
Stocké en clair en préfixe du fichier (le salt n'est pas un secret).
"""
import base64
import json
import logging
import os
import warnings
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Types de credentials supportés
CREDENTIAL_TYPES = {"login", "totp_seed", "session_token", "certificate"}
# Taille du salt en bytes
SALT_SIZE = 16
# Nombre d'itérations PBKDF2 — recommandation OWASP 2023 pour SHA-256
PBKDF2_ITERATIONS = 600_000
# Tentative d'import de cryptography pour le chiffrement Fernet
_HAS_FERNET = False
try:
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
_HAS_FERNET = True
except ImportError:
_HAS_FERNET = False
warnings.warn(
"Module 'cryptography' non disponible. Le vault utilisera un encodage "
"base64 NON SÉCURISÉ. NE PAS utiliser en production.",
stacklevel=2,
)
class CredentialVault:
"""Coffre-fort chiffré pour les credentials d'applications.
Usage :
vault = CredentialVault("/chemin/vault.enc", "mot_de_passe_maitre")
vault.add_credential("DPI_Crossway", "login", {
"username": "robot_lea", "password": "xxx", "domain": "HOPITAL"
})
vault.save()
creds = vault.get_credential("DPI_Crossway", "login")
"""
def __init__(self, vault_path: str, master_password: str):
"""Charge ou crée un vault chiffré.
Args:
vault_path: Chemin du fichier vault chiffré sur disque.
master_password: Mot de passe maître pour dériver la clé de chiffrement.
"""
self._vault_path = Path(vault_path)
self._master_password = master_password
self._data: Dict[str, Any] = {
"version": "1.0",
"created_at": datetime.now(timezone.utc).isoformat(),
"credentials": {},
}
if self._vault_path.exists():
self._load()
else:
logger.info("Vault inexistant, création d'un nouveau vault : %s", vault_path)
# =========================================================================
# API publique
# =========================================================================
def add_credential(
self, app_name: str, credential_type: str, data: Dict[str, Any]
) -> None:
"""Ajoute ou met à jour un credential pour une application.
Args:
app_name: Nom de l'application (ex: "DPI_Crossway").
credential_type: Type parmi "login", "totp_seed", "session_token", "certificate".
data: Dictionnaire avec les champs spécifiques au type.
Raises:
ValueError: Si le credential_type n'est pas supporté.
"""
if credential_type not in CREDENTIAL_TYPES:
raise ValueError(
f"Type de credential invalide : {credential_type!r}. "
f"Types supportés : {CREDENTIAL_TYPES}"
)
if app_name not in self._data["credentials"]:
self._data["credentials"][app_name] = {}
self._data["credentials"][app_name][credential_type] = data
logger.info(
"Credential ajouté : app=%s type=%s", app_name, credential_type
)
def get_credential(
self, app_name: str, credential_type: str
) -> Optional[Dict[str, Any]]:
"""Récupère un credential pour une application.
Args:
app_name: Nom de l'application.
credential_type: Type de credential recherché.
Returns:
Dictionnaire du credential, ou None si non trouvé.
"""
app_creds = self._data["credentials"].get(app_name, {})
return app_creds.get(credential_type)
def remove_credential(self, app_name: str, credential_type: str) -> bool:
"""Supprime un credential.
Args:
app_name: Nom de l'application.
credential_type: Type de credential à supprimer.
Returns:
True si supprimé, False si non trouvé.
"""
app_creds = self._data["credentials"].get(app_name, {})
if credential_type in app_creds:
del app_creds[credential_type]
# Nettoyer l'app si plus de credentials
if not app_creds:
del self._data["credentials"][app_name]
logger.info(
"Credential supprimé : app=%s type=%s", app_name, credential_type
)
return True
return False
def list_apps(self) -> List[str]:
"""Liste les noms d'applications configurées.
Returns:
Liste triée des noms d'applications.
"""
return sorted(self._data["credentials"].keys())
def list_credential_types(self, app_name: str) -> List[str]:
"""Liste les types de credentials pour une application.
Args:
app_name: Nom de l'application.
Returns:
Liste des types de credentials configurés.
"""
return list(self._data["credentials"].get(app_name, {}).keys())
def save(self) -> None:
"""Chiffre et sauvegarde le vault sur disque."""
plaintext = json.dumps(self._data, ensure_ascii=False, indent=2).encode("utf-8")
encrypted = self._encrypt(plaintext)
# Écriture atomique via fichier temporaire
tmp_path = self._vault_path.with_suffix(".tmp")
self._vault_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_bytes(encrypted)
tmp_path.rename(self._vault_path)
logger.info("Vault sauvegardé : %s (%d bytes)", self._vault_path, len(encrypted))
# =========================================================================
# Chiffrement / Déchiffrement
# =========================================================================
def _derive_key(self, password: str, salt: bytes) -> bytes:
"""Dérive une clé Fernet à partir du mot de passe maître.
Utilise PBKDF2-HMAC-SHA256 avec 600 000 itérations (OWASP 2023).
La sortie est encodée en base64 URL-safe pour Fernet (32 bytes → 44 chars).
Args:
password: Mot de passe maître.
salt: Salt aléatoire (16 bytes minimum).
Returns:
Clé Fernet encodée en base64 URL-safe (44 bytes).
"""
if _HAS_FERNET:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=PBKDF2_ITERATIONS,
)
key = base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))
return key
else:
# Fallback non sécurisé — simple hash pour le développement
import hashlib
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS
)
return base64.urlsafe_b64encode(dk)
def _encrypt(self, plaintext: bytes) -> bytes:
"""Chiffre les données avec Fernet (ou base64 en fallback).
Format du fichier vault :
[16 bytes salt][données chiffrées Fernet]
Args:
plaintext: Données en clair à chiffrer.
Returns:
Bytes chiffrés avec le salt en préfixe.
"""
salt = os.urandom(SALT_SIZE)
key = self._derive_key(self._master_password, salt)
if _HAS_FERNET:
fernet = Fernet(key)
encrypted = fernet.encrypt(plaintext)
else:
# Fallback : base64 simple (NON sécurisé)
encrypted = base64.urlsafe_b64encode(plaintext)
return salt + encrypted
def _decrypt(self, encrypted_data: bytes) -> bytes:
"""Déchiffre les données.
Args:
encrypted_data: Bytes chiffrés (salt + données Fernet).
Returns:
Données déchiffrées.
Raises:
ValueError: Si le mot de passe est incorrect ou les données corrompues.
"""
if len(encrypted_data) < SALT_SIZE:
raise ValueError("Fichier vault corrompu (trop court)")
salt = encrypted_data[:SALT_SIZE]
ciphertext = encrypted_data[SALT_SIZE:]
key = self._derive_key(self._master_password, salt)
if _HAS_FERNET:
try:
fernet = Fernet(key)
return fernet.decrypt(ciphertext)
except InvalidToken:
raise ValueError(
"Mot de passe maître incorrect ou fichier vault corrompu"
)
else:
# Fallback : base64 simple
return base64.urlsafe_b64decode(ciphertext)
# =========================================================================
# Chargement
# =========================================================================
def _load(self) -> None:
"""Charge et déchiffre le vault depuis le disque."""
try:
encrypted_data = self._vault_path.read_bytes()
plaintext = self._decrypt(encrypted_data)
self._data = json.loads(plaintext.decode("utf-8"))
logger.info(
"Vault chargé : %s (%d apps)",
self._vault_path,
len(self._data.get("credentials", {})),
)
except (ValueError, json.JSONDecodeError) as e:
raise ValueError(f"Impossible de charger le vault : {e}") from e

213
core/auth/manage_vault.py Normal file
View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
CLI de gestion du coffre-fort de credentials (vault).
Usage :
# Ajouter un login
python -m core.auth.manage_vault --vault /path/to/vault.enc --action add \
--app "DPI_Crossway" --type login \
--username "robot_lea" --password "xxx"
# Ajouter un seed TOTP
python -m core.auth.manage_vault --vault /path/to/vault.enc --action add \
--app "DPI_Crossway" --type totp_seed \
--secret "JBSWY3DPEHPK3PXP"
# Lister les applications configurées
python -m core.auth.manage_vault --vault /path/to/vault.enc --action list
# Générer un code TOTP
python -m core.auth.manage_vault --vault /path/to/vault.enc --action generate-totp \
--app "DPI_Crossway"
# Supprimer un credential
python -m core.auth.manage_vault --vault /path/to/vault.enc --action remove \
--app "DPI_Crossway" --type login
Le mot de passe maître est demandé interactivement via getpass.
"""
import argparse
import getpass
import sys
from .credential_vault import CredentialVault
from .totp_generator import TOTPGenerator
def main():
parser = argparse.ArgumentParser(
description="Gestionnaire de coffre-fort de credentials pour Léa.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--vault",
required=True,
help="Chemin du fichier vault chiffré",
)
parser.add_argument(
"--action",
required=True,
choices=["add", "list", "remove", "generate-totp", "show"],
help="Action à effectuer",
)
parser.add_argument("--app", help="Nom de l'application")
parser.add_argument(
"--type",
dest="cred_type",
choices=["login", "totp_seed", "session_token", "certificate"],
help="Type de credential",
)
# Champs pour le type "login"
parser.add_argument("--username", help="Nom d'utilisateur (type login)")
parser.add_argument("--password", help="Mot de passe (type login)")
parser.add_argument("--domain", help="Domaine Windows (type login, optionnel)")
# Champs pour le type "totp_seed"
parser.add_argument("--secret", help="Secret base32 (type totp_seed)")
parser.add_argument(
"--digits", type=int, default=6, help="Nombre de chiffres TOTP (défaut: 6)"
)
parser.add_argument(
"--interval", type=int, default=30, help="Intervalle TOTP en secondes (défaut: 30)"
)
parser.add_argument(
"--algorithm", default="SHA1", help="Algorithme HMAC (défaut: SHA1)"
)
args = parser.parse_args()
# Demander le mot de passe maître
master_password = getpass.getpass("Mot de passe maître : ")
if not master_password:
print("Erreur : mot de passe maître requis.", file=sys.stderr)
sys.exit(1)
try:
vault = CredentialVault(args.vault, master_password)
except ValueError as e:
print(f"Erreur d'ouverture du vault : {e}", file=sys.stderr)
sys.exit(1)
# ---- Actions ----
if args.action == "list":
apps = vault.list_apps()
if not apps:
print("Vault vide — aucune application configurée.")
else:
print(f"Applications configurées ({len(apps)}) :")
for app in apps:
types = vault.list_credential_types(app)
print(f" {app} : {', '.join(types)}")
elif args.action == "add":
if not args.app:
print("Erreur : --app requis pour l'action 'add'.", file=sys.stderr)
sys.exit(1)
if not args.cred_type:
print("Erreur : --type requis pour l'action 'add'.", file=sys.stderr)
sys.exit(1)
if args.cred_type == "login":
if not args.username:
args.username = input("Username : ")
if not args.password:
args.password = getpass.getpass("Password : ")
data = {"username": args.username, "password": args.password}
if args.domain:
data["domain"] = args.domain
elif args.cred_type == "totp_seed":
if not args.secret:
args.secret = input("Secret base32 : ")
data = {
"secret": args.secret,
"digits": args.digits,
"interval": args.interval,
"algorithm": args.algorithm,
}
elif args.cred_type == "session_token":
token = input("Token de session : ")
data = {"token": token}
elif args.cred_type == "certificate":
cert_path = input("Chemin du certificat : ")
key_path = input("Chemin de la clé privée : ")
data = {"cert_path": cert_path, "key_path": key_path}
else:
print(f"Type non géré : {args.cred_type}", file=sys.stderr)
sys.exit(1)
vault.add_credential(args.app, args.cred_type, data)
vault.save()
print(f"Credential ajouté : {args.app} / {args.cred_type}")
elif args.action == "remove":
if not args.app or not args.cred_type:
print(
"Erreur : --app et --type requis pour l'action 'remove'.",
file=sys.stderr,
)
sys.exit(1)
removed = vault.remove_credential(args.app, args.cred_type)
if removed:
vault.save()
print(f"Credential supprimé : {args.app} / {args.cred_type}")
else:
print(f"Credential non trouvé : {args.app} / {args.cred_type}")
elif args.action == "generate-totp":
if not args.app:
print(
"Erreur : --app requis pour l'action 'generate-totp'.",
file=sys.stderr,
)
sys.exit(1)
totp_creds = vault.get_credential(args.app, "totp_seed")
if not totp_creds:
print(
f"Pas de seed TOTP configuré pour '{args.app}'.",
file=sys.stderr,
)
sys.exit(1)
totp = TOTPGenerator(
secret=totp_creds["secret"],
digits=totp_creds.get("digits", 6),
interval=totp_creds.get("interval", 30),
algorithm=totp_creds.get("algorithm", "SHA1"),
)
code = totp.generate()
remaining = totp.time_remaining()
print(f"Code TOTP : {code}")
print(f"Expire dans : {remaining}s")
elif args.action == "show":
if not args.app:
print(
"Erreur : --app requis pour l'action 'show'.",
file=sys.stderr,
)
sys.exit(1)
types = vault.list_credential_types(args.app)
if not types:
print(f"Aucun credential pour '{args.app}'.")
else:
print(f"Credentials pour '{args.app}' :")
for cred_type in types:
cred = vault.get_credential(args.app, cred_type)
# Masquer les mots de passe et secrets
display = {}
for k, v in (cred or {}).items():
if k in ("password", "secret", "token"):
display[k] = v[:3] + "***" if len(str(v)) > 3 else "***"
else:
display[k] = v
print(f" {cred_type} : {display}")
if __name__ == "__main__":
main()

183
core/auth/totp_generator.py Normal file
View File

@@ -0,0 +1,183 @@
"""
Générateur TOTP (Time-based One-Time Password) pour l'authentification 2FA.
Implémente RFC 6238 directement, sans dépendance externe.
Compatible avec FreeOTP, Google Authenticator, Microsoft Authenticator.
Algorithme (RFC 6238 / RFC 4226) :
1. Décoder le secret partagé depuis base32
2. Calculer le compteur temporel T = floor(unix_time / interval)
3. Encoder T en big-endian 8 bytes
4. Calculer HMAC-SHA1(secret, T) (ou SHA-256/SHA-512 selon config)
5. Extraction dynamique (dynamic truncation) :
- offset = dernier octet du HMAC & 0x0F
- extraire 4 bytes à partir de offset
- masquer le bit de signe (& 0x7FFFFFFF)
- modulo 10^digits pour obtenir le code
"""
import base64
import hashlib
import hmac
import logging
import struct
import time
logger = logging.getLogger(__name__)
# Mapping des algorithmes supportés
_HASH_ALGORITHMS = {
"SHA1": hashlib.sha1,
"SHA256": hashlib.sha256,
"SHA512": hashlib.sha512,
}
class TOTPGenerator:
"""Générateur de codes TOTP conformes à la RFC 6238.
Usage :
totp = TOTPGenerator("JBSWY3DPEHPK3PXP")
code = totp.generate() # "492039"
remaining = totp.time_remaining() # 17 (secondes)
valid = totp.verify("492039") # True
"""
def __init__(
self,
secret: str,
digits: int = 6,
interval: int = 30,
algorithm: str = "SHA1",
):
"""Initialise le générateur TOTP.
Args:
secret: Clé secrète encodée en base32 (standard TOTP).
digits: Nombre de chiffres du code (6 ou 8, défaut 6).
interval: Intervalle en secondes entre deux codes (défaut 30).
algorithm: Algorithme HMAC ("SHA1", "SHA256", "SHA512").
Raises:
ValueError: Si le secret n'est pas du base32 valide ou l'algorithme inconnu.
"""
# Normaliser et décoder le secret base32
# Les secrets TOTP peuvent contenir des espaces pour la lisibilité
clean_secret = secret.upper().replace(" ", "")
# Ajouter du padding base32 si nécessaire
padding = (8 - len(clean_secret) % 8) % 8
clean_secret += "=" * padding
try:
self._secret_bytes = base64.b32decode(clean_secret)
except Exception as e:
raise ValueError(f"Secret base32 invalide : {e}") from e
if algorithm.upper() not in _HASH_ALGORITHMS:
raise ValueError(
f"Algorithme non supporté : {algorithm!r}. "
f"Valeurs acceptées : {list(_HASH_ALGORITHMS.keys())}"
)
self._digits = digits
self._interval = interval
self._algorithm = algorithm.upper()
def generate(self, timestamp: float | None = None) -> str:
"""Génère le code TOTP pour l'instant présent (ou un timestamp donné).
Args:
timestamp: Timestamp Unix optionnel (pour les tests). Si None, utilise time.time().
Returns:
Code TOTP sous forme de chaîne zero-padded (ex: "003271").
"""
if timestamp is None:
timestamp = time.time()
counter = int(timestamp) // self._interval
return self._generate_hotp(counter)
def time_remaining(self) -> int:
"""Nombre de secondes avant expiration du code actuel.
Returns:
Secondes restantes (entre 1 et interval).
"""
return self._interval - (int(time.time()) % self._interval)
def verify(self, code: str, timestamp: float | None = None, window: int = 1) -> bool:
"""Vérifie un code TOTP avec une fenêtre de tolérance.
La fenêtre permet de compenser le décalage d'horloge entre client et serveur.
Avec window=1, on vérifie le code actuel, le précédent et le suivant.
Args:
code: Code TOTP à vérifier.
timestamp: Timestamp Unix optionnel.
window: Nombre d'intervalles de tolérance de chaque côté (défaut 1).
Returns:
True si le code correspond à un intervalle dans la fenêtre.
"""
if timestamp is None:
timestamp = time.time()
counter = int(timestamp) // self._interval
for offset in range(-window, window + 1):
check_counter = counter + offset
if check_counter < 0:
continue # Compteur négatif impossible
expected = self._generate_hotp(check_counter)
# Comparaison en temps constant pour éviter les timing attacks
if hmac.compare_digest(code, expected):
return True
return False
# =========================================================================
# Implémentation interne HOTP (RFC 4226)
# =========================================================================
def _generate_hotp(self, counter: int) -> str:
"""Génère un code HOTP pour un compteur donné.
Implémentation conforme à la RFC 4226 section 5.3 :
1. Encoder le compteur en big-endian 8 bytes
2. HMAC avec l'algorithme configuré
3. Truncation dynamique
4. Réduction modulo 10^digits
Args:
counter: Valeur du compteur (entier 64 bits).
Returns:
Code HOTP zero-padded.
"""
# Étape 1 : Compteur en big-endian 8 bytes
counter_bytes = struct.pack(">Q", counter)
# Étape 2 : HMAC
hash_func = _HASH_ALGORITHMS[self._algorithm]
hmac_digest = hmac.new(
self._secret_bytes, counter_bytes, hash_func
).digest()
# Étape 3 : Truncation dynamique (RFC 4226 section 5.4)
# L'offset est déterminé par les 4 bits de poids faible du dernier octet
offset = hmac_digest[-1] & 0x0F
# Extraire 4 bytes à partir de l'offset et masquer le bit de signe
truncated = (
((hmac_digest[offset] & 0x7F) << 24)
| ((hmac_digest[offset + 1] & 0xFF) << 16)
| ((hmac_digest[offset + 2] & 0xFF) << 8)
| (hmac_digest[offset + 3] & 0xFF)
)
# Étape 4 : Réduction modulo pour obtenir le nombre de chiffres voulu
code = truncated % (10 ** self._digits)
# Zero-padding pour garantir la longueur
return str(code).zfill(self._digits)

View File

@@ -26,7 +26,7 @@ class OllamaClient:
def __init__(self, def __init__(self,
endpoint: str = "http://localhost:11434", endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b", model: str = "qwen3-vl:8b",
timeout: int = 60): timeout: int = 180):
""" """
Initialiser le client Ollama Initialiser le client Ollama
@@ -63,14 +63,21 @@ class OllamaClient:
system_prompt: Optional[str] = None, system_prompt: Optional[str] = None,
temperature: float = 0.1, temperature: float = 0.1,
max_tokens: int = 500, max_tokens: int = 500,
force_json: bool = False) -> Dict[str, Any]: force_json: bool = False,
assistant_prefill: Optional[str] = None,
num_ctx: Optional[int] = None,
extra_images_b64: Optional[List[str]] = None) -> Dict[str, Any]:
""" """
Générer une réponse du VLM via l'API chat d'Ollama. Générer une réponse du VLM via l'API chat d'Ollama.
Note: On utilise /api/chat au lieu de /api/generate car qwen3-vl Pour les modèles thinking (qwen3-vl), on utilise la technique du
avec /api/generate consomme tous les tokens en thinking interne "assistant prefill" : un message assistant pré-rempli est ajouté
et retourne une réponse vide. L'API chat gère correctement après le message user, forçant le modèle à continuer directement
le mode /no_think et sépare thinking/réponse. sans phase de thinking. Cela résout le bug Ollama 0.18.x où
think=false est ignoré par le renderer qwen3-vl-thinking.
Sans prefill : le modèle pense 500+ tokens puis répond (~180s)
Avec prefill : le modèle répond directement (~1-5s)
Args: Args:
prompt: Prompt textuel prompt: Prompt textuel
@@ -80,6 +87,11 @@ class OllamaClient:
temperature: Température de génération temperature: Température de génération
max_tokens: Nombre max de tokens max_tokens: Nombre max de tokens
force_json: Forcer la sortie JSON (non recommandé pour qwen3-vl) force_json: Forcer la sortie JSON (non recommandé pour qwen3-vl)
assistant_prefill: Début de réponse pré-rempli (auto-détecté si None)
num_ctx: Context window (défaut 2048, augmenter pour batch)
extra_images_b64: Images supplémentaires en base64 à envoyer avec le prompt.
Ajoutées après l'image principale. Utile pour le VLM multi-image
(ex: screenshot + crop de référence).
Returns: Returns:
Dict avec 'response', 'success', 'error' Dict avec 'response', 'success', 'error'
@@ -93,17 +105,19 @@ class OllamaClient:
image_data = self._encode_image_from_pil(image) image_data = self._encode_image_from_pil(image)
# Nettoyer le prompt — retirer /no_think et /nothink du texte # Nettoyer le prompt — retirer /no_think et /nothink du texte
# car le mode thinking est contrôlé via le paramètre think=false
# de l'API chat. Les préfixes /no_think dans le prompt causent
# paradoxalement PLUS de thinking interne chez qwen3-vl.
effective_prompt = prompt.replace("/no_think\n", "").replace("/no_think", "") effective_prompt = prompt.replace("/no_think\n", "").replace("/no_think", "")
effective_prompt = effective_prompt.replace("/nothink ", "").replace("/nothink", "") effective_prompt = effective_prompt.replace("/nothink ", "").replace("/nothink", "")
effective_prompt = effective_prompt.strip() effective_prompt = effective_prompt.strip()
# Construire le message utilisateur # Construire le message utilisateur
user_message = {"role": "user", "content": effective_prompt} user_message = {"role": "user", "content": effective_prompt}
all_images = []
if image_data: if image_data:
user_message["images"] = [image_data] all_images.append(image_data)
if extra_images_b64:
all_images.extend(extra_images_b64)
if all_images:
user_message["images"] = all_images
# Construire les messages # Construire les messages
messages = [] messages = []
@@ -111,9 +125,37 @@ class OllamaClient:
messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "system", "content": system_prompt})
messages.append(user_message) messages.append(user_message)
# Déterminer si le modèle supporte le thinking # Déterminer si le modèle est un modèle thinking (qwen3)
is_thinking_model = "qwen3" in self.model.lower() is_thinking_model = "qwen3" in self.model.lower()
# WORKAROUND Ollama 0.18.x : think=false est ignoré par le
# renderer qwen3-vl-thinking. On utilise un assistant prefill
# pour forcer le modèle à skip le thinking et répondre directement.
# Le prefill est choisi en fonction du format attendu.
# IMPORTANT : avec image, sans prefill le thinking dépasse 180s.
prefill_used = None
if is_thinking_model:
if assistant_prefill is not None:
prefill_used = assistant_prefill
elif force_json:
prefill_used = "{"
elif all_images:
# Avec image(s), le thinking est catastrophique (>180s).
# Prefill générique pour forcer une réponse directe.
prefill_used = "Based on the image,"
if prefill_used is not None:
messages.append({
"role": "assistant",
"content": prefill_used
})
# num_ctx par défaut à 2048 (correspondant au default du modèle
# chargé en mémoire). Changer num_ctx force un rechargement du
# KV cache (~30s de pénalité), donc ne l'augmenter que pour les
# requêtes batch qui dépassent la limite (image + prompt long).
effective_num_ctx = num_ctx or 2048
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
@@ -121,13 +163,13 @@ class OllamaClient:
"options": { "options": {
"temperature": temperature, "temperature": temperature,
"num_predict": max_tokens, "num_predict": max_tokens,
"num_ctx": 2048, "num_ctx": effective_num_ctx,
"top_k": 1 "top_k": 1
} }
} }
# Désactiver le thinking pour les modèles qui le supportent # Garder think=false au cas où une future version d'Ollama le
# Cela réduit drastiquement la consommation de tokens et le temps # corrige — le prefill reste le mécanisme principal
if is_thinking_model: if is_thinking_model:
payload["think"] = False payload["think"] = False
@@ -144,6 +186,11 @@ class OllamaClient:
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
content = result.get("message", {}).get("content", "") content = result.get("message", {}).get("content", "")
# Reconstituer la réponse complète en ajoutant le prefill
if prefill_used and content:
content = prefill_used + content
return { return {
"response": content, "response": content,
"success": True, "success": True,
@@ -181,8 +228,11 @@ For each element, provide:
- Semantic role (primary_action, cancel, submit, form_input, search_field, navigation, settings, close) - Semantic role (primary_action, cancel, submit, form_input, search_field, navigation, settings, close)
Format your response as JSON.""" Format your response as JSON."""
result = self.generate(prompt, image_path=image_path, temperature=0.1) result = self.generate(
prompt, image_path=image_path, temperature=0.1,
assistant_prefill="[",
)
if result["success"]: if result["success"]:
try: try:
@@ -214,14 +264,21 @@ Format your response as JSON."""
Choose ONLY ONE from: {types_list} Choose ONLY ONE from: {types_list}
Respond with just the type name, nothing else.""" Respond with just the type name, nothing else."""
if context: if context:
prompt += f"\n\nContext: {context}" prompt += f"\n\nContext: {context}"
result = self.generate(prompt, image=element_image, temperature=0.1) result = self.generate(
prompt, image=element_image, temperature=0.1,
assistant_prefill="The type is:",
)
if result["success"]: if result["success"]:
element_type = result["response"].strip().lower() # Retirer le prefill du début pour extraire le type
raw = result["response"]
if raw.startswith("The type is:"):
raw = raw[len("The type is:"):]
element_type = raw.strip().lower()
# Valider que c'est un type connu # Valider que c'est un type connu
valid_types = types_list.split(", ") valid_types = types_list.split(", ")
if element_type in valid_types: if element_type in valid_types:
@@ -255,14 +312,21 @@ Respond with just the type name, nothing else."""
Choose ONLY ONE from: {roles_list} Choose ONLY ONE from: {roles_list}
Respond with just the role name, nothing else.""" Respond with just the role name, nothing else."""
if context: if context:
prompt += f"\n\nContext: {context}" prompt += f"\n\nContext: {context}"
result = self.generate(prompt, image=element_image, temperature=0.1) result = self.generate(
prompt, image=element_image, temperature=0.1,
assistant_prefill="The role is:",
)
if result["success"]: if result["success"]:
role = result["response"].strip().lower() # Retirer le prefill du début pour extraire le rôle
raw = result["response"]
if raw.startswith("The role is:"):
raw = raw[len("The role is:"):]
role = raw.strip().lower()
# Valider que c'est un rôle connu # Valider que c'est un rôle connu
valid_roles = roles_list.split(", ") valid_roles = roles_list.split(", ")
if role in valid_roles: if role in valid_roles:
@@ -286,12 +350,19 @@ Respond with just the role name, nothing else."""
Dict avec 'text' extrait Dict avec 'text' extrait
""" """
prompt = "Extract all visible text from this image. Return only the text, nothing else." prompt = "Extract all visible text from this image. Return only the text, nothing else."
result = self.generate(prompt, image=image, temperature=0.1) result = self.generate(
prompt, image=image, temperature=0.1,
assistant_prefill="Text:",
)
if result["success"]: if result["success"]:
return {"text": result["response"].strip(), "success": True} # Retirer le prefill du début pour extraire le texte
raw = result["response"]
if raw.startswith("Text:"):
raw = raw[len("Text:"):]
return {"text": raw.strip(), "success": True}
return {"text": "", "success": False, "error": result["error"]} return {"text": "", "success": False, "error": result["error"]}
# Taille minimum pour une classification fiable par le VLM # Taille minimum pour une classification fiable par le VLM
@@ -346,7 +417,8 @@ Your answer:"""
system_prompt=system_prompt, system_prompt=system_prompt,
temperature=0.1, temperature=0.1,
max_tokens=300, max_tokens=300,
force_json=False force_json=False,
assistant_prefill="{"
) )
if not result["success"]: if not result["success"]:

View File

@@ -220,7 +220,7 @@ class UIDetector:
# des centaines d'appels VLM inutiles (~2-3s chacun). # des centaines d'appels VLM inutiles (~2-3s chacun).
# On garde max 80 candidats — suffisant pour obtenir ~50 éléments # On garde max 80 candidats — suffisant pour obtenir ~50 éléments
# après filtrage par confiance, tout en gardant un temps raisonnable. # après filtrage par confiance, tout en gardant un temps raisonnable.
max_candidates = 30 # 30 suffisent pour les éléments principaux (~6min/screenshot au lieu de 17) max_candidates = 10 # 10 régions : compact, rapide (~5-10s avec prefill)
if len(regions) > max_candidates: if len(regions) > max_candidates:
# Trier par confiance décroissante, puis par surface décroissante # Trier par confiance décroissante, puis par surface décroissante
regions.sort(key=lambda r: (r.confidence, r.w * r.h), reverse=True) regions.sort(key=lambda r: (r.confidence, r.w * r.h), reverse=True)
@@ -489,32 +489,18 @@ class UIDetector:
if not self.vlm_client or not regions: if not self.vlm_client or not regions:
return None return None
# Construire la description des régions pour le prompt # Construire une description compacte des régions (économise les tokens)
regions_desc_lines = [] regions_desc_lines = []
for i, r in enumerate(regions): for i, r in enumerate(regions):
regions_desc_lines.append( regions_desc_lines.append(f"#{i}:({r.x},{r.y},{r.w}x{r.h})")
f" #{i}: position=({r.x},{r.y}), size={r.w}x{r.h}, source={r.source}" regions_description = " ".join(regions_desc_lines)
)
regions_description = "\n".join(regions_desc_lines)
prompt = f"""Analyze this screenshot. I have detected UI elements at these positions: prompt = f"""Classify UI elements at: {regions_description}
{regions_description} Types: button,text_input,checkbox,radio,dropdown,tab,link,icon,table_row,menu_item
Roles: primary_action,cancel,submit,form_input,search_field,navigation,settings,close,delete,edit,save
JSON array: [{{"id":0,"type":"...","role":"...","text":"..."}}]"""
For each element, classify it as a JSON array. Each entry must have: system_prompt = "JSON-only UI classifier. No explanation."
- "id": the element number (matching # above)
- "type": one of button, text_input, checkbox, radio, dropdown, tab, link, icon, table_row, menu_item
- "role": one of primary_action, cancel, submit, form_input, search_field, navigation, settings, close, delete, edit, save
- "text": visible text on the element (empty string if none)
Return ONLY the JSON array, nothing else. Example:
[{{"id": 0, "type": "button", "role": "submit", "text": "OK"}}, {{"id": 1, "type": "text_input", "role": "form_input", "text": ""}}]
Your answer:"""
system_prompt = (
"You are a JSON-only UI classifier. No thinking. No explanation. "
"Output a raw JSON array only."
)
# Appel VLM unique avec le screenshot complet # Appel VLM unique avec le screenshot complet
for attempt in range(2): for attempt in range(2):
@@ -523,8 +509,10 @@ Your answer:"""
image=pil_image, image=pil_image,
system_prompt=system_prompt, system_prompt=system_prompt,
temperature=0.1, temperature=0.1,
max_tokens=2000, # Plus de tokens car réponse groupée max_tokens=1500, # ~100 tokens/element * 10 elements + marge
force_json=False, force_json=False,
assistant_prefill="[", # Force JSON array direct, skip thinking
num_ctx=2048, # 2048 suffit pour 10 régions compactes + image
) )
if not result["success"]: if not result["success"]:

View File

@@ -0,0 +1,622 @@
"""
UIDetector - Détection Sémantique d'Éléments UI avec VLM
Utilise un Vision-Language Model (VLM) pour détecter et classifier
les éléments UI avec leurs types et rôles sémantiques.
"""
from typing import List, Dict, Optional, Any, Tuple
from pathlib import Path
from dataclasses import dataclass
import numpy as np
from PIL import Image
import json
import re
from ..models.ui_element import UIElement, UIElementEmbeddings, VisualFeatures
from .ollama_client import OllamaClient, check_ollama_available
@dataclass
class DetectionConfig:
"""Configuration de la détection UI"""
vlm_model: str = "qwen3-vl:8b" # Modèle VLM à utiliser (qwen3-vl:8b recommandé)
vlm_endpoint: str = "http://localhost:11434" # Endpoint Ollama
confidence_threshold: float = 0.7 # Seuil de confiance minimum
max_elements: int = 50 # Nombre max d'éléments à détecter
detect_regions: bool = True # Détecter régions d'intérêt d'abord
use_embeddings: bool = True # Générer embeddings duaux
class UIDetector:
"""
Détecteur d'éléments UI sémantique
Utilise un VLM (Vision-Language Model) pour :
1. Détecter les régions d'intérêt dans un screenshot
2. Classifier le type de chaque élément UI
3. Déterminer le rôle sémantique
4. Extraire les features visuelles
5. Générer des embeddings duaux (image + texte)
"""
def __init__(self, config: Optional[DetectionConfig] = None):
"""
Initialiser le détecteur
Args:
config: Configuration (utilise config par défaut si None)
"""
self.config = config or DetectionConfig()
self.vlm_client = None
self._initialize_vlm()
def _initialize_vlm(self) -> None:
"""Initialiser le client VLM (Ollama)"""
try:
# Vérifier si Ollama est disponible
if check_ollama_available(self.config.vlm_endpoint):
self.vlm_client = OllamaClient(
endpoint=self.config.vlm_endpoint,
model=self.config.vlm_model
)
print(f"✓ VLM initialized: {self.config.vlm_model} at {self.config.vlm_endpoint}")
else:
print(f"⚠ Ollama not available at {self.config.vlm_endpoint}, using simulation mode")
self.vlm_client = None
except Exception as e:
print(f"⚠ Failed to initialize VLM: {e}, using simulation mode")
self.vlm_client = None
def detect(self,
screenshot_path: str,
window_context: Optional[Dict[str, Any]] = None) -> List[UIElement]:
"""
Détecter tous les éléments UI dans un screenshot
Args:
screenshot_path: Chemin vers le screenshot
window_context: Contexte de la fenêtre (titre, process, etc.)
Returns:
Liste d'UIElements détectés
"""
# Charger image
image = self._load_image(screenshot_path)
if image is None:
return []
# Détecter régions d'intérêt si activé
if self.config.detect_regions:
regions = self._detect_regions_of_interest(image, window_context)
else:
# Utiliser image complète
regions = [{"bbox": (0, 0, image.width, image.height), "confidence": 1.0}]
# Détecter éléments UI dans chaque région
ui_elements = []
for region in regions:
elements = self._detect_elements_in_region(
image,
region,
screenshot_path,
window_context
)
ui_elements.extend(elements)
# Filtrer par confiance
ui_elements = [
el for el in ui_elements
if el.confidence >= self.config.confidence_threshold
]
# Limiter nombre d'éléments
if len(ui_elements) > self.config.max_elements:
# Trier par confiance et garder les meilleurs
ui_elements.sort(key=lambda x: x.confidence, reverse=True)
ui_elements = ui_elements[:self.config.max_elements]
return ui_elements
def _load_image(self, screenshot_path: str) -> Optional[Image.Image]:
"""Charger une image depuis un fichier"""
try:
return Image.open(screenshot_path)
except Exception as e:
print(f"Error loading image {screenshot_path}: {e}")
return None
def _detect_regions_of_interest(self,
image: Image.Image,
window_context: Optional[Dict] = None) -> List[Dict]:
"""
Détecter les régions d'intérêt dans l'image
Utilise le VLM pour identifier les zones contenant des éléments UI.
Args:
image: Image PIL
window_context: Contexte de la fenêtre
Returns:
Liste de régions {bbox: (x, y, w, h), confidence: float}
"""
if self.vlm_client is None:
# Mode simulation : diviser l'image en grille
return self._simulate_region_detection(image)
# Utiliser VLM pour détecter régions
# Pour l'instant, on utilise l'image complète (plus simple et efficace)
width, height = image.size
return [{
"bbox": (0, 0, width, height),
"confidence": 1.0
}]
def _simulate_region_detection(self, image: Image.Image) -> List[Dict]:
"""Simulation de détection de régions (pour développement)"""
width, height = image.size
# Diviser en grille 3x3 pour simulation
regions = []
grid_size = 3
cell_w = width // grid_size
cell_h = height // grid_size
for i in range(grid_size):
for j in range(grid_size):
regions.append({
"bbox": (j * cell_w, i * cell_h, cell_w, cell_h),
"confidence": 0.8
})
return regions
def _detect_elements_in_region(self,
image: Image.Image,
region: Dict,
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""
Détecter éléments UI dans une région spécifique
Args:
image: Image complète
region: Région à analyser
screenshot_path: Chemin du screenshot
window_context: Contexte de la fenêtre
Returns:
Liste d'UIElements dans cette région
"""
bbox = region["bbox"]
x, y, w, h = bbox
# Extraire crop de la région
region_image = image.crop((x, y, x + w, y + h))
# Détecter éléments avec VLM
if self.vlm_client is None:
# Mode simulation
return self._simulate_element_detection(
region_image, bbox, screenshot_path, window_context
)
# Vraie détection avec VLM !
return self._detect_with_vlm(
region_image, bbox, screenshot_path, window_context
)
def _detect_with_vlm(self,
region_image: Image.Image,
region_bbox: Tuple[int, int, int, int],
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""
Détecter éléments UI avec le VLM (vraie détection)
Args:
region_image: Image de la région
region_bbox: Bbox de la région (x, y, w, h)
screenshot_path: Chemin du screenshot
window_context: Contexte de la fenêtre
Returns:
Liste d'UIElements détectés
"""
x_offset, y_offset, w, h = region_bbox
# Construire le prompt pour le VLM
context_str = ""
if window_context:
context_str = f"\nWindow context: {window_context.get('title', 'Unknown')}"
# Approche simplifiée : demander une description structurée
prompt = f"""List all interactive UI elements in this screenshot.{context_str}
For each element, provide:
- type (button, text_input, checkbox, link, etc.)
- label (visible text)
- approximate position (top/middle/bottom, left/center/right)
Format as JSON array:
[{{"type": "button", "label": "Submit", "position": "middle-center"}}]
Return ONLY the JSON array, no other text."""
# Appeler le VLM
# Note: Utiliser le chemin du screenshot complet plutôt que le crop
# car certains VLM gèrent mieux les fichiers que les images PIL
result = self.vlm_client.generate(
prompt=prompt,
image_path=screenshot_path, # Utiliser le chemin au lieu de l'image PIL
temperature=0.1,
max_tokens=1000
)
if not result["success"]:
print(f"❌ VLM detection failed: {result.get('error', 'Unknown error')}")
return []
if not result["response"] or len(result["response"].strip()) == 0:
print(f"⚠ VLM returned empty response")
return []
# Parser la réponse JSON
elements = self._parse_vlm_response(
result["response"],
region_bbox,
screenshot_path,
window_context
)
return elements
def _parse_vlm_response(self,
response: str,
region_bbox: Tuple[int, int, int, int],
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""
Parser la réponse JSON du VLM
Args:
response: Réponse texte du VLM
region_bbox: Bbox de la région
screenshot_path: Chemin du screenshot
window_context: Contexte de la fenêtre
Returns:
Liste d'UIElements
"""
x_offset, y_offset, region_w, region_h = region_bbox
try:
# Extraire le JSON de la réponse (peut contenir du texte avant/après)
json_match = re.search(r'\[.*\]', response, re.DOTALL)
if not json_match:
print(f"No JSON array found in VLM response")
print(f"VLM response was: {response[:500]}...")
return []
elements_data = json.loads(json_match.group(0))
if not isinstance(elements_data, list):
print(f"VLM response is not a JSON array")
return []
elements = []
for i, elem_data in enumerate(elements_data):
try:
# Gérer les positions (pourcentages ou textuelles)
if 'x' in elem_data and 'y' in elem_data:
# Format avec pourcentages
x_pct = float(elem_data.get('x', 0))
y_pct = float(elem_data.get('y', 0))
w_pct = float(elem_data.get('width', 10))
h_pct = float(elem_data.get('height', 5))
elem_x = x_offset + int(region_w * x_pct / 100)
elem_y = y_offset + int(region_h * y_pct / 100)
elem_w = int(region_w * w_pct / 100)
elem_h = int(region_h * h_pct / 100)
else:
# Format avec position textuelle (top/middle/bottom, left/center/right)
position = elem_data.get('position', 'middle-center').lower()
# Parser la position
if 'top' in position:
elem_y = y_offset + region_h // 4
elif 'bottom' in position:
elem_y = y_offset + 3 * region_h // 4
else: # middle
elem_y = y_offset + region_h // 2
if 'left' in position:
elem_x = x_offset + region_w // 4
elif 'right' in position:
elem_x = x_offset + 3 * region_w // 4
else: # center
elem_x = x_offset + region_w // 2
# Taille par défaut basée sur le type
elem_type = elem_data.get('type', 'button')
if elem_type == 'button':
elem_w, elem_h = 100, 40
elif elem_type == 'text_input':
elem_w, elem_h = 200, 35
elif elem_type == 'checkbox':
elem_w, elem_h = 25, 25
else:
elem_w, elem_h = 80, 30
# Créer l'UIElement
element = UIElement(
element_id=f"vlm_{elem_x}_{elem_y}",
type=elem_data.get('type', 'unknown'),
role=elem_data.get('role', 'unknown'),
bbox=(elem_x, elem_y, elem_w, elem_h),
center=(elem_x + elem_w // 2, elem_y + elem_h // 2),
label=elem_data.get('label', ''),
label_confidence=0.85, # Confiance par défaut pour VLM
embeddings=UIElementEmbeddings(),
visual_features=VisualFeatures(
dominant_color="rgb(128, 128, 128)",
has_icon=elem_data.get('type') == 'icon',
shape="rectangle",
size_category="medium"
),
confidence=0.85, # Confiance par défaut pour VLM
metadata={
"detected_by": "vlm",
"model": self.config.vlm_model,
"screenshot_path": screenshot_path
}
)
elements.append(element)
except (KeyError, ValueError, TypeError) as e:
print(f"Error parsing element {i}: {e}")
continue
return elements
except json.JSONDecodeError as e:
print(f"Failed to parse VLM JSON response: {e}")
print(f"Response was: {response[:200]}...")
return []
def _simulate_element_detection(self,
region_image: Image.Image,
region_bbox: Tuple[int, int, int, int],
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""Simulation de détection d'éléments (pour développement)"""
# Pour simulation, créer quelques éléments fictifs
elements = []
x_offset, y_offset, w, h = region_bbox
# Simuler 2-3 éléments par région
num_elements = np.random.randint(2, 4)
for i in range(num_elements):
# Position aléatoire dans la région
elem_w = np.random.randint(50, 150)
elem_h = np.random.randint(20, 60)
elem_x = x_offset + np.random.randint(0, max(1, w - elem_w))
elem_y = y_offset + np.random.randint(0, max(1, h - elem_h))
# Type et rôle aléatoires
types = ["button", "text_input", "checkbox", "link", "icon"]
roles = ["primary_action", "cancel", "submit", "form_input", "navigation"]
element = UIElement(
element_id=f"elem_{elem_x}_{elem_y}",
type=np.random.choice(types),
role=np.random.choice(roles),
bbox=(elem_x, elem_y, elem_w, elem_h),
center=(elem_x + elem_w // 2, elem_y + elem_h // 2),
label=f"Element {i}",
label_confidence=np.random.uniform(0.7, 0.95),
embeddings=UIElementEmbeddings(), # Embeddings vides
visual_features=VisualFeatures(
dominant_color="rgb(128, 128, 128)",
has_icon=np.random.choice([True, False]),
shape="rectangle",
size_category="medium"
),
confidence=np.random.uniform(0.7, 0.95),
metadata={"simulated": True, "screenshot_path": screenshot_path}
)
elements.append(element)
return elements
def classify_type(self,
element_image: Image.Image,
context: Optional[Dict] = None) -> Tuple[str, float]:
"""
Classifier le type d'un élément UI
Args:
element_image: Image de l'élément
context: Contexte additionnel
Returns:
(type, confidence)
"""
if self.vlm_client is None:
# Simulation
types = ["button", "text_input", "checkbox", "radio", "dropdown",
"tab", "link", "icon", "table_row", "menu_item"]
return np.random.choice(types), np.random.uniform(0.7, 0.95)
# Vraie classification avec VLM
result = self.vlm_client.classify_element_type(element_image, context)
if result["success"]:
return result["type"], result["confidence"]
return "unknown", 0.0
def classify_role(self,
element_image: Image.Image,
element_type: str,
context: Optional[Dict] = None) -> Tuple[str, float]:
"""
Classifier le rôle sémantique d'un élément
Args:
element_image: Image de l'élément
element_type: Type de l'élément
context: Contexte additionnel
Returns:
(role, confidence)
"""
if self.vlm_client is None:
# Simulation
roles = ["primary_action", "cancel", "submit", "form_input",
"search_field", "navigation", "settings", "close"]
return np.random.choice(roles), np.random.uniform(0.7, 0.95)
# Vraie classification avec VLM
result = self.vlm_client.classify_element_role(
element_image,
element_type,
context
)
if result["success"]:
return result["role"], result["confidence"]
return "unknown", 0.0
def extract_visual_features(self,
element_image: Image.Image) -> VisualFeatures:
"""
Extraire les features visuelles d'un élément
Args:
element_image: Image de l'élément
Returns:
VisualFeatures
"""
# Calculer couleur dominante
img_array = np.array(element_image)
if len(img_array.shape) == 3:
# Moyenne des couleurs
dominant_color = tuple(img_array.mean(axis=(0, 1)).astype(int).tolist())
else:
dominant_color = (128, 128, 128)
# Déterminer forme (simplifié)
width, height = element_image.size
aspect_ratio = width / height if height > 0 else 1.0
if aspect_ratio > 3:
shape = "horizontal_bar"
elif aspect_ratio < 0.33:
shape = "vertical_bar"
elif 0.8 <= aspect_ratio <= 1.2:
shape = "square"
else:
shape = "rectangle"
# Catégorie de taille
area = width * height
if area < 1000:
size_category = "small"
elif area < 10000:
size_category = "medium"
else:
size_category = "large"
# Détection d'icône (simplifié)
has_icon = width < 100 and height < 100 and 0.8 <= aspect_ratio <= 1.2
return VisualFeatures(
dominant_color=dominant_color,
has_icon=has_icon,
shape=shape,
size_category=size_category
)
def generate_embeddings(self,
element_image: Image.Image,
element_label: str,
embedder: Optional[Any] = None) -> Optional[UIElementEmbeddings]:
"""
Générer embeddings duaux (image + texte) pour un élément
Args:
element_image: Image de l'élément
element_label: Label textuel de l'élément
embedder: Embedder à utiliser (optionnel)
Returns:
UIElementEmbeddings ou None
"""
if not self.config.use_embeddings or embedder is None:
return None
try:
# Générer embedding image
image_embedding_id = None
if hasattr(embedder, 'embed_image'):
# Sauvegarder temporairement l'image
# TODO: Implémenter sauvegarde et embedding
pass
# Générer embedding texte
text_embedding_id = None
if element_label and hasattr(embedder, 'embed_text'):
# TODO: Implémenter embedding texte
pass
if image_embedding_id or text_embedding_id:
return UIElementEmbeddings(
image_embedding_id=image_embedding_id,
text_embedding_id=text_embedding_id,
provider="openclip_ViT-B-32",
dimensions=512
)
except Exception as e:
print(f"Warning: Failed to generate embeddings: {e}")
return None
def set_vlm_client(self, client: Any) -> None:
"""Définir le client VLM"""
self.vlm_client = client
def get_config(self) -> DetectionConfig:
"""Récupérer la configuration"""
return self.config
# ============================================================================
# Fonctions utilitaires
# ============================================================================
def create_detector(vlm_model: str = "qwen3-vl:8b",
confidence_threshold: float = 0.7) -> UIDetector:
"""
Créer un UIDetector avec configuration personnalisée
Args:
vlm_model: Modèle VLM à utiliser
confidence_threshold: Seuil de confiance
Returns:
UIDetector configuré
"""
config = DetectionConfig(
vlm_model=vlm_model,
confidence_threshold=confidence_threshold
)
return UIDetector(config)

View File

@@ -125,18 +125,32 @@ class FusionEngine:
weights: Dict[str, float]) -> np.ndarray: weights: Dict[str, float]) -> np.ndarray:
""" """
Fusion pondérée simple : somme pondérée des vecteurs Fusion pondérée simple : somme pondérée des vecteurs
fused = w1*v1 + w2*v2 + w3*v3 + w4*v4 fused = w1*v1 + w2*v2 + w3*v3 + w4*v4
Les poids sont renormalisés en fonction des modalités effectivement
présentes, pour que la somme des poids effectifs = 1.0.
Exemple : si seuls image (0.5) et text (0.3) sont fournis,
les poids deviennent image=0.625, text=0.375.
""" """
# Initialiser vecteur résultat # Initialiser vecteur résultat
first_vector = next(iter(embeddings.values())) first_vector = next(iter(embeddings.values()))
fused = np.zeros_like(first_vector, dtype=np.float32) fused = np.zeros_like(first_vector, dtype=np.float32)
# Somme pondérée # Calculer la somme des poids des modalités présentes pour renormaliser
present_weight_sum = sum(
weights.get(modality, 0.0) for modality in embeddings
)
# Somme pondérée avec renormalisation
for modality, vector in embeddings.items(): for modality, vector in embeddings.items():
weight = weights.get(modality, 0.0) raw_weight = weights.get(modality, 0.0)
fused += weight * vector if present_weight_sum > 1e-10:
effective_weight = raw_weight / present_weight_sum
else:
effective_weight = 1.0 / len(embeddings)
fused += effective_weight * vector
return fused return fused
def _fuse_concat_projection(self, def _fuse_concat_projection(self,

View File

@@ -112,7 +112,7 @@ class StateEmbeddingBuilder:
metadata={ metadata={
"screen_state_id": screen_state.screen_state_id, "screen_state_id": screen_state.screen_state_id,
"timestamp": screen_state.timestamp.isoformat(), "timestamp": screen_state.timestamp.isoformat(),
"window_title": getattr(screen_state.window, 'title', ''), "window_title": getattr(screen_state.window, 'window_title', ''),
"created_at": datetime.now().isoformat() "created_at": datetime.now().isoformat()
} }
) )
@@ -160,15 +160,16 @@ class StateEmbeddingBuilder:
if ui_emb is not None: if ui_emb is not None:
embeddings["ui"] = ui_emb embeddings["ui"] = ui_emb
# Si aucun embedding calculé, créer des vecteurs par défaut # Si aucun embedding calculé, retourner un vecteur zéro unique
# (sera ignoré par DBSCAN → noise, comportement correct)
if not embeddings: if not embeddings:
# Utiliser dimensions par défaut (512)
default_dim = 512 default_dim = 512
logger.warning(
"Aucun embedding calculé pour ce ScreenState — "
"retour d'un vecteur zéro (sera traité comme noise par DBSCAN)"
)
embeddings = { embeddings = {
"image": np.random.randn(default_dim).astype(np.float32), "image": np.zeros(default_dim, dtype=np.float32)
"text": np.random.randn(default_dim).astype(np.float32),
"title": np.random.randn(default_dim).astype(np.float32),
"ui": np.random.randn(default_dim).astype(np.float32)
} }
return embeddings return embeddings
@@ -243,7 +244,7 @@ class StateEmbeddingBuilder:
try: try:
embedder = self.embedders["title"] embedder = self.embedders["title"]
title = getattr(screen_state.window, 'title', '') title = getattr(screen_state.window, 'window_title', '')
if not title: if not title:
return None return None

View File

@@ -0,0 +1,24 @@
"""
core.federation — Fédération des apprentissages entre clients.
Exporte les connaissances anonymisées (Learning Packs) de chaque site client,
les fusionne sur un serveur central, et redistribue le modèle enrichi.
Modules :
learning_pack — Format d'export, exportation, fusion
faiss_global — Index FAISS global multi-clients
"""
from .learning_pack import (
LearningPack,
LearningPackExporter,
LearningPackMerger,
)
from .faiss_global import GlobalFAISSIndex
__all__ = [
"LearningPack",
"LearningPackExporter",
"LearningPackMerger",
"GlobalFAISSIndex",
]

View File

@@ -0,0 +1,354 @@
"""
GlobalFAISSIndex — Index FAISS global fédérant les prototypes de tous les clients.
Construit un index de recherche vectorielle à partir des Learning Packs
reçus de multiples sites clients. Chaque vecteur indexé porte des métadonnées
permettant de retrouver le pack source, le workflow et l'application d'origine.
Cet index est utilisé par le serveur central (DGX Spark) pour :
- Reconnaître instantanément un écran déjà vu chez un autre client
- Proposer des workflows existants quand un nouveau client rencontre un écran familier
- Mesurer la couverture applicative globale de Léa
Auteur : Dom, Claude — 19 mars 2026
"""
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
from .learning_pack import LearningPack, ScreenPrototype
logger = logging.getLogger(__name__)
# Dimensions par défaut des embeddings CLIP (ViT-B-32)
DEFAULT_DIMENSIONS = 512
try:
import faiss
FAISS_AVAILABLE = True
except ImportError:
FAISS_AVAILABLE = False
logger.warning("FAISS non installé — GlobalFAISSIndex désactivé. pip install faiss-cpu")
@dataclass
class GlobalSearchResult:
"""Résultat d'une recherche dans l'index global."""
prototype_id: str
similarity: float
pack_source_hash: str
workflow_skeleton_id: str
node_name: str
app_name: str
metadata: Dict[str, Any] = field(default_factory=dict)
class GlobalFAISSIndex:
"""
Index FAISS global contenant les prototypes d'écran de tous les clients.
Chaque vecteur est associé à des métadonnées :
- pack_source_hash : hash du client source
- workflow_skeleton_id : ID du workflow d'origine
- node_name : nom du nœud (écran) dans le workflow
- app_name : nom de l'application
Usage :
>>> index = GlobalFAISSIndex()
>>> index.build_from_packs([pack_a, pack_b])
>>> results = index.search(query_vector, k=5)
>>> index.save(Path("global/faiss_index"))
"""
def __init__(self, dimensions: int = DEFAULT_DIMENSIONS):
"""
Initialiser l'index global.
Args:
dimensions: Nombre de dimensions des vecteurs (512 pour CLIP ViT-B-32).
"""
if not FAISS_AVAILABLE:
raise ImportError(
"FAISS est requis pour GlobalFAISSIndex. "
"Installer avec : pip install faiss-cpu"
)
self.dimensions = dimensions
self.index: Optional["faiss.IndexFlatIP"] = None
self._metadata: List[Dict[str, Any]] = []
self._rebuild_index()
def _rebuild_index(self) -> None:
"""Créer ou recréer l'index FAISS vide."""
# IndexFlatIP pour similarité cosinus (vecteurs normalisés)
self.index = faiss.IndexFlatIP(self.dimensions)
self._metadata = []
@property
def total_vectors(self) -> int:
"""Nombre de vecteurs dans l'index."""
return self.index.ntotal if self.index is not None else 0
# ------------------------------------------------------------------
# Construction depuis les Learning Packs
# ------------------------------------------------------------------
def build_from_packs(self, packs: List[LearningPack]) -> int:
"""
Construire l'index à partir d'une liste de Learning Packs.
Remplace le contenu existant de l'index.
Args:
packs: Liste de LearningPacks à indexer.
Returns:
Nombre de vecteurs ajoutés à l'index.
"""
self._rebuild_index()
vectors = []
metadata_list = []
for pack in packs:
for proto in pack.screen_prototypes:
vec = self._proto_to_vector(proto)
if vec is None:
continue
meta = {
"prototype_id": proto.prototype_id,
"pack_source_hash": pack.source_hash,
"workflow_skeleton_id": self._extract_skeleton_id(proto),
"node_name": self._extract_node_name(proto),
"app_name": proto.app_name or "",
}
vectors.append(vec)
metadata_list.append(meta)
if not vectors:
logger.info("Aucun vecteur valide trouvé dans les packs.")
return 0
# Empiler et normaliser les vecteurs
matrix = np.array(vectors, dtype=np.float32)
faiss.normalize_L2(matrix)
# Ajouter à l'index
self.index.add(matrix)
self._metadata = metadata_list
logger.info(
"Index global construit : %d vecteurs depuis %d packs",
len(vectors), len(packs),
)
return len(vectors)
def add_pack(self, pack: LearningPack) -> int:
"""
Ajouter les prototypes d'un pack à l'index existant (incrémental).
Args:
pack: LearningPack à ajouter.
Returns:
Nombre de vecteurs ajoutés.
"""
vectors = []
metadata_list = []
for proto in pack.screen_prototypes:
vec = self._proto_to_vector(proto)
if vec is None:
continue
meta = {
"prototype_id": proto.prototype_id,
"pack_source_hash": pack.source_hash,
"workflow_skeleton_id": self._extract_skeleton_id(proto),
"node_name": self._extract_node_name(proto),
"app_name": proto.app_name or "",
}
vectors.append(vec)
metadata_list.append(meta)
if not vectors:
return 0
matrix = np.array(vectors, dtype=np.float32)
faiss.normalize_L2(matrix)
self.index.add(matrix)
self._metadata.extend(metadata_list)
logger.info(
"Pack ajouté à l'index global : +%d vecteurs (total=%d)",
len(vectors), self.total_vectors,
)
return len(vectors)
# ------------------------------------------------------------------
# Recherche
# ------------------------------------------------------------------
def search(
self, query_vector: np.ndarray, k: int = 5
) -> List[GlobalSearchResult]:
"""
Chercher les k écrans les plus similaires dans l'index global.
Args:
query_vector: Vecteur de requête (même dimension que l'index).
k: Nombre de résultats à retourner.
Returns:
Liste de GlobalSearchResult triée par similarité décroissante.
"""
if self.total_vectors == 0:
return []
# Préparer le vecteur
q = np.array(query_vector, dtype=np.float32).reshape(1, -1)
faiss.normalize_L2(q)
k = min(k, self.total_vectors)
distances, indices = self.index.search(q, k)
results = []
for dist, idx in zip(distances[0], indices[0]):
if idx < 0 or idx >= len(self._metadata):
continue
meta = self._metadata[int(idx)]
results.append(GlobalSearchResult(
prototype_id=meta["prototype_id"],
similarity=float(dist),
pack_source_hash=meta["pack_source_hash"],
workflow_skeleton_id=meta["workflow_skeleton_id"],
node_name=meta["node_name"],
app_name=meta["app_name"],
metadata=meta,
))
return results
# ------------------------------------------------------------------
# Persistance
# ------------------------------------------------------------------
def save(self, path: Path) -> None:
"""
Sauvegarder l'index et ses métadonnées.
Crée deux fichiers :
- ``{path}.faiss`` — index FAISS binaire
- ``{path}.meta.json`` — métadonnées JSON
Args:
path: Chemin de base (sans extension).
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
index_path = path.with_suffix(".faiss")
meta_path = path.with_suffix(".meta.json")
faiss.write_index(self.index, str(index_path))
meta_data = {
"dimensions": self.dimensions,
"total_vectors": self.total_vectors,
"entries": self._metadata,
}
with open(meta_path, "w", encoding="utf-8") as fh:
json.dump(meta_data, fh, indent=2, ensure_ascii=False)
logger.info(
"Index global sauvegardé : %s (%d vecteurs)",
index_path, self.total_vectors,
)
@classmethod
def load(cls, path: Path) -> "GlobalFAISSIndex":
"""
Charger un index depuis le disque.
Args:
path: Chemin de base (sans extension).
Returns:
GlobalFAISSIndex chargé et prêt à l'emploi.
"""
if not FAISS_AVAILABLE:
raise ImportError("FAISS requis pour charger l'index global.")
path = Path(path)
index_path = path.with_suffix(".faiss")
meta_path = path.with_suffix(".meta.json")
with open(meta_path, "r", encoding="utf-8") as fh:
meta_data = json.load(fh)
dimensions = meta_data.get("dimensions", DEFAULT_DIMENSIONS)
instance = cls.__new__(cls)
instance.dimensions = dimensions
instance.index = faiss.read_index(str(index_path))
instance._metadata = meta_data.get("entries", [])
logger.info(
"Index global chargé : %s (%d vecteurs, %dd)",
index_path, instance.total_vectors, dimensions,
)
return instance
def get_stats(self) -> Dict[str, Any]:
"""Statistiques de l'index global."""
source_hashes = set()
app_names = set()
for meta in self._metadata:
source_hashes.add(meta.get("pack_source_hash", ""))
app_name = meta.get("app_name", "")
if app_name:
app_names.add(app_name)
return {
"dimensions": self.dimensions,
"total_vectors": self.total_vectors,
"unique_sources": len(source_hashes),
"unique_apps": sorted(app_names),
}
# ------------------------------------------------------------------
# Utilitaires internes
# ------------------------------------------------------------------
def _proto_to_vector(self, proto: ScreenPrototype) -> Optional[np.ndarray]:
"""Convertir un ScreenPrototype en vecteur numpy, ou None si absent."""
if proto.vector is None or len(proto.vector) == 0:
return None
vec = np.array(proto.vector, dtype=np.float32)
if vec.shape[0] != self.dimensions:
logger.warning(
"Prototype %s : dimensions incorrectes (%d != %d), ignoré",
proto.prototype_id, vec.shape[0], self.dimensions,
)
return None
return vec
@staticmethod
def _extract_skeleton_id(proto: ScreenPrototype) -> str:
"""Extraire le workflow_id depuis le prototype_id (format: workflow_id__node_id)."""
parts = proto.prototype_id.split("__", 1)
return parts[0] if len(parts) >= 1 else ""
@staticmethod
def _extract_node_name(proto: ScreenPrototype) -> str:
"""Extraire le node_id depuis le prototype_id."""
parts = proto.prototype_id.split("__", 1)
return parts[1] if len(parts) >= 2 else proto.prototype_id

View File

@@ -0,0 +1,961 @@
"""
Learning Pack — Format d'export anonymisé des apprentissages.
Un LearningPack contient les connaissances extraites des workflows
d'un client, sans aucune donnée personnelle ou sensible.
Ce qu'on exporte (anonymisé) :
- Embeddings CLIP des prototypes d'écran (vecteurs 512d — pas réversibles)
- ScreenTemplates (contraintes UI : titres fenêtres, rôles éléments)
- Structure des workflows (nodes/edges, actions, contraintes)
- Patterns d'erreur rencontrés
- Signatures d'applications (app_name, version)
Ce qu'on N'exporte PAS :
- Screenshots bruts
- Textes OCR bruts (données patient potentielles)
- Événements clavier bruts (mots de passe potentiels)
- machine_id, hostname, IP (identification du client)
Structure JSON :
{
"version": "1.0",
"created_at": "2026-03-19T...",
"source_hash": "abc123...", # SHA-256 anonyme du client
"pack_id": "lp_xxx",
"stats": { ... },
"app_signatures": [ ... ],
"screen_prototypes": [ ... ],
"workflow_skeletons": [ ... ],
"ui_patterns": [ ... ],
"error_patterns": [ ... ],
"edge_statistics": [ ... ],
}
Auteur : Dom, Claude — 19 mars 2026
"""
import hashlib
import json
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
# Version du format Learning Pack
LEARNING_PACK_VERSION = "1.0"
# Seuil de similarité cosinus pour considérer deux prototypes comme identiques
DEDUP_COSINE_THRESHOLD = 0.95
# Longueur maximale d'un texte avant d'être considéré comme donnée OCR sensible
MAX_SAFE_TEXT_LENGTH = 120
# Champs de métadonnées à exclure (données sensibles)
_SENSITIVE_METADATA_KEYS = frozenset({
"screenshot_path", "screenshot", "ocr_text", "ocr_raw",
"raw_text", "keyboard_events", "key_events", "input_text",
"machine_id", "hostname", "ip_address", "user", "username",
"patient", "patient_id", "dossier", "nip", "ipp",
})
# ============================================================================
# Structures de données du Learning Pack
# ============================================================================
@dataclass
class AppSignature:
"""Signature d'une application observée."""
app_name: str
version: Optional[str] = None
window_title_patterns: List[str] = field(default_factory=list)
observation_count: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"app_name": self.app_name,
"version": self.version,
"window_title_patterns": self.window_title_patterns,
"observation_count": self.observation_count,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AppSignature":
return cls(
app_name=data["app_name"],
version=data.get("version"),
window_title_patterns=data.get("window_title_patterns", []),
observation_count=data.get("observation_count", 1),
)
@dataclass
class ScreenPrototype:
"""Prototype d'écran anonymisé (embedding + contraintes UI)."""
prototype_id: str
vector: Optional[List[float]] = None # Vecteur 512d sérialisé en liste
provider: str = "openclip_ViT-B-32"
app_name: Optional[str] = None
window_constraints: Optional[Dict[str, Any]] = None
text_constraints: Optional[Dict[str, Any]] = None
ui_constraints: Optional[Dict[str, Any]] = None
sample_count: int = 1
source_hashes: List[str] = field(default_factory=list) # Packs d'origine
def to_dict(self) -> Dict[str, Any]:
return {
"prototype_id": self.prototype_id,
"vector": self.vector,
"provider": self.provider,
"app_name": self.app_name,
"window_constraints": self.window_constraints,
"text_constraints": self.text_constraints,
"ui_constraints": self.ui_constraints,
"sample_count": self.sample_count,
"source_hashes": self.source_hashes,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ScreenPrototype":
return cls(
prototype_id=data["prototype_id"],
vector=data.get("vector"),
provider=data.get("provider", "openclip_ViT-B-32"),
app_name=data.get("app_name"),
window_constraints=data.get("window_constraints"),
text_constraints=data.get("text_constraints"),
ui_constraints=data.get("ui_constraints"),
sample_count=data.get("sample_count", 1),
source_hashes=data.get("source_hashes", []),
)
@dataclass
class WorkflowSkeleton:
"""Structure anonymisée d'un workflow (sans données sensibles)."""
skeleton_id: str
name: str
description: str
learning_state: str
node_names: List[str]
edge_summaries: List[Dict[str, Any]] # from_node, to_node, action_type, target_role
entry_nodes: List[str]
end_nodes: List[str]
node_count: int = 0
edge_count: int = 0
app_names: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"skeleton_id": self.skeleton_id,
"name": self.name,
"description": self.description,
"learning_state": self.learning_state,
"node_names": self.node_names,
"edge_summaries": self.edge_summaries,
"entry_nodes": self.entry_nodes,
"end_nodes": self.end_nodes,
"node_count": self.node_count,
"edge_count": self.edge_count,
"app_names": self.app_names,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "WorkflowSkeleton":
return cls(
skeleton_id=data["skeleton_id"],
name=data["name"],
description=data.get("description", ""),
learning_state=data.get("learning_state", "OBSERVATION"),
node_names=data.get("node_names", []),
edge_summaries=data.get("edge_summaries", []),
entry_nodes=data.get("entry_nodes", []),
end_nodes=data.get("end_nodes", []),
node_count=data.get("node_count", 0),
edge_count=data.get("edge_count", 0),
app_names=data.get("app_names", []),
)
@dataclass
class UIPattern:
"""Pattern UI universel (bouton Enregistrer, menu Fichier, etc.)."""
pattern_id: str
role: str # button, textfield, menu, etc.
context_description: str # description du contexte
window_title_patterns: List[str] = field(default_factory=list)
observation_count: int = 1
cross_client_count: int = 1 # Nb de clients différents l'ayant vu
confidence: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"pattern_id": self.pattern_id,
"role": self.role,
"context_description": self.context_description,
"window_title_patterns": self.window_title_patterns,
"observation_count": self.observation_count,
"cross_client_count": self.cross_client_count,
"confidence": self.confidence,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "UIPattern":
return cls(
pattern_id=data["pattern_id"],
role=data.get("role", "unknown"),
context_description=data.get("context_description", ""),
window_title_patterns=data.get("window_title_patterns", []),
observation_count=data.get("observation_count", 1),
cross_client_count=data.get("cross_client_count", 1),
confidence=data.get("confidence", 0.0),
)
@dataclass
class ErrorPattern:
"""Pattern d'erreur rencontré (texte d'erreur, contexte, fréquence)."""
pattern_id: str
error_text: str
kind: str = "text_present" # kind du PostConditionCheck source
app_name: Optional[str] = None
observation_count: int = 1
cross_client_count: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"pattern_id": self.pattern_id,
"error_text": self.error_text,
"kind": self.kind,
"app_name": self.app_name,
"observation_count": self.observation_count,
"cross_client_count": self.cross_client_count,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ErrorPattern":
return cls(
pattern_id=data["pattern_id"],
error_text=data["error_text"],
kind=data.get("kind", "text_present"),
app_name=data.get("app_name"),
observation_count=data.get("observation_count", 1),
cross_client_count=data.get("cross_client_count", 1),
)
@dataclass
class EdgeStatistic:
"""Statistiques anonymisées d'une transition entre écrans."""
from_node_name: str
to_node_name: str
action_type: str
target_role: Optional[str] = None
execution_count: int = 0
success_rate: float = 0.0
avg_execution_time_ms: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"from_node_name": self.from_node_name,
"to_node_name": self.to_node_name,
"action_type": self.action_type,
"target_role": self.target_role,
"execution_count": self.execution_count,
"success_rate": self.success_rate,
"avg_execution_time_ms": self.avg_execution_time_ms,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "EdgeStatistic":
return cls(
from_node_name=data["from_node_name"],
to_node_name=data["to_node_name"],
action_type=data["action_type"],
target_role=data.get("target_role"),
execution_count=data.get("execution_count", 0),
success_rate=data.get("success_rate", 0.0),
avg_execution_time_ms=data.get("avg_execution_time_ms", 0.0),
)
# ============================================================================
# LearningPack — conteneur principal
# ============================================================================
@dataclass
class LearningPack:
"""
Pack d'apprentissage anonymisé prêt à être échangé entre sites.
Peut être sérialisé en JSON (``to_dict`` / ``from_dict``)
ou sauvegardé / chargé depuis un fichier (``save`` / ``load``).
"""
version: str = LEARNING_PACK_VERSION
created_at: str = ""
source_hash: str = ""
pack_id: str = ""
stats: Dict[str, Any] = field(default_factory=dict)
app_signatures: List[AppSignature] = field(default_factory=list)
screen_prototypes: List[ScreenPrototype] = field(default_factory=list)
workflow_skeletons: List[WorkflowSkeleton] = field(default_factory=list)
ui_patterns: List[UIPattern] = field(default_factory=list)
error_patterns: List[ErrorPattern] = field(default_factory=list)
edge_statistics: List[EdgeStatistic] = field(default_factory=list)
# --- Sérialisation -------------------------------------------------------
def to_dict(self) -> Dict[str, Any]:
"""Convertir en dictionnaire JSON-sérialisable."""
return {
"version": self.version,
"created_at": self.created_at,
"source_hash": self.source_hash,
"pack_id": self.pack_id,
"stats": self.stats,
"app_signatures": [a.to_dict() for a in self.app_signatures],
"screen_prototypes": [p.to_dict() for p in self.screen_prototypes],
"workflow_skeletons": [s.to_dict() for s in self.workflow_skeletons],
"ui_patterns": [u.to_dict() for u in self.ui_patterns],
"error_patterns": [e.to_dict() for e in self.error_patterns],
"edge_statistics": [e.to_dict() for e in self.edge_statistics],
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "LearningPack":
"""Reconstruire depuis un dictionnaire."""
return cls(
version=data.get("version", LEARNING_PACK_VERSION),
created_at=data.get("created_at", ""),
source_hash=data.get("source_hash", ""),
pack_id=data.get("pack_id", ""),
stats=data.get("stats", {}),
app_signatures=[
AppSignature.from_dict(a) for a in data.get("app_signatures", [])
],
screen_prototypes=[
ScreenPrototype.from_dict(p) for p in data.get("screen_prototypes", [])
],
workflow_skeletons=[
WorkflowSkeleton.from_dict(s) for s in data.get("workflow_skeletons", [])
],
ui_patterns=[
UIPattern.from_dict(u) for u in data.get("ui_patterns", [])
],
error_patterns=[
ErrorPattern.from_dict(e) for e in data.get("error_patterns", [])
],
edge_statistics=[
EdgeStatistic.from_dict(e) for e in data.get("edge_statistics", [])
],
)
# --- Persistance fichier --------------------------------------------------
def save(self, path: Path) -> None:
"""Sauvegarder le pack au format JSON compressé."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(self.to_dict(), fh, indent=2, ensure_ascii=False)
logger.info("Learning pack sauvegardé : %s (%d prototypes, %d skeletons)",
path, len(self.screen_prototypes), len(self.workflow_skeletons))
@classmethod
def load(cls, path: Path) -> "LearningPack":
"""Charger un pack depuis un fichier JSON."""
path = Path(path)
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
pack = cls.from_dict(data)
logger.info("Learning pack chargé : %s (v%s, %d prototypes)",
path, pack.version, len(pack.screen_prototypes))
return pack
# ============================================================================
# Fonctions utilitaires d'anonymisation
# ============================================================================
def _hash_client_id(client_id: str) -> str:
"""Hacher un identifiant client via SHA-256 (irréversible)."""
return hashlib.sha256(client_id.encode("utf-8")).hexdigest()
def _sanitize_text(text: str) -> Optional[str]:
"""
Nettoyer un texte pour l'export.
Retourne None si le texte est trop long (probable donnée OCR sensible)
ou s'il contient des patterns suspects (numéros de dossier, etc.).
"""
if not text or len(text) > MAX_SAFE_TEXT_LENGTH:
return None
# Filtrer les textes qui ressemblent à des identifiants patients
lower = text.lower()
for suspect in ("patient", "nip:", "ipp:", "dossier n", "numéro de"):
if suspect in lower:
return None
return text
def _clean_metadata(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""Retirer les clés sensibles d'un dictionnaire de métadonnées."""
return {
k: v for k, v in metadata.items()
if k.lower() not in _SENSITIVE_METADATA_KEYS
}
def _extract_prototype_vector(node) -> Optional[List[float]]:
"""
Extraire le vecteur prototype d'un WorkflowNode.
Cherche dans ``node.metadata["_prototype_vector"]`` (numpy array ou liste)
puis tente de charger depuis le fichier .npy référencé par le template.
"""
# 1. Vecteur directement stocké dans les métadonnées
vec = node.metadata.get("_prototype_vector")
if vec is not None:
if isinstance(vec, np.ndarray):
return vec.tolist()
if isinstance(vec, list):
return vec
# 2. Fichier .npy référencé par le template embedding
vector_id = node.template.embedding.vector_id
if vector_id:
npy_path = Path(vector_id)
if npy_path.exists() and npy_path.suffix == ".npy":
try:
arr = np.load(str(npy_path))
return arr.tolist()
except Exception as exc:
logger.debug("Impossible de charger %s : %s", npy_path, exc)
return None
# ============================================================================
# LearningPackExporter
# ============================================================================
class LearningPackExporter:
"""
Produit un LearningPack anonymisé à partir d'une liste de Workflows.
Usage :
>>> from core.models.workflow_graph import Workflow
>>> exporter = LearningPackExporter()
>>> pack = exporter.export(workflows, client_id="CHU-Lyon-001")
>>> pack.save(Path("export/chu_lyon.json"))
"""
def export(self, workflows, client_id: str) -> LearningPack:
"""
Exporter les workflows d'un client en un LearningPack anonymisé.
Args:
workflows: Liste d'objets ``Workflow`` (core.models.workflow_graph).
client_id: Identifiant en clair du client (sera haché).
Returns:
LearningPack prêt à être sauvegardé ou envoyé au serveur central.
"""
source_hash = _hash_client_id(client_id)
pack_id = f"lp_{uuid.uuid4().hex[:12]}"
app_sigs: Dict[str, AppSignature] = {}
prototypes: List[ScreenPrototype] = []
skeletons: List[WorkflowSkeleton] = []
ui_patterns_map: Dict[str, UIPattern] = {}
error_patterns_map: Dict[str, ErrorPattern] = {}
edge_stats: List[EdgeStatistic] = []
total_nodes = 0
total_edges = 0
for wf in workflows:
# --- Skeleton ---
skeleton = self._extract_skeleton(wf)
skeletons.append(skeleton)
total_nodes += len(wf.nodes)
total_edges += len(wf.edges)
# --- Nodes : prototypes + app signatures + UI patterns ---
for node in wf.nodes:
proto = self._extract_prototype(node, source_hash, wf.workflow_id)
if proto is not None:
prototypes.append(proto)
self._collect_app_signature(node, app_sigs)
self._collect_ui_patterns(node, ui_patterns_map)
# --- Edges : actions + error patterns + stats ---
for edge in wf.edges:
self._collect_error_patterns(edge, error_patterns_map, wf)
stat = self._extract_edge_statistic(edge, wf)
if stat is not None:
edge_stats.append(stat)
apps_seen = sorted(app_sigs.keys())
pack = LearningPack(
version=LEARNING_PACK_VERSION,
created_at=datetime.utcnow().isoformat(),
source_hash=source_hash,
pack_id=pack_id,
stats={
"workflows_count": len(workflows),
"total_nodes": total_nodes,
"total_edges": total_edges,
"apps_seen": apps_seen,
"prototypes_exported": len(prototypes),
},
app_signatures=list(app_sigs.values()),
screen_prototypes=prototypes,
workflow_skeletons=skeletons,
ui_patterns=list(ui_patterns_map.values()),
error_patterns=list(error_patterns_map.values()),
edge_statistics=edge_stats,
)
logger.info(
"Learning pack exporté : %s%d workflows, %d prototypes, %d error patterns",
pack_id, len(workflows), len(prototypes), len(error_patterns_map),
)
return pack
# ------------------------------------------------------------------
# Extraction unitaire
# ------------------------------------------------------------------
def _extract_skeleton(self, wf) -> WorkflowSkeleton:
"""Extraire le squelette anonymisé d'un workflow."""
node_names = [n.name for n in wf.nodes]
app_names = set()
edge_summaries = []
for edge in wf.edges:
summary: Dict[str, Any] = {
"from_node": edge.from_node,
"to_node": edge.to_node,
"action_type": edge.action.type,
"target_role": edge.action.target.by_role,
}
edge_summaries.append(summary)
for node in wf.nodes:
proc = node.template.window.process_name
if proc:
app_names.add(proc)
return WorkflowSkeleton(
skeleton_id=wf.workflow_id,
name=wf.name,
description=wf.description,
learning_state=wf.learning_state,
node_names=node_names,
edge_summaries=edge_summaries,
entry_nodes=wf.entry_nodes,
end_nodes=wf.end_nodes,
node_count=len(wf.nodes),
edge_count=len(wf.edges),
app_names=sorted(app_names),
)
def _extract_prototype(
self, node, source_hash: str, workflow_id: str
) -> Optional[ScreenPrototype]:
"""Extraire un ScreenPrototype anonymisé depuis un WorkflowNode."""
vector = _extract_prototype_vector(node)
# On exporte même sans vecteur : les contraintes UI ont de la valeur
app_name = node.template.window.process_name
# Construire les contraintes nettoyées
window_constraints = node.template.window.to_dict()
text_constraints = self._sanitize_text_constraints(node.template.text.to_dict())
ui_constraints = node.template.ui.to_dict()
return ScreenPrototype(
prototype_id=f"{workflow_id}__{node.node_id}",
vector=vector,
provider=node.template.embedding.provider,
app_name=app_name,
window_constraints=window_constraints,
text_constraints=text_constraints,
ui_constraints=ui_constraints,
sample_count=node.template.embedding.sample_count,
source_hashes=[source_hash],
)
def _sanitize_text_constraints(self, text_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Nettoyer les contraintes texte en retirant les textes trop longs / sensibles."""
required = [
t for t in text_dict.get("required_texts", [])
if _sanitize_text(t) is not None
]
forbidden = [
t for t in text_dict.get("forbidden_texts", [])
if _sanitize_text(t) is not None
]
return {"required_texts": required, "forbidden_texts": forbidden}
def _collect_app_signature(
self, node, app_sigs: Dict[str, AppSignature]
) -> None:
"""Collecter la signature d'application depuis un node."""
proc = node.template.window.process_name
if not proc:
return
if proc in app_sigs:
app_sigs[proc].observation_count += 1
else:
title_pattern = node.template.window.title_pattern
patterns = [title_pattern] if title_pattern else []
app_sigs[proc] = AppSignature(
app_name=proc,
window_title_patterns=patterns,
)
# Ajouter le pattern de titre s'il est nouveau
title_pattern = node.template.window.title_pattern
if title_pattern and title_pattern not in app_sigs[proc].window_title_patterns:
app_sigs[proc].window_title_patterns.append(title_pattern)
def _collect_ui_patterns(
self, node, patterns: Dict[str, UIPattern]
) -> None:
"""Collecter les patterns UI depuis les contraintes d'un node."""
for role in node.template.ui.required_roles:
key = role
if key in patterns:
patterns[key].observation_count += 1
else:
title_pattern = node.template.window.title_pattern
title_patterns = [title_pattern] if title_pattern else []
patterns[key] = UIPattern(
pattern_id=f"uip_{role}",
role=role,
context_description=f"Rôle UI requis : {role}",
window_title_patterns=title_patterns,
)
def _collect_error_patterns(
self, edge, patterns: Dict[str, ErrorPattern], wf
) -> None:
"""Extraire les patterns d'erreur depuis les PostConditions.fail_fast."""
for check in edge.post_conditions.fail_fast:
if check.value and _sanitize_text(check.value) is not None:
key = check.value
if key in patterns:
patterns[key].observation_count += 1
else:
# Trouver l'app_name du node source
source_node = wf.get_node(edge.from_node)
app_name = None
if source_node:
app_name = source_node.template.window.process_name
patterns[key] = ErrorPattern(
pattern_id=f"err_{hashlib.md5(key.encode()).hexdigest()[:8]}",
error_text=check.value,
kind=check.kind,
app_name=app_name,
)
def _extract_edge_statistic(self, edge, wf) -> Optional[EdgeStatistic]:
"""Extraire les statistiques anonymisées d'un edge."""
source_node = wf.get_node(edge.from_node)
target_node = wf.get_node(edge.to_node)
from_name = source_node.name if source_node else edge.from_node
to_name = target_node.name if target_node else edge.to_node
return EdgeStatistic(
from_node_name=from_name,
to_node_name=to_name,
action_type=edge.action.type,
target_role=edge.action.target.by_role,
execution_count=edge.stats.execution_count,
success_rate=edge.stats.success_rate,
avg_execution_time_ms=edge.stats.avg_execution_time_ms,
)
# ============================================================================
# LearningPackMerger
# ============================================================================
class LearningPackMerger:
"""
Fusionne plusieurs LearningPacks en un seul pack consolidé.
La fusion :
- Déduplique les prototypes similaires (cosine > 0.95 = même écran)
- Fusionne les signatures d'application (union)
- Fusionne les patterns d'erreur (union, comptage cross-clients)
- Calcule les occurrences cross-clients (haute confiance si vu par N clients)
Usage :
>>> merger = LearningPackMerger()
>>> merged = merger.merge([pack_a, pack_b, pack_c])
>>> merged.save(Path("global/merged_pack.json"))
"""
def __init__(self, dedup_threshold: float = DEDUP_COSINE_THRESHOLD):
self.dedup_threshold = dedup_threshold
def merge(self, packs: List[LearningPack]) -> LearningPack:
"""
Fusionner plusieurs packs en un pack global consolidé.
Args:
packs: Liste de LearningPacks à fusionner.
Returns:
LearningPack consolidé avec déduplication et comptage cross-clients.
"""
if not packs:
return LearningPack(
created_at=datetime.utcnow().isoformat(),
pack_id=f"lp_merged_{uuid.uuid4().hex[:8]}",
)
if len(packs) == 1:
# Un seul pack : on le retourne avec un nouveau pack_id
merged = LearningPack.from_dict(packs[0].to_dict())
merged.pack_id = f"lp_merged_{uuid.uuid4().hex[:8]}"
return merged
merged_id = f"lp_merged_{uuid.uuid4().hex[:8]}"
source_hashes = list({p.source_hash for p in packs if p.source_hash})
# Fusionner chaque catégorie
app_sigs = self._merge_app_signatures(packs)
prototypes = self._merge_prototypes(packs)
skeletons = self._merge_skeletons(packs)
ui_patterns = self._merge_ui_patterns(packs)
error_patterns = self._merge_error_patterns(packs)
edge_stats = self._merge_edge_statistics(packs)
# Calculer les stats globales
total_wf = sum(p.stats.get("workflows_count", 0) for p in packs)
total_nodes = sum(p.stats.get("total_nodes", 0) for p in packs)
total_edges = sum(p.stats.get("total_edges", 0) for p in packs)
all_apps = set()
for p in packs:
all_apps.update(p.stats.get("apps_seen", []))
return LearningPack(
version=LEARNING_PACK_VERSION,
created_at=datetime.utcnow().isoformat(),
source_hash=",".join(sorted(source_hashes)),
pack_id=merged_id,
stats={
"workflows_count": total_wf,
"total_nodes": total_nodes,
"total_edges": total_edges,
"apps_seen": sorted(all_apps),
"prototypes_exported": len(prototypes),
"source_packs_count": len(packs),
"source_hashes": source_hashes,
},
app_signatures=app_sigs,
screen_prototypes=prototypes,
workflow_skeletons=skeletons,
ui_patterns=ui_patterns,
error_patterns=error_patterns,
edge_statistics=edge_stats,
)
# ------------------------------------------------------------------
# Fusion par catégorie
# ------------------------------------------------------------------
def _merge_app_signatures(self, packs: List[LearningPack]) -> List[AppSignature]:
"""Union des signatures d'application, cumul des compteurs."""
merged: Dict[str, AppSignature] = {}
for pack in packs:
for sig in pack.app_signatures:
if sig.app_name in merged:
existing = merged[sig.app_name]
existing.observation_count += sig.observation_count
for pat in sig.window_title_patterns:
if pat not in existing.window_title_patterns:
existing.window_title_patterns.append(pat)
else:
merged[sig.app_name] = AppSignature.from_dict(sig.to_dict())
return list(merged.values())
def _merge_prototypes(self, packs: List[LearningPack]) -> List[ScreenPrototype]:
"""
Fusionner les prototypes avec déduplication par similarité cosinus.
Deux prototypes avec cosine > ``self.dedup_threshold`` sont considérés
comme le même écran. On conserve celui avec le plus d'échantillons
et on fusionne les source_hashes.
"""
all_protos: List[ScreenPrototype] = []
for pack in packs:
all_protos.extend(pack.screen_prototypes)
if not all_protos:
return []
# Séparer les prototypes avec et sans vecteur
with_vec: List[Tuple[ScreenPrototype, np.ndarray]] = []
without_vec: List[ScreenPrototype] = []
for proto in all_protos:
if proto.vector is not None and len(proto.vector) > 0:
vec = np.array(proto.vector, dtype=np.float32)
norm = np.linalg.norm(vec)
if norm > 0:
vec = vec / norm
with_vec.append((proto, vec))
else:
without_vec.append(proto)
# Déduplication greedy par similarité cosinus
merged: List[ScreenPrototype] = []
used = [False] * len(with_vec)
for i, (proto_i, vec_i) in enumerate(with_vec):
if used[i]:
continue
used[i] = True
# Chercher les prototypes similaires
group_sources = set(proto_i.source_hashes)
best_sample_count = proto_i.sample_count
best_proto = proto_i
for j in range(i + 1, len(with_vec)):
if used[j]:
continue
proto_j, vec_j = with_vec[j]
cosine_sim = float(np.dot(vec_i, vec_j))
if cosine_sim >= self.dedup_threshold:
used[j] = True
group_sources.update(proto_j.source_hashes)
if proto_j.sample_count > best_sample_count:
best_sample_count = proto_j.sample_count
best_proto = proto_j
# Construire le prototype consolidé
consolidated = ScreenPrototype.from_dict(best_proto.to_dict())
consolidated.source_hashes = sorted(group_sources)
consolidated.sample_count = best_sample_count
merged.append(consolidated)
# Ajouter les prototypes sans vecteur (pas de déduplication possible)
merged.extend(without_vec)
logger.info(
"Fusion prototypes : %d entrées → %d après déduplication (seuil=%.2f)",
len(all_protos), len(merged), self.dedup_threshold,
)
return merged
def _merge_skeletons(self, packs: List[LearningPack]) -> List[WorkflowSkeleton]:
"""Union des skeletons de workflows (dédupliqués par skeleton_id)."""
merged: Dict[str, WorkflowSkeleton] = {}
for pack in packs:
for skel in pack.workflow_skeletons:
if skel.skeleton_id not in merged:
merged[skel.skeleton_id] = skel
return list(merged.values())
def _merge_ui_patterns(self, packs: List[LearningPack]) -> List[UIPattern]:
"""Fusionner les patterns UI avec comptage cross-clients."""
merged: Dict[str, UIPattern] = {}
# Suivre quels source_hashes ont vu chaque pattern
pattern_sources: Dict[str, set] = {}
for pack in packs:
for pattern in pack.ui_patterns:
key = pattern.role
if key in merged:
merged[key].observation_count += pattern.observation_count
for pat in pattern.window_title_patterns:
if pat not in merged[key].window_title_patterns:
merged[key].window_title_patterns.append(pat)
else:
merged[key] = UIPattern.from_dict(pattern.to_dict())
pattern_sources[key] = set()
if pack.source_hash:
pattern_sources.setdefault(key, set()).add(pack.source_hash)
# Mettre à jour le cross_client_count
for key, pattern in merged.items():
sources = pattern_sources.get(key, set())
pattern.cross_client_count = len(sources)
# Confiance = proportion de clients ayant vu le pattern
total_clients = len({p.source_hash for p in packs if p.source_hash})
pattern.confidence = (
len(sources) / total_clients if total_clients > 0 else 0.0
)
return list(merged.values())
def _merge_error_patterns(self, packs: List[LearningPack]) -> List[ErrorPattern]:
"""Fusionner les patterns d'erreur avec comptage cross-clients."""
merged: Dict[str, ErrorPattern] = {}
pattern_sources: Dict[str, set] = {}
for pack in packs:
for pattern in pack.error_patterns:
key = pattern.error_text
if key in merged:
merged[key].observation_count += pattern.observation_count
else:
merged[key] = ErrorPattern.from_dict(pattern.to_dict())
pattern_sources[key] = set()
if pack.source_hash:
pattern_sources.setdefault(key, set()).add(pack.source_hash)
for key, pattern in merged.items():
pattern.cross_client_count = len(pattern_sources.get(key, set()))
return list(merged.values())
def _merge_edge_statistics(
self, packs: List[LearningPack]
) -> List[EdgeStatistic]:
"""Fusionner les statistiques de transitions."""
merged: Dict[str, EdgeStatistic] = {}
for pack in packs:
for stat in pack.edge_statistics:
key = f"{stat.from_node_name}{stat.to_node_name}{stat.action_type}"
if key in merged:
existing = merged[key]
total_exec = existing.execution_count + stat.execution_count
if total_exec > 0:
# Moyenne pondérée du success_rate
existing.success_rate = (
existing.success_rate * existing.execution_count
+ stat.success_rate * stat.execution_count
) / total_exec
# Moyenne pondérée du temps d'exécution
existing.avg_execution_time_ms = (
existing.avg_execution_time_ms * existing.execution_count
+ stat.avg_execution_time_ms * stat.execution_count
) / total_exec
existing.execution_count = total_exec
else:
merged[key] = EdgeStatistic.from_dict(stat.to_dict())
return list(merged.values())

File diff suppressed because it is too large Load Diff

View File

@@ -135,27 +135,48 @@ class ContextLevel:
@dataclass @dataclass
class WindowContext: class WindowContext:
"""Contexte de fenêtre""" """Contexte de fenêtre avec métadonnées d'environnement graphique"""
app_name: str app_name: str
window_title: str window_title: str
screen_resolution: List[int] screen_resolution: List[int]
workspace: str = "main" workspace: str = "main"
monitor_index: int = 0 # Index du moniteur (0 = principal)
dpi_scale: int = 100 # Facteur DPI en % (100 = normal, 150 = haute résolution)
window_bounds: Optional[List[int]] = None # [x, y, width, height] de la fenêtre
monitors: Optional[List[Dict[str, int]]] = None # Liste des moniteurs [{width, height, x, y}]
os_theme: str = "unknown" # "light", "dark", "unknown"
os_language: str = "unknown" # Code langue (fr, en, de...)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { result = {
"app_name": self.app_name, "app_name": self.app_name,
"window_title": self.window_title, "window_title": self.window_title,
"screen_resolution": self.screen_resolution, "screen_resolution": self.screen_resolution,
"workspace": self.workspace "workspace": self.workspace,
"monitor_index": self.monitor_index,
"dpi_scale": self.dpi_scale,
"os_theme": self.os_theme,
"os_language": self.os_language,
} }
if self.window_bounds is not None:
result["window_bounds"] = self.window_bounds
if self.monitors is not None:
result["monitors"] = self.monitors
return result
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'WindowContext': def from_dict(cls, data: Dict[str, Any]) -> 'WindowContext':
return cls( return cls(
app_name=data["app_name"], app_name=data["app_name"],
window_title=data["window_title"], window_title=data["window_title"],
screen_resolution=data["screen_resolution"], screen_resolution=data["screen_resolution"],
workspace=data.get("workspace", "main") workspace=data.get("workspace", "main"),
monitor_index=data.get("monitor_index", 0),
dpi_scale=data.get("dpi_scale", 100),
window_bounds=data.get("window_bounds"),
monitors=data.get("monitors"),
os_theme=data.get("os_theme", "unknown"),
os_language=data.get("os_language", "unknown"),
) )

View File

@@ -304,7 +304,7 @@ class ScreenTemplate:
# Vérifier contraintes de fenêtre # Vérifier contraintes de fenêtre
if hasattr(screen_state, 'window'): if hasattr(screen_state, 'window'):
window_title = getattr(screen_state.window, 'title', '') window_title = getattr(screen_state.window, 'window_title', '')
process = getattr(screen_state.window, 'process', '') process = getattr(screen_state.window, 'process', '')
if not self.window.matches(window_title, process): if not self.window.matches(window_title, process):
return False, 0.0 return False, 0.0
@@ -672,24 +672,94 @@ class Action:
@dataclass @dataclass
class EdgeConstraints: class EdgeConstraints:
"""Contraintes pour l'exécution d'un edge""" """Contraintes pour l'exécution d'un edge (pré-conditions avant l'action)"""
pre_conditions: Dict[str, Any] = field(default_factory=dict) pre_conditions: Dict[str, Any] = field(default_factory=dict)
required_confidence: float = 0.8 required_confidence: float = 0.8
max_wait_time_ms: int = 5000 max_wait_time_ms: int = 5000
# Contraintes enrichies extraites du node source
window: Optional[WindowConstraint] = None
text: Optional[TextConstraint] = None
min_source_similarity: float = 0.80
required_app_name: Optional[str] = None
required_window_title: Optional[str] = None
def check_preconditions(
self, window_title: str = "", app_name: str = "",
detected_texts: Optional[List[str]] = None,
source_similarity: float = 1.0,
) -> Tuple[bool, str]:
"""
Vérifier si toutes les pré-conditions sont satisfaites.
Returns:
(ok: bool, reason: str)
"""
# Vérifier similarité minimale avec le node source
if source_similarity < self.min_source_similarity:
return False, (
f"Similarité source insuffisante: {source_similarity:.2f} "
f"< {self.min_source_similarity:.2f}"
)
# Vérifier titre de fenêtre
if self.required_window_title and window_title:
if self.required_window_title not in window_title:
return False, (
f"Titre de fenêtre incorrect: '{window_title}' "
f"ne contient pas '{self.required_window_title}'"
)
# Vérifier nom d'application
if self.required_app_name and app_name:
if self.required_app_name.lower() not in app_name.lower():
return False, (
f"Application incorrecte: '{app_name}' "
f"ne correspond pas à '{self.required_app_name}'"
)
# Vérifier contrainte de fenêtre (objet WindowConstraint)
if self.window:
if not self.window.matches(window_title, app_name):
return False, f"Contrainte de fenêtre non satisfaite"
# Vérifier contrainte de texte (objet TextConstraint)
if self.text and detected_texts is not None:
if not self.text.matches(detected_texts):
return False, f"Contrainte de texte non satisfaite"
return True, "OK"
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
"pre_conditions": self.pre_conditions, "pre_conditions": self.pre_conditions,
"required_confidence": self.required_confidence, "required_confidence": self.required_confidence,
"max_wait_time_ms": self.max_wait_time_ms "max_wait_time_ms": self.max_wait_time_ms,
"window": self.window.to_dict() if self.window else None,
"text": self.text.to_dict() if self.text else None,
"min_source_similarity": self.min_source_similarity,
"required_app_name": self.required_app_name,
"required_window_title": self.required_window_title,
} }
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'EdgeConstraints': def from_dict(cls, data: Dict[str, Any]) -> 'EdgeConstraints':
window = None
if data.get("window"):
window = WindowConstraint.from_dict(data["window"])
text = None
if data.get("text"):
text = TextConstraint.from_dict(data["text"])
return cls( return cls(
pre_conditions=data.get("pre_conditions", {}), pre_conditions=data.get("pre_conditions", {}),
required_confidence=data.get("required_confidence", 0.8), required_confidence=data.get("required_confidence", 0.8),
max_wait_time_ms=data.get("max_wait_time_ms", 5000) max_wait_time_ms=data.get("max_wait_time_ms", 5000),
window=window,
text=text,
min_source_similarity=data.get("min_source_similarity", 0.80),
required_app_name=data.get("required_app_name"),
required_window_title=data.get("required_window_title"),
) )
@@ -709,23 +779,101 @@ class PostConditionCheck:
@dataclass @dataclass
class PostConditions: class PostConditions:
"""Post-conditions attendues après exécution - Fiche #9""" """Post-conditions attendues après exécution - Fiche #9"""
# (garde tes champs existants si tu en as déjà, et ajoute ceux-ci)
success_mode: str = "all" # "all" | "any" success_mode: str = "all" # "all" | "any"
timeout_ms: int = 2500 timeout_ms: int = 2500
poll_ms: int = 200 poll_ms: int = 200
success: List[PostConditionCheck] = field(default_factory=list) success: List[PostConditionCheck] = field(default_factory=list)
fail_fast: List[PostConditionCheck] = field(default_factory=list) fail_fast: List[PostConditionCheck] = field(default_factory=list)
retries: int = 2 # nb de tentatives après échec post-conditions retries: int = 2 # nb de tentatives après échec post-conditions
backoff_ms: int = 150 # 150, 300, 600... backoff_ms: int = 150 # 150, 300, 600...
# Contraintes enrichies extraites du node cible
expected_window_title: Optional[str] = None
expected_app_name: Optional[str] = None
min_target_similarity: float = 0.80
# Legacy fields (garde compatibilité) # Legacy fields (garde compatibilité)
expected_node: Optional[str] = None # Node attendu après action expected_node: Optional[str] = None # Node attendu après action
window_change_expected: bool = False window_change_expected: bool = False
new_ui_elements_expected: List[str] = field(default_factory=list) new_ui_elements_expected: List[str] = field(default_factory=list)
def check_postconditions(
self, window_title: str = "", app_name: str = "",
detected_texts: Optional[List[str]] = None,
target_similarity: float = 1.0,
) -> Tuple[bool, str]:
"""
Vérifier si les post-conditions sont satisfaites après l'action.
Returns:
(ok: bool, reason: str)
"""
# Vérifier similarité minimale avec le node cible
if target_similarity < self.min_target_similarity:
return False, (
f"Similarité cible insuffisante: {target_similarity:.2f} "
f"< {self.min_target_similarity:.2f}"
)
# Vérifier titre de fenêtre attendu
if self.expected_window_title and window_title:
if self.expected_window_title not in window_title:
return False, (
f"Titre de fenêtre post-action incorrect: '{window_title}' "
f"ne contient pas '{self.expected_window_title}'"
)
# Vérifier application attendue
if self.expected_app_name and app_name:
if self.expected_app_name.lower() not in app_name.lower():
return False, (
f"Application post-action incorrecte: '{app_name}' "
f"ne correspond pas à '{self.expected_app_name}'"
)
# Vérifier les checks de succès (PostConditionCheck)
if self.success:
results = []
for check in self.success:
ok = self._evaluate_check(check, window_title, detected_texts or [])
results.append(ok)
if self.success_mode == "all" and not all(results):
return False, "Certaines post-conditions de succès non satisfaites"
if self.success_mode == "any" and not any(results):
return False, "Aucune post-condition de succès satisfaite"
# Vérifier fail_fast (si un pattern d'erreur est détecté, échec immédiat)
if self.fail_fast and detected_texts:
for check in self.fail_fast:
if self._evaluate_check(check, window_title, detected_texts):
return False, (
f"Condition d'échec détectée: {check.kind}={check.value}"
)
return True, "OK"
@staticmethod
def _evaluate_check(
check: PostConditionCheck,
window_title: str,
detected_texts: List[str],
) -> bool:
"""Évaluer un PostConditionCheck individuel."""
texts_lower = [t.lower() for t in detected_texts]
if check.kind == "text_present":
return any(check.value.lower() in t for t in texts_lower) if check.value else False
elif check.kind == "text_absent":
return not any(check.value.lower() in t for t in texts_lower) if check.value else True
elif check.kind == "window_title_contains":
return check.value.lower() in window_title.lower() if check.value else False
# Autres types de checks non gérés ici → considérés comme OK
return True
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
"success_mode": self.success_mode, "success_mode": self.success_mode,
@@ -735,24 +883,28 @@ class PostConditions:
"fail_fast": [{"kind": c.kind, "value": c.value, "target": c.target.to_dict() if c.target else None} for c in self.fail_fast], "fail_fast": [{"kind": c.kind, "value": c.value, "target": c.target.to_dict() if c.target else None} for c in self.fail_fast],
"retries": self.retries, "retries": self.retries,
"backoff_ms": self.backoff_ms, "backoff_ms": self.backoff_ms,
# Contraintes enrichies
"expected_window_title": self.expected_window_title,
"expected_app_name": self.expected_app_name,
"min_target_similarity": self.min_target_similarity,
# Legacy # Legacy
"expected_node": self.expected_node, "expected_node": self.expected_node,
"window_change_expected": self.window_change_expected, "window_change_expected": self.window_change_expected,
"new_ui_elements_expected": self.new_ui_elements_expected "new_ui_elements_expected": self.new_ui_elements_expected
} }
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PostConditions': def from_dict(cls, data: Dict[str, Any]) -> 'PostConditions':
success_checks = [] success_checks = []
for c in data.get("success", []): for c in data.get("success", []):
target = TargetSpec.from_dict(c["target"]) if c.get("target") else None target = TargetSpec.from_dict(c["target"]) if c.get("target") else None
success_checks.append(PostConditionCheck(kind=c["kind"], value=c.get("value"), target=target)) success_checks.append(PostConditionCheck(kind=c["kind"], value=c.get("value"), target=target))
fail_fast_checks = [] fail_fast_checks = []
for c in data.get("fail_fast", []): for c in data.get("fail_fast", []):
target = TargetSpec.from_dict(c["target"]) if c.get("target") else None target = TargetSpec.from_dict(c["target"]) if c.get("target") else None
fail_fast_checks.append(PostConditionCheck(kind=c["kind"], value=c.get("value"), target=target)) fail_fast_checks.append(PostConditionCheck(kind=c["kind"], value=c.get("value"), target=target))
return cls( return cls(
success_mode=data.get("success_mode", "all"), success_mode=data.get("success_mode", "all"),
timeout_ms=data.get("timeout_ms", 2500), timeout_ms=data.get("timeout_ms", 2500),
@@ -761,6 +913,10 @@ class PostConditions:
fail_fast=fail_fast_checks, fail_fast=fail_fast_checks,
retries=data.get("retries", 2), retries=data.get("retries", 2),
backoff_ms=data.get("backoff_ms", 150), backoff_ms=data.get("backoff_ms", 150),
# Contraintes enrichies
expected_window_title=data.get("expected_window_title"),
expected_app_name=data.get("expected_app_name"),
min_target_similarity=data.get("min_target_similarity", 0.80),
# Legacy # Legacy
expected_node=data.get("expected_node"), expected_node=data.get("expected_node"),
window_change_expected=data.get("window_change_expected", False), window_change_expected=data.get("window_change_expected", False),

View File

@@ -321,6 +321,12 @@ class ScreenAnalyzer:
window_title=window_info.get("title", "Unknown"), window_title=window_info.get("title", "Unknown"),
screen_resolution=window_info.get("screen_resolution", [1920, 1080]), screen_resolution=window_info.get("screen_resolution", [1920, 1080]),
workspace=window_info.get("workspace", "main"), workspace=window_info.get("workspace", "main"),
monitor_index=window_info.get("monitor_index", 0),
dpi_scale=window_info.get("dpi_scale", 100),
window_bounds=window_info.get("window_bounds"),
monitors=window_info.get("monitors"),
os_theme=window_info.get("os_theme", "unknown"),
os_language=window_info.get("os_language", "unknown"),
) )
return WindowContext( return WindowContext(
app_name="unknown", app_name="unknown",

275
deploy/build_lea_exe.sh Executable file
View File

@@ -0,0 +1,275 @@
#!/bin/bash
# ============================================================
# build_lea_exe.sh — Cree un executable Windows autonome via PyInstaller
#
# IMPORTANT : Ce script doit tourner SUR WINDOWS (ou dans Wine/WSL
# avec acces a un Python Windows). PyInstaller ne peut pas produire
# un .exe Windows depuis Linux natif.
#
# Procedure recommandee :
# 1. Sur le PC Windows (192.168.1.11 ou autre) :
# - Installer Python 3.12 (https://python.org)
# - pip install pyinstaller
# 2. Copier ce script et le dossier agent_v0/ sur le PC Windows
# 3. Executer depuis PowerShell/cmd :
# python -m PyInstaller --onefile --windowed ^
# --name "Lea" ^
# --add-data "agent_v1;agent_v1" ^
# --add-data "lea_ui;lea_ui" ^
# --add-data "config.txt;." ^
# --hidden-import "pynput.keyboard._win32" ^
# --hidden-import "pynput.mouse._win32" ^
# --hidden-import "pystray._win32" ^
# --hidden-import "plyer.platforms.win.notification" ^
# --hidden-import "win32api" ^
# --hidden-import "win32con" ^
# --hidden-import "win32gui" ^
# run_agent_v1.py
#
# Le .exe resultant sera dans dist/Lea.exe (~50-100 MB)
#
# ============================================================
#
# OPTION ALTERNATIVE : Python Embedded (recommandee)
#
# Python Embedded est un Python portable officiel (pas d'installation).
# Combine avec le code source, c'est la methode la plus fiable
# pour les non-informaticiens.
#
# Sur une machine Windows :
# 1. Telecharger Python Embedded 3.12 :
# https://www.python.org/ftp/python/3.12.9/python-3.12.9-embed-amd64.zip
#
# 2. Dezipper dans un dossier temporaire
#
# 3. Activer pip dans Python Embedded :
# - Editer python312._pth, decommenter "import site"
# - Telecharger get-pip.py : https://bootstrap.pypa.io/get-pip.py
# - Executer : python.exe get-pip.py
#
# 4. Installer les dependances :
# python.exe -m pip install -r requirements_agent.txt
#
# 5. Copier le code source (agent_v1/, lea_ui/, run_agent_v1.py)
#
# 6. Zipper le tout → Lea_Portable.zip (~40-60 MB)
#
# Le Lea.bat dans ce cas utiliserait :
# python\python.exe run_agent_v1.py
# au lieu de .venv\Scripts\python.exe
#
# ============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
echo "============================================================"
echo " Build Lea.exe (PyInstaller)"
echo "============================================================"
echo ""
echo " Ce script ne peut pas produire un .exe Windows depuis Linux."
echo ""
echo " OPTIONS DISPONIBLES :"
echo ""
echo " 1. OPTION VIA PC WINDOWS (recommandee pour .exe) :"
echo " Copiez le dossier deploy/ sur le PC Windows"
echo " puis lancez la commande PyInstaller ci-dessous."
echo ""
echo " 2. OPTION ZIP + VENV (recommandee pour deploiement rapide) :"
echo " Lancez ./deploy/build_package.sh"
echo " Le zip resultant contient install.bat + Lea.bat"
echo ""
echo " 3. OPTION PYTHON EMBEDDED (recommandee pour zero install) :"
echo " Suivez les instructions dans ce script (section ALTERNATIVE)"
echo ""
echo "============================================================"
echo ""
# Generer le .spec PyInstaller pour reference
SPEC_FILE="$SCRIPT_DIR/Lea.spec"
cat > "$SPEC_FILE" << 'PYINSTALLER_SPEC'
# -*- mode: python ; coding: utf-8 -*-
# Lea.spec — Configuration PyInstaller pour l'agent Lea
#
# Usage sur Windows :
# pip install pyinstaller
# pyinstaller Lea.spec
#
# Le .exe resultant sera dans dist/Lea.exe
import os
import sys
block_cipher = None
# Repertoire de travail (ou se trouve ce .spec)
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC())) if 'SPEC' in dir() else '.'
a = Analysis(
['run_agent_v1.py'],
pathex=['.'],
binaries=[],
datas=[
('agent_v1', 'agent_v1'),
('lea_ui', 'lea_ui'),
('config.txt', '.'),
('LISEZMOI.txt', '.'),
],
hiddenimports=[
# pynput backends Windows
'pynput.keyboard._win32',
'pynput.mouse._win32',
# pystray backend Windows
'pystray._win32',
# plyer notification Windows
'plyer.platforms.win',
'plyer.platforms.win.notification',
# pywin32
'win32api',
'win32con',
'win32gui',
'win32com',
'pythoncom',
# tkinter (stdlib, parfois manquant dans PyInstaller)
'tkinter',
'tkinter.simpledialog',
'tkinter.messagebox',
'tkinter.filedialog',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
# Exclure les modules lourds non necessaires cote client
'torch',
'torchvision',
'transformers',
'clip',
'open_clip',
'faiss',
'cv2', # opencv pas obligatoire (blur_sensitive a un fallback)
'numpy', # requis par PIL mais pas directement
'scipy',
'sklearn',
'matplotlib',
'pandas',
'tensorflow',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='Lea',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # --windowed : pas de console visible
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
# icon='assets/lea_icon.ico', # Decommenter quand l'icone sera creee
)
PYINSTALLER_SPEC
echo " Fichier Lea.spec genere dans : $SPEC_FILE"
echo ""
echo " Pour builder sur Windows :"
echo " 1. Copier le dossier Lea/ (apres build_package.sh) sur le PC Windows"
echo " 2. pip install pyinstaller"
echo " 3. cd Lea"
echo " 4. pyinstaller ../Lea.spec"
echo " 5. Le .exe sera dans dist/Lea.exe"
echo ""
# Generer aussi un script batch pour builder sur Windows
WIN_BUILD="$SCRIPT_DIR/build_exe_windows.bat"
cat > "$WIN_BUILD" << 'WIN_BATCH'
@echo off
chcp 65001 >nul 2>&1
title Build Lea.exe
echo ============================================================
echo Build Lea.exe (PyInstaller)
echo ============================================================
echo.
:: Verifier PyInstaller
pip show pyinstaller >nul 2>&1
if errorlevel 1 (
echo Installation de PyInstaller...
pip install pyinstaller
)
:: Builder
echo Build en cours (cela prend 2-5 minutes)...
echo.
pyinstaller --onefile --windowed ^
--name "Lea" ^
--add-data "agent_v1;agent_v1" ^
--add-data "lea_ui;lea_ui" ^
--add-data "config.txt;." ^
--add-data "LISEZMOI.txt;." ^
--hidden-import "pynput.keyboard._win32" ^
--hidden-import "pynput.mouse._win32" ^
--hidden-import "pystray._win32" ^
--hidden-import "plyer.platforms.win.notification" ^
--hidden-import "win32api" ^
--hidden-import "win32con" ^
--hidden-import "win32gui" ^
--hidden-import "tkinter" ^
--hidden-import "tkinter.simpledialog" ^
--hidden-import "tkinter.messagebox" ^
--exclude-module "torch" ^
--exclude-module "torchvision" ^
--exclude-module "transformers" ^
--exclude-module "clip" ^
--exclude-module "faiss" ^
--exclude-module "scipy" ^
--exclude-module "sklearn" ^
--exclude-module "matplotlib" ^
--exclude-module "pandas" ^
--exclude-module "tensorflow" ^
run_agent_v1.py
if errorlevel 1 (
echo.
echo ERREUR : Le build a echoue.
pause
exit /b 1
)
echo.
echo ============================================================
echo Build termine !
echo.
echo Lea.exe est dans le dossier dist\
echo Taille :
dir dist\Lea.exe | findstr "Lea.exe"
echo.
echo Pour deployer : copiez dist\Lea.exe + config.txt + LISEZMOI.txt
echo ============================================================
pause
WIN_BATCH
echo " Script Windows genere : $WIN_BUILD"
echo ""
echo "============================================================"

166
deploy/build_package.sh Executable file
View File

@@ -0,0 +1,166 @@
#!/bin/bash
# ============================================================
# build_package.sh — Assemble le package Lea pour deploiement Windows
#
# Produit : Lea_v<version>.zip (< 5 MB sans venv)
#
# Usage :
# ./deploy/build_package.sh # Package standard
# ./deploy/build_package.sh --clean # Nettoyer avant de builder
#
# Le zip contient tout ce qu'il faut pour un deploiement :
# - install.bat (premiere installation)
# - Lea.bat (lancement quotidien)
# - config.txt (parametres serveur)
# - LISEZMOI.txt (documentation utilisateur)
# - Code Python de l'agent
# ============================================================
set -euo pipefail
# Couleurs pour les messages
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Repertoire racine du projet
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Version (lue depuis config.py de l'agent)
VERSION=$(grep -oP 'AGENT_VERSION\s*=\s*"([^"]+)"' "$PROJECT_ROOT/agent_v0/agent_v1/config.py" | grep -oP '"[^"]+"' | tr -d '"' || echo "1.0.0")
# Dossier de sortie
BUILD_DIR="$SCRIPT_DIR/build"
PACKAGE_DIR="$BUILD_DIR/Lea"
OUTPUT_ZIP="$SCRIPT_DIR/Lea_v${VERSION}.zip"
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} Build du package Lea v${VERSION}${NC}"
echo -e "${GREEN}============================================================${NC}"
echo ""
# ---------------------------------------------------------------
# Option --clean
# ---------------------------------------------------------------
if [[ "${1:-}" == "--clean" ]]; then
echo -e "${YELLOW}Nettoyage du build precedent...${NC}"
rm -rf "$BUILD_DIR"
rm -f "$SCRIPT_DIR"/Lea_v*.zip
echo " OK"
echo ""
fi
# ---------------------------------------------------------------
# 1. Creer le dossier de build
# ---------------------------------------------------------------
echo "[1/7] Preparation du dossier de build..."
rm -rf "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR"
echo " $PACKAGE_DIR cree"
echo ""
# ---------------------------------------------------------------
# 2. Copier les fichiers de deploiement (bat, config, readme)
# ---------------------------------------------------------------
echo "[2/7] Copie des fichiers de deploiement..."
cp "$SCRIPT_DIR/lea_package/Lea.bat" "$PACKAGE_DIR/"
cp "$SCRIPT_DIR/lea_package/install.bat" "$PACKAGE_DIR/"
cp "$SCRIPT_DIR/lea_package/config.txt" "$PACKAGE_DIR/"
cp "$SCRIPT_DIR/lea_package/LISEZMOI.txt" "$PACKAGE_DIR/"
cp "$SCRIPT_DIR/lea_package/requirements_agent.txt" "$PACKAGE_DIR/"
echo " 5 fichiers copies"
echo ""
# ---------------------------------------------------------------
# 3. Copier le point d'entree
# ---------------------------------------------------------------
echo "[3/7] Copie du point d'entree..."
cp "$PROJECT_ROOT/agent_v0/run_agent_v1.py" "$PACKAGE_DIR/"
echo " run_agent_v1.py copie"
echo ""
# ---------------------------------------------------------------
# 4. Copier le package agent_v1 (code Python)
# ---------------------------------------------------------------
echo "[4/7] Copie du code agent_v1..."
# Copier tout le dossier en excluant les fichiers inutiles
rsync -a \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.pytest_cache' \
--exclude='sessions/' \
--exclude='logs/*.log' \
--exclude='.hypothesis' \
"$PROJECT_ROOT/agent_v0/agent_v1/" \
"$PACKAGE_DIR/agent_v1/"
# Creer les dossiers necessaires (vides)
mkdir -p "$PACKAGE_DIR/agent_v1/sessions"
mkdir -p "$PACKAGE_DIR/agent_v1/logs"
echo " agent_v1/ copie ($(find "$PACKAGE_DIR/agent_v1" -name "*.py" | wc -l) fichiers Python)"
echo ""
# ---------------------------------------------------------------
# 5. Copier le module lea_ui (client serveur pour le chat)
# ---------------------------------------------------------------
echo "[5/7] Copie du module lea_ui..."
mkdir -p "$PACKAGE_DIR/lea_ui"
cp "$PROJECT_ROOT/agent_v0/lea_ui/"*.py "$PACKAGE_DIR/lea_ui/"
echo " lea_ui/ copie ($(ls "$PACKAGE_DIR/lea_ui/"*.py | wc -l) fichiers)"
echo ""
# ---------------------------------------------------------------
# 6. Copier le __init__.py racine (pour les imports relatifs)
# ---------------------------------------------------------------
echo "[6/7] Configuration des packages Python..."
# Le __init__.py au niveau racine du package (agent_v0 level)
# n'est PAS necessaire car run_agent_v1.py est au meme niveau que agent_v1/
# Mais lea_ui est importe avec un import relatif depuis agent_v1/main.py
# via `from ..lea_ui.server_client import LeaServerClient`
# Cet import fonctionne uniquement si l'arborescence est un package.
# Or, dans le deploiement, lea_ui est au meme niveau que agent_v1,
# et le fallback dans main.py fait `from lea_ui.server_client import LeaServerClient`
# qui fonctionne car run_agent_v1.py ajoute current_dir au sys.path.
echo " Structure d'imports verifiee"
echo ""
# ---------------------------------------------------------------
# 7. Creer le zip
# ---------------------------------------------------------------
echo "[7/7] Creation du zip..."
cd "$BUILD_DIR"
rm -f "$OUTPUT_ZIP"
zip -r "$OUTPUT_ZIP" "Lea/" -x "Lea/.venv/*" "Lea/__pycache__/*" "Lea/*/__pycache__/*"
cd "$PROJECT_ROOT"
# Taille du zip
ZIP_SIZE=$(du -h "$OUTPUT_ZIP" | cut -f1)
echo ""
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} Build termine !${NC}"
echo -e "${GREEN}============================================================${NC}"
echo ""
echo " Package : $OUTPUT_ZIP"
echo " Taille : $ZIP_SIZE"
echo " Version : $VERSION"
echo ""
echo " Contenu du package :"
echo " --------------------"
echo ""
# Lister le contenu du zip (structure lisible)
unzip -l "$OUTPUT_ZIP" | tail -n +4 | head -n -2 | awk '{print " " $4}'
echo ""
echo -e "${YELLOW} Deploiement :${NC}"
echo " 1. Copier le zip sur le PC Windows du collaborateur"
echo " 2. Dezipper dans un dossier (ex: C:\\Lea)"
echo " 3. Editer config.txt si besoin (adresse serveur, token)"
echo " 4. Double-cliquer install.bat (une seule fois)"
echo " 5. Double-cliquer Lea.bat pour lancer"
echo ""
echo -e "${GREEN}============================================================${NC}"

View File

@@ -0,0 +1,85 @@
============================================================
Lea - Votre assistante intelligente
============================================================
Bienvenue ! Lea est une assistante qui apprend vos taches
repetitives sur l'ordinateur et peut les refaire a votre place.
PREMIERE INSTALLATION
---------------------
1. Double-cliquez sur "install.bat"
(cela prend 2-3 minutes, une seule fois)
2. Si une fenetre vous demande d'autoriser Python,
cliquez "Oui" ou "Autoriser".
3. A la fin, vous verrez "Installation terminee !"
LANCER LEA
----------
Double-cliquez sur "Lea.bat"
Lea apparait en bas a droite de votre ecran, dans la barre
des taches (petite icone ronde, a cote de l'horloge).
Clic droit sur l'icone pour ouvrir le menu :
- "Apprenez-moi une tache" : Lea observe ce que vous faites
et memorise les etapes.
- "Mes taches" : Liste des taches que Lea a apprises.
Cliquez sur une tache pour que Lea la refasse.
- "Discuter avec Lea" : Ouvre une fenetre de discussion
pour poser des questions ou donner des instructions.
- "ARRET D'URGENCE" : Arrete immediatement tout ce que
Lea est en train de faire.
- "Quitter Lea" : Ferme le programme.
CONFIGURATION
-------------
Si vous devez modifier l'adresse du serveur, ouvrez le fichier
"config.txt" avec le Bloc-notes et changez les valeurs.
Ne modifiez rien d'autre sans l'accord de votre administrateur.
EN CAS DE PROBLEME
-------------------
- "Python n'est pas installe" : Demandez a votre
service informatique d'installer Python 3.10
depuis https://python.org
- Lea ne demarre pas : Relancez "install.bat" puis
relancez "Lea.bat"
- Lea est deconnectee : Verifiez votre connexion
internet/reseau. Le serveur est peut-etre en
maintenance.
- En cas de doute, contactez votre administrateur.
INFORMATIONS
------------
Lea est un systeme base sur l'intelligence artificielle.
Quand Lea enregistre vos actions, elle capture votre ecran,
vos clics et vos frappes clavier. Les donnees sensibles
(mots de passe, informations medicales) sont automatiquement
floutees avant envoi.
Vous pouvez arreter l'enregistrement ou le replay a tout
moment via le menu ou le bouton "ARRET D'URGENCE".
============================================================

View File

@@ -0,0 +1,54 @@
@echo off
chcp 65001 >nul 2>&1
title Lea - Assistante IA
:: ---------------------------------------------------------------
:: Se placer dans le dossier du script (important pour les chemins)
:: ---------------------------------------------------------------
cd /d "%~dp0"
:: ---------------------------------------------------------------
:: Verifier que l'installation a ete faite
:: ---------------------------------------------------------------
if not exist ".venv\Scripts\python.exe" (
echo.
echo Lea n'est pas encore installee.
echo Lancez d'abord "install.bat" puis revenez ici.
echo.
pause
exit /b 1
)
:: ---------------------------------------------------------------
:: Charger la configuration depuis config.txt
:: Les lignes commencant par # sont ignorees (commentaires)
:: Format attendu : NOM_VARIABLE=valeur
:: ---------------------------------------------------------------
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"
)
) else (
echo ATTENTION : config.txt introuvable, utilisation des valeurs par defaut.
)
:: ---------------------------------------------------------------
:: Lancer Lea
:: ---------------------------------------------------------------
echo.
echo Demarrage de Lea...
echo (Lea apparait dans la barre des taches, en bas a droite)
echo.
echo Pour arreter Lea : clic droit sur l'icone ^> "Quitter Lea"
echo Vous pouvez fermer cette fenetre.
echo.
.venv\Scripts\pythonw.exe run_agent_v1.py
if errorlevel 1 (
echo.
echo Lea a rencontre un probleme au demarrage.
echo Tentative avec affichage des erreurs...
echo.
.venv\Scripts\python.exe run_agent_v1.py
pause
)

View File

@@ -0,0 +1,31 @@
# ============================================================
# Configuration Lea
# ============================================================
#
# Ce fichier contient les parametres de connexion au serveur.
# Modifiez uniquement les valeurs apres le signe =
# Ne touchez pas aux noms des parametres (a gauche du =).
#
# Les lignes commencant par # sont des commentaires (ignorees).
#
# ============================================================
# Adresse du serveur Lea (URL complete avec /api/v1)
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
# Cle d'authentification (fournie par l'administrateur)
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
# Nom du serveur (sans https://, sans /api/v1)
RPA_SERVER_HOST=lea.labs.laurinebazin.design
# ============================================================
# Parametres avances (ne pas modifier sauf indication)
# ============================================================
# Flouter les zones de texte dans les captures (securite donnees)
# Mettre false uniquement pour le developpement/tests
RPA_BLUR_SENSITIVE=true
# Duree de conservation des logs en jours (minimum 180 pour conformite)
RPA_LOG_RETENTION_DAYS=180

View File

@@ -0,0 +1,127 @@
@echo off
chcp 65001 >nul 2>&1
title Lea - Installation
echo.
echo ============================================================
echo Lea - Installation
echo ============================================================
echo.
echo Cette installation prend 2-3 minutes.
echo Ne fermez pas cette fenetre.
echo.
echo ============================================================
echo.
:: ---------------------------------------------------------------
:: 0. Verifier que Python est installe
:: ---------------------------------------------------------------
echo [1/5] Verification de Python...
python --version >nul 2>&1
if errorlevel 1 (
echo.
echo ERREUR : Python n'est pas installe sur cet ordinateur.
echo.
echo Pour installer Python :
echo 1. Allez sur https://python.org
echo 2. Cliquez "Download Python 3.12"
echo 3. IMPORTANT : cochez "Add Python to PATH" pendant l'installation
echo 4. Relancez install.bat apres l'installation de Python
echo.
pause
exit /b 1
)
:: Afficher la version Python detectee
for /f "tokens=*" %%v in ('python --version 2^>^&1') do echo %%v detecte - OK
echo.
:: ---------------------------------------------------------------
:: 1. Creer l'environnement virtuel
:: ---------------------------------------------------------------
if not exist ".venv" (
echo [2/5] Creation de l'environnement isole...
python -m venv .venv
if errorlevel 1 (
echo ERREUR : Impossible de creer l'environnement virtuel.
echo Verifiez que Python est correctement installe.
pause
exit /b 1
)
echo Environnement cree - OK
) else (
echo [2/5] Environnement existant detecte - OK
)
echo.
:: ---------------------------------------------------------------
:: 2. Activer l'environnement
:: ---------------------------------------------------------------
echo [3/5] Activation de l'environnement...
call .venv\Scripts\activate.bat
echo Active - OK
echo.
:: ---------------------------------------------------------------
:: 3. Installer les dependances
:: ---------------------------------------------------------------
echo [4/5] Installation des composants (cela peut prendre 1-2 min)...
python -m pip install --upgrade pip --quiet 2>nul
pip install -r requirements_agent.txt --quiet 2>nul
if errorlevel 1 (
echo.
echo ATTENTION : Certains composants n'ont pas pu etre installes.
echo Nouvelle tentative avec affichage des details...
echo.
pip install -r requirements_agent.txt
if errorlevel 1 (
echo.
echo ERREUR : L'installation a echoue.
echo Verifiez votre connexion internet et reessayez.
pause
exit /b 1
)
)
echo Composants installes - OK
echo.
:: ---------------------------------------------------------------
:: 4. Post-installation Windows (pywin32)
:: ---------------------------------------------------------------
echo [5/5] Configuration Windows...
python -c "import win32api" >nul 2>&1
if errorlevel 1 (
python .venv\Scripts\pywin32_postinstall.py -install >nul 2>&1
)
echo Configuration terminee - OK
echo.
:: ---------------------------------------------------------------
:: 5. Verification finale
:: ---------------------------------------------------------------
echo ============================================================
echo Verification finale...
echo ============================================================
echo.
python -c "import mss; import pynput; import pystray; import plyer; import requests; import PIL; print(' Tous les composants sont OK !')"
if errorlevel 1 (
echo.
echo ATTENTION : Certains composants manquent.
echo Essayez de relancer install.bat.
echo Si le probleme persiste, contactez votre administrateur.
pause
exit /b 1
)
echo.
echo ============================================================
echo.
echo Installation terminee !
echo.
echo Pour lancer Lea, double-cliquez sur "Lea.bat"
echo.
echo ============================================================
echo.
pause

View File

@@ -0,0 +1,13 @@
# Dependances Lea Agent (client leger)
# Pas de CLIP, PyTorch, ou modele lourd - tout le calcul est sur le serveur
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
psutil>=5.9.0 # Monitoring CPU/RAM
pystray>=0.19.5 # Icone systray
plyer>=2.1.0 # Notifications toast natives
# Windows specifique
pywin32>=306 ; sys_platform == 'win32'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -18,4 +18,5 @@ vwb-backend|5002|visual_workflow_builder/backend/app.py|required
monitoring|5003|monitoring_server.py|optional monitoring|5003|monitoring_server.py|optional
agent-chat|5004|agent_chat/app.py|optional agent-chat|5004|agent_chat/app.py|optional
streaming|5005|agent_v0/server_v1/api_stream.py|optional streaming|5005|agent_v0/server_v1/api_stream.py|optional
worker|5099|agent_v0/server_v1/run_worker.py|optional
vwb-frontend|3002|cd visual_workflow_builder/frontend_v4 && npm run dev|required vwb-frontend|3002|cd visual_workflow_builder/frontend_v4 && npm run dev|required

22
svc.sh
View File

@@ -54,6 +54,7 @@ declare -A PORTS=(
[monitoring]=5003 [monitoring]=5003
[agent-chat]=5004 [agent-chat]=5004
[streaming]=5005 [streaming]=5005
[worker]=5099
[vwb-frontend]=3002 [vwb-frontend]=3002
) )
@@ -63,14 +64,15 @@ declare -A SYSTEMD_UNITS=(
[vwb-backend]="rpa-vwb-backend.service" [vwb-backend]="rpa-vwb-backend.service"
[agent-chat]="rpa-agent-chat.service" [agent-chat]="rpa-agent-chat.service"
[streaming]="rpa-streaming.service" [streaming]="rpa-streaming.service"
[worker]="rpa-worker.service"
[vwb-frontend]="rpa-vwb-frontend.service" [vwb-frontend]="rpa-vwb-frontend.service"
) )
# Services gérés par systemd (ceux qui ont un .service) # Services gérés par systemd (ceux qui ont un .service)
SYSTEMD_SERVICES="streaming agent-chat dashboard vwb-backend vwb-frontend" SYSTEMD_SERVICES="streaming worker agent-chat dashboard vwb-backend vwb-frontend"
# Tous les services connus # Tous les services connus
ALL_SERVICES="api dashboard vwb-backend monitoring agent-chat streaming vwb-frontend" ALL_SERVICES="api dashboard vwb-backend monitoring agent-chat streaming worker vwb-frontend"
declare -A COMMANDS=( declare -A COMMANDS=(
[api]="$VENV_DIR/bin/python3 server/api_upload.py" [api]="$VENV_DIR/bin/python3 server/api_upload.py"
@@ -79,6 +81,7 @@ declare -A COMMANDS=(
[monitoring]="$VENV_DIR/bin/python3 monitoring_server.py" [monitoring]="$VENV_DIR/bin/python3 monitoring_server.py"
[agent-chat]="$VENV_DIR/bin/python3 -m agent_chat.app" [agent-chat]="$VENV_DIR/bin/python3 -m agent_chat.app"
[streaming]="$VENV_DIR/bin/python3 -m agent_v0.server_v1.api_stream" [streaming]="$VENV_DIR/bin/python3 -m agent_v0.server_v1.api_stream"
[worker]="$VENV_DIR/bin/python3 -m agent_v0.server_v1.run_worker"
[vwb-frontend]="cd $SCRIPT_DIR/visual_workflow_builder/frontend_v4 && npm run dev" [vwb-frontend]="cd $SCRIPT_DIR/visual_workflow_builder/frontend_v4 && npm run dev"
) )
@@ -86,8 +89,8 @@ declare -A COMMANDS=(
declare -A SVC_GROUPS=( declare -A SVC_GROUPS=(
[vwb]="vwb-backend vwb-frontend" [vwb]="vwb-backend vwb-frontend"
[all]="api dashboard vwb-backend vwb-frontend" [all]="api dashboard vwb-backend vwb-frontend"
[full]="api dashboard vwb-backend vwb-frontend monitoring agent-chat streaming" [full]="api dashboard vwb-backend vwb-frontend monitoring agent-chat streaming worker"
[boot]="streaming agent-chat dashboard vwb-backend vwb-frontend" [boot]="streaming worker agent-chat dashboard vwb-backend vwb-frontend"
) )
# ============================================================================= # =============================================================================
@@ -350,7 +353,7 @@ do_install() {
# Vérifier que les fichiers existent # Vérifier que les fichiers existent
local missing=false local missing=false
for unit in rpa-streaming.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service rpa-vision.target; do for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service rpa-vision.target; do
if [ -f "$SYSTEMD_DIR/$unit" ]; then if [ -f "$SYSTEMD_DIR/$unit" ]; then
echo -e " ${GREEN}OK${NC} $unit" echo -e " ${GREEN}OK${NC} $unit"
else else
@@ -394,7 +397,7 @@ do_enable() {
echo -e "${CYAN}${BOLD}Activation du demarrage automatique au boot...${NC}" echo -e "${CYAN}${BOLD}Activation du demarrage automatique au boot...${NC}"
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable rpa-vision.target systemctl --user enable rpa-vision.target
for unit in rpa-streaming.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service; do for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service; do
systemctl --user enable "$unit" 2>/dev/null systemctl --user enable "$unit" 2>/dev/null
echo -e " ${GREEN}OK${NC} $unit" echo -e " ${GREEN}OK${NC} $unit"
done done
@@ -405,7 +408,7 @@ do_enable() {
do_disable() { do_disable() {
echo -e "${YELLOW}${BOLD}Desactivation du demarrage automatique...${NC}" echo -e "${YELLOW}${BOLD}Desactivation du demarrage automatique...${NC}"
systemctl --user disable rpa-vision.target 2>/dev/null || true systemctl --user disable rpa-vision.target 2>/dev/null || true
for unit in rpa-streaming.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service; do for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service; do
systemctl --user disable "$unit" 2>/dev/null || true systemctl --user disable "$unit" 2>/dev/null || true
echo -e " ${GREEN}OK${NC} $unit" echo -e " ${GREEN}OK${NC} $unit"
done done
@@ -429,7 +432,8 @@ show_help() {
echo " disable Desactiver le demarrage auto au boot" echo " disable Desactiver le demarrage auto au boot"
echo "" echo ""
echo -e "${BOLD}Services:${NC}" echo -e "${BOLD}Services:${NC}"
echo " streaming Streaming Server GPU (port 5005)" echo " streaming Streaming Server HTTP (port 5005)"
echo " worker VLM Worker GPU (process séparé)"
echo " agent-chat Agent Chat (port 5004)" echo " agent-chat Agent Chat (port 5004)"
echo " dashboard Web Dashboard (port 5001)" echo " dashboard Web Dashboard (port 5001)"
echo " vwb-backend VWB Backend Flask (port 5002)" echo " vwb-backend VWB Backend Flask (port 5002)"
@@ -438,7 +442,7 @@ show_help() {
echo " monitoring Monitoring (port 5003) [legacy uniquement]" echo " monitoring Monitoring (port 5003) [legacy uniquement]"
echo "" echo ""
echo -e "${BOLD}Groupes:${NC}" echo -e "${BOLD}Groupes:${NC}"
echo " boot Services systemd (streaming, chat, dashboard, vwb)" echo " boot Services systemd (streaming, worker, chat, dashboard, vwb)"
echo " vwb VWB backend + frontend" echo " vwb VWB backend + frontend"
echo " all Core (api, dashboard, vwb)" echo " all Core (api, dashboard, vwb)"
echo " full Tous les services" echo " full Tous les services"

View File

@@ -220,7 +220,7 @@ class TestStreamWorker:
event_file = session_dir / "live_events.jsonl" event_file = session_dir / "live_events.jsonl"
event_file.write_text( event_file.write_text(
json.dumps({"type": "click", "timestamp": 100}) + "\n" json.dumps({"type": "click", "timestamp": 100}) + "\n"
+ json.dumps({"type": "key_press", "timestamp": 200}) + "\n" + json.dumps({"type": "key_press", "keys": ["enter"], "timestamp": 200}) + "\n"
) )
# Simuler un tour de polling # Simuler un tour de polling

576
tests/unit/test_auth.py Normal file
View File

@@ -0,0 +1,576 @@
"""
Tests du module d'authentification automatique (core/auth).
Couvre :
- TOTPGenerator : génération, vérification, vecteurs de test RFC 6238
- CredentialVault : CRUD, chiffrement, persistance
- AuthHandler : détection d'écrans d'auth, génération d'actions
"""
import json
import os
import tempfile
import time
import pytest
from core.auth.credential_vault import CredentialVault, _HAS_FERNET
from core.auth.totp_generator import TOTPGenerator
from core.auth.auth_handler import AuthHandler, AuthRequest
# =========================================================================
# Tests TOTP
# =========================================================================
class TestTOTPGenerator:
"""Tests du générateur TOTP RFC 6238."""
def test_generate_returns_6_digits(self):
"""Le code généré fait exactement 6 chiffres."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP")
code = totp.generate()
assert len(code) == 6
assert code.isdigit()
def test_generate_deterministic(self):
"""Le même timestamp donne le même code."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP")
ts = 1700000000.0
code1 = totp.generate(timestamp=ts)
code2 = totp.generate(timestamp=ts)
assert code1 == code2
def test_verify_current_code(self):
"""Le code généré est validé par verify()."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP")
ts = time.time()
code = totp.generate(timestamp=ts)
assert totp.verify(code, timestamp=ts)
def test_verify_rejects_wrong_code(self):
"""Un code incorrect est rejeté."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP")
# Utiliser un timestamp suffisamment grand pour éviter les problèmes
# avec window=-1 (counter négatif)
assert not totp.verify("000000", timestamp=1700000000.0)
def test_verify_with_window(self):
"""La fenêtre de tolérance accepte les codes adjacents."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP", interval=30)
ts = 1700000000.0
# Code de l'intervalle précédent
prev_code = totp.generate(timestamp=ts - 30)
assert totp.verify(prev_code, timestamp=ts, window=1)
# Code de l'intervalle suivant
next_code = totp.generate(timestamp=ts + 30)
assert totp.verify(next_code, timestamp=ts, window=1)
def test_verify_window_zero_strict(self):
"""Window=0 n'accepte que le code exact de l'intervalle courant."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP", interval=30)
ts = 1700000000.0
code = totp.generate(timestamp=ts)
assert totp.verify(code, timestamp=ts, window=0)
prev_code = totp.generate(timestamp=ts - 30)
assert not totp.verify(prev_code, timestamp=ts, window=0)
def test_time_remaining_in_range(self):
"""time_remaining() retourne entre 1 et interval."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP", interval=30)
remaining = totp.time_remaining()
assert 1 <= remaining <= 30
def test_8_digits(self):
"""Support des codes à 8 chiffres."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP", digits=8)
code = totp.generate()
assert len(code) == 8
assert code.isdigit()
def test_rfc6238_sha1_vector(self):
"""Vecteur de test RFC 6238 pour SHA1.
Secret de test : "12345678901234567890" (ASCII)
En base32 : "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
Timestamp : 59 → T = 59 // 30 = 1 → code attendu 287082
"""
# Le secret ASCII "12345678901234567890" encodé en base32
secret_b32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
totp = TOTPGenerator(secret_b32, digits=8, interval=30, algorithm="SHA1")
code = totp.generate(timestamp=59)
assert code == "94287082"
def test_rfc6238_sha1_vector_t1111111109(self):
"""Vecteur de test RFC 6238 — T=1111111109."""
secret_b32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
totp = TOTPGenerator(secret_b32, digits=8, interval=30, algorithm="SHA1")
code = totp.generate(timestamp=1111111109)
assert code == "07081804"
def test_rfc6238_sha256_vector(self):
"""Vecteur de test RFC 6238 pour SHA256.
Secret 32 bytes : "12345678901234567890123456789012"
En base32 : "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA"
Timestamp : 59 → code attendu 46119246
"""
secret_b32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA"
totp = TOTPGenerator(secret_b32, digits=8, interval=30, algorithm="SHA256")
code = totp.generate(timestamp=59)
assert code == "46119246"
def test_invalid_secret_raises(self):
"""Un secret invalide lève ValueError."""
with pytest.raises(ValueError, match="base32 invalide"):
TOTPGenerator("!!! not base32 !!!")
def test_invalid_algorithm_raises(self):
"""Un algorithme inconnu lève ValueError."""
with pytest.raises(ValueError, match="non supporté"):
TOTPGenerator("JBSWY3DPEHPK3PXP", algorithm="MD5")
def test_secret_with_spaces(self):
"""Les espaces dans le secret sont tolérés."""
totp1 = TOTPGenerator("JBSWY3DPEHPK3PXP")
totp2 = TOTPGenerator("JBSW Y3DP EHPK 3PXP")
ts = 1700000000.0
assert totp1.generate(timestamp=ts) == totp2.generate(timestamp=ts)
def test_zero_padded_code(self):
"""Les codes courts sont zero-padded (ex: 003271 et non 3271)."""
totp = TOTPGenerator("JBSWY3DPEHPK3PXP")
# Tester beaucoup de timestamps pour trouver un code qui commence par 0
for ts in range(1700000000, 1700001000, 30):
code = totp.generate(timestamp=float(ts))
assert len(code) == 6, f"Code {code!r} n'a pas 6 chiffres pour ts={ts}"
# =========================================================================
# Tests CredentialVault
# =========================================================================
class TestCredentialVault:
"""Tests du coffre-fort chiffré."""
def test_create_add_get(self):
"""Créer un vault, ajouter un credential, le récupérer."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path) # Supprimer pour que le vault se crée
vault = CredentialVault(vault_path, "test_password")
vault.add_credential("TestApp", "login", {
"username": "user1",
"password": "pass1",
})
cred = vault.get_credential("TestApp", "login")
assert cred is not None
assert cred["username"] == "user1"
assert cred["password"] == "pass1"
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_save_and_reload(self):
"""Sauvegarder et recharger un vault préserve les données."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "master123")
vault.add_credential("MyApp", "login", {
"username": "admin",
"password": "secret",
})
vault.add_credential("MyApp", "totp_seed", {
"secret": "JBSWY3DPEHPK3PXP",
"digits": 6,
"interval": 30,
"algorithm": "SHA1",
})
vault.save()
# Recharger
vault2 = CredentialVault(vault_path, "master123")
assert vault2.list_apps() == ["MyApp"]
login = vault2.get_credential("MyApp", "login")
assert login["username"] == "admin"
totp = vault2.get_credential("MyApp", "totp_seed")
assert totp["secret"] == "JBSWY3DPEHPK3PXP"
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_remove_credential(self):
"""Supprimer un credential fonctionne."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "pw")
vault.add_credential("App1", "login", {"username": "u", "password": "p"})
assert vault.remove_credential("App1", "login") is True
assert vault.get_credential("App1", "login") is None
assert vault.list_apps() == []
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_remove_nonexistent(self):
"""Supprimer un credential inexistant retourne False."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "pw")
assert vault.remove_credential("NopApp", "login") is False
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_list_apps_sorted(self):
"""list_apps() retourne les apps triées."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "pw")
vault.add_credential("Zebra", "login", {"username": "z", "password": "z"})
vault.add_credential("Alpha", "login", {"username": "a", "password": "a"})
vault.add_credential("Middle", "login", {"username": "m", "password": "m"})
assert vault.list_apps() == ["Alpha", "Middle", "Zebra"]
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_invalid_credential_type(self):
"""Un type de credential invalide lève ValueError."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "pw")
with pytest.raises(ValueError, match="invalide"):
vault.add_credential("App1", "invalid_type", {})
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_encryption_on_disk(self):
"""Le fichier vault sur disque ne contient pas de texte en clair."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "strong_password_42")
vault.add_credential("SecretApp", "login", {
"username": "robot_lea",
"password": "super_secret_password_xyz",
})
vault.save()
# Lire le fichier brut
raw_bytes = open(vault_path, "rb").read()
raw_str = raw_bytes.decode("latin-1") # Pour chercher du texte ASCII
# Les données sensibles ne doivent PAS apparaître en clair
assert "robot_lea" not in raw_str
assert "super_secret_password_xyz" not in raw_str
assert "SecretApp" not in raw_str
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_wrong_password_raises(self):
"""Un mauvais mot de passe empêche le déchiffrement."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "correct_password")
vault.add_credential("App", "login", {"username": "u", "password": "p"})
vault.save()
# Tenter de charger avec un mauvais mot de passe
with pytest.raises(ValueError, match="[Mm]ot de passe|corrompu"):
CredentialVault(vault_path, "wrong_password")
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
def test_multiple_credential_types_per_app(self):
"""Une app peut avoir plusieurs types de credentials."""
with tempfile.NamedTemporaryFile(suffix=".enc", delete=False) as f:
vault_path = f.name
try:
os.unlink(vault_path)
vault = CredentialVault(vault_path, "pw")
vault.add_credential("DPI", "login", {
"username": "lea", "password": "p"
})
vault.add_credential("DPI", "totp_seed", {
"secret": "JBSWY3DPEHPK3PXP"
})
assert vault.list_credential_types("DPI") == ["login", "totp_seed"]
assert vault.get_credential("DPI", "login")["username"] == "lea"
assert vault.get_credential("DPI", "totp_seed")["secret"] == "JBSWY3DPEHPK3PXP"
finally:
if os.path.exists(vault_path):
os.unlink(vault_path)
# =========================================================================
# Tests AuthHandler
# =========================================================================
class TestAuthHandler:
"""Tests du gestionnaire d'authentification."""
@pytest.fixture
def vault_with_creds(self, tmp_path):
"""Vault avec des credentials de test."""
vault_path = str(tmp_path / "test_vault.enc")
vault = CredentialVault(vault_path, "test_pw")
vault.add_credential("DPI_Crossway", "login", {
"username": "robot_lea",
"password": "secret123",
"domain": "HOPITAL",
})
vault.add_credential("DPI_Crossway", "totp_seed", {
"secret": "JBSWY3DPEHPK3PXP",
"digits": 6,
"interval": 30,
"algorithm": "SHA1",
})
vault.add_credential("Outlook", "login", {
"username": "lea@hopital.fr",
"password": "outlook_pass",
})
return vault
@pytest.fixture
def handler(self, vault_with_creds):
return AuthHandler(vault_with_creds)
def test_detect_login_screen(self, handler):
"""Détecter un écran de login classique."""
screen_state = {
"perception": {
"detected_text": [
"Bienvenue sur DPI Crossway",
"Identifiant",
"Mot de passe",
"Se connecter",
],
},
"ui_elements": [
{"type": "text_input", "role": "text", "label": "Identifiant", "center": [500, 300], "element_id": "e1", "tags": []},
{"type": "text_input", "role": "password", "label": "Mot de passe", "center": [500, 350], "element_id": "e2", "tags": []},
{"type": "button", "role": "primary_action", "label": "Se connecter", "center": [500, 420], "element_id": "e3", "tags": []},
],
"window": {"app_name": "DPI_Crossway", "window_title": "DPI Crossway - Connexion"},
}
auth_req = handler.detect_auth_screen(screen_state)
assert auth_req is not None
assert auth_req.auth_type == "login"
assert auth_req.app_name == "DPI_Crossway"
assert auth_req.confidence >= 0.6 # Plusieurs signaux
def test_detect_totp_screen(self, handler):
"""Détecter un écran 2FA/TOTP (sans éléments de login)."""
screen_state = {
"perception": {
"detected_text": [
"Entrez votre code 2FA",
"Code à 6 chiffres",
],
},
"ui_elements": [
{"type": "text_input", "role": "text", "label": "Code OTP", "center": [500, 350], "element_id": "e1", "tags": []},
{"type": "button", "role": "primary_action", "label": "Confirmer", "center": [500, 420], "element_id": "e2", "tags": []},
],
"window": {"app_name": "DPI_Crossway"},
}
auth_req = handler.detect_auth_screen(screen_state)
assert auth_req is not None
assert auth_req.auth_type == "totp"
assert auth_req.confidence >= 0.3
def test_detect_login_and_totp(self, handler):
"""Détecter un écran combiné login + TOTP."""
screen_state = {
"perception": {
"detected_text": [
"Connexion sécurisée",
"Identifiant",
"Mot de passe",
"Code OTP",
],
},
"ui_elements": [
{"type": "text_input", "role": "text", "label": "Identifiant", "center": [500, 300], "element_id": "e1", "tags": []},
{"type": "text_input", "role": "password", "label": "Mot de passe", "center": [500, 350], "element_id": "e2", "tags": []},
{"type": "text_input", "role": "text", "label": "Code OTP", "center": [500, 400], "element_id": "e3", "tags": []},
{"type": "button", "role": "primary_action", "label": "Valider", "center": [500, 450], "element_id": "e4", "tags": []},
],
"window": {"app_name": "DPI_Crossway"},
}
auth_req = handler.detect_auth_screen(screen_state)
assert auth_req is not None
assert auth_req.auth_type == "login_and_totp"
assert auth_req.confidence >= 0.85 # Beaucoup de signaux
def test_no_auth_on_normal_screen(self, handler):
"""Un écran normal ne déclenche pas de détection."""
screen_state = {
"perception": {
"detected_text": ["Patient: Jean Dupont", "Dossier médical", "Résultats"],
},
"ui_elements": [
{"type": "button", "role": "navigation", "label": "Suivant", "center": [500, 500], "element_id": "e1", "tags": []},
],
"window": {"app_name": "DPI_Crossway"},
}
auth_req = handler.detect_auth_screen(screen_state)
assert auth_req is None
def test_get_auth_actions_login(self, handler):
"""Générer les actions pour un login classique."""
auth_req = AuthRequest(
auth_type="login",
app_name="DPI_Crossway",
detected_fields={
"username_field": {"type": "text_input", "label": "Identifiant", "center": [500, 300], "element_id": "e1"},
"password_field": {"type": "text_input", "label": "Mot de passe", "center": [500, 350], "element_id": "e2"},
"submit_button": {"type": "button", "label": "Se connecter", "center": [500, 420], "element_id": "e3"},
},
confidence=0.85,
)
actions = handler.get_auth_actions(auth_req)
assert len(actions) > 0
# Vérifier la séquence : click username, type username, click password, type password, click submit, wait
action_types = [(a["type"], a.get("text", "")) for a in actions]
# Il doit y avoir des clics et des saisies
has_click = any(a["type"] == "click" for a in actions)
has_type = any(a["type"] == "type_text" for a in actions)
has_wait = any(a["type"] == "wait" for a in actions)
assert has_click
assert has_type
assert has_wait
# Vérifier que le username et password sont ceux du vault
typed_texts = [a["text"] for a in actions if a["type"] == "type_text"]
assert "robot_lea" in typed_texts
assert "secret123" in typed_texts
# Toutes les actions ont le flag _auth_action
for action in actions:
assert action.get("_auth_action") is True
def test_get_auth_actions_totp(self, handler):
"""Générer les actions pour une auth TOTP."""
auth_req = AuthRequest(
auth_type="totp",
app_name="DPI_Crossway",
detected_fields={
"otp_field": {"type": "text_input", "label": "Code", "center": [500, 350], "element_id": "e1"},
"submit_button": {"type": "button", "label": "Valider", "center": [500, 420], "element_id": "e2"},
},
confidence=0.85,
)
actions = handler.get_auth_actions(auth_req)
assert len(actions) > 0
# Vérifier qu'un code TOTP est tapé (6 chiffres)
typed_texts = [a["text"] for a in actions if a["type"] == "type_text"]
assert len(typed_texts) >= 1
totp_code = typed_texts[0]
assert len(totp_code) == 6
assert totp_code.isdigit()
def test_get_auth_actions_login_and_totp(self, handler):
"""Générer les actions pour login + TOTP combiné."""
auth_req = AuthRequest(
auth_type="login_and_totp",
app_name="DPI_Crossway",
detected_fields={
"username_field": {"type": "text_input", "label": "Identifiant", "center": [500, 300], "element_id": "e1"},
"password_field": {"type": "text_input", "label": "Mot de passe", "center": [500, 350], "element_id": "e2"},
"otp_field": {"type": "text_input", "label": "Code OTP", "center": [500, 400], "element_id": "e3"},
"submit_button": {"type": "button", "label": "Valider", "center": [500, 450], "element_id": "e4"},
},
confidence=0.95,
)
actions = handler.get_auth_actions(auth_req)
assert len(actions) > 0
typed_texts = [a["text"] for a in actions if a["type"] == "type_text"]
# username + password + TOTP code
assert len(typed_texts) >= 3
assert "robot_lea" in typed_texts
assert "secret123" in typed_texts
# Le 3e est un code TOTP à 6 chiffres
totp_code = typed_texts[2]
assert len(totp_code) == 6
assert totp_code.isdigit()
def test_get_auth_actions_missing_credentials(self, handler):
"""Si le vault n'a pas les credentials, retourne une liste vide."""
auth_req = AuthRequest(
auth_type="login",
app_name="AppInconnue",
detected_fields={
"username_field": {"type": "text_input", "label": "Login", "center": [500, 300], "element_id": "e1"},
"password_field": {"type": "text_input", "label": "Password", "center": [500, 350], "element_id": "e2"},
},
confidence=0.85,
)
actions = handler.get_auth_actions(auth_req)
assert actions == []
def test_detect_english_auth_screen(self, handler):
"""Détecter un écran d'auth en anglais."""
screen_state = {
"perception": {
"detected_text": ["Sign in to your account", "Username", "Password"],
},
"ui_elements": [
{"type": "text_input", "role": "text", "label": "Username", "center": [500, 300], "element_id": "e1", "tags": []},
{"type": "text_input", "role": "password", "label": "Password", "center": [500, 350], "element_id": "e2", "tags": []},
{"type": "button", "role": "primary_action", "label": "Sign in", "center": [500, 420], "element_id": "e3", "tags": []},
],
"window": {"app_name": "Outlook"},
}
auth_req = handler.detect_auth_screen(screen_state)
assert auth_req is not None
assert auth_req.auth_type == "login"
assert auth_req.app_name == "Outlook"
def test_detect_password_tag(self, handler):
"""Détecter un champ password via les tags de l'élément UI."""
screen_state = {
"perception": {"detected_text": []},
"ui_elements": [
{"type": "text_input", "role": "text", "label": "", "center": [500, 300], "element_id": "e1", "tags": ["password"]},
],
"window": {"app_name": "SomeApp"},
}
auth_req = handler.detect_auth_screen(screen_state)
assert auth_req is not None
assert "password_field" in auth_req.detected_fields

View File

@@ -0,0 +1,721 @@
"""
Tests unitaires pour core.federation.learning_pack
Vérifie :
- Export d'un workflow simple → pas de screenshots/OCR dans le pack
- Merge de 2 packs → déduplication correcte des prototypes
- Sérialisation / désérialisation JSON round-trip
- Anonymisation du client_id (SHA-256, pas en clair)
- Filtrage des données sensibles (textes OCR longs, métadonnées)
- Index FAISS global (construction, recherche, persistance)
"""
import hashlib
import json
import tempfile
from datetime import datetime
from pathlib import Path
from typing import List
import numpy as np
import pytest
from core.federation.learning_pack import (
DEDUP_COSINE_THRESHOLD,
LEARNING_PACK_VERSION,
AppSignature,
EdgeStatistic,
ErrorPattern,
LearningPack,
LearningPackExporter,
LearningPackMerger,
ScreenPrototype,
UIPattern,
WorkflowSkeleton,
_hash_client_id,
_sanitize_text,
)
from core.models.workflow_graph import (
Action,
EdgeConstraints,
EdgeStats,
EmbeddingPrototype,
PostConditionCheck,
PostConditions,
ScreenTemplate,
TargetSpec,
TextConstraint,
UIConstraint,
WindowConstraint,
Workflow,
WorkflowEdge,
WorkflowNode,
)
# ============================================================================
# Helpers — construction de workflows de test
# ============================================================================
def _make_node(
node_id: str,
name: str,
process_name: str = "Notepad.exe",
title_pattern: str = ".*Sans titre.*",
required_roles: List[str] = None,
prototype_vector: List[float] = None,
) -> WorkflowNode:
"""Créer un WorkflowNode minimal pour les tests."""
window = WindowConstraint(
title_pattern=title_pattern,
process_name=process_name,
)
text = TextConstraint(
required_texts=["Fichier", "Edition"],
forbidden_texts=["Erreur critique"],
)
ui = UIConstraint(
required_roles=required_roles or ["button", "textfield"],
)
embedding = EmbeddingPrototype(
provider="openclip_ViT-B-32",
vector_id="",
min_cosine_similarity=0.85,
sample_count=5,
)
template = ScreenTemplate(window=window, text=text, ui=ui, embedding=embedding)
metadata = {}
if prototype_vector is not None:
metadata["_prototype_vector"] = prototype_vector
return WorkflowNode(
node_id=node_id,
name=name,
description=f"Node de test : {name}",
template=template,
metadata=metadata,
)
def _make_edge(
edge_id: str,
from_node: str,
to_node: str,
action_type: str = "mouse_click",
target_role: str = "button",
fail_fast_texts: List[str] = None,
) -> WorkflowEdge:
"""Créer un WorkflowEdge minimal pour les tests."""
target = TargetSpec(by_role=target_role)
action = Action(type=action_type, target=target)
constraints = EdgeConstraints()
fail_fast = []
for txt in (fail_fast_texts or []):
fail_fast.append(PostConditionCheck(kind="text_present", value=txt))
post_conditions = PostConditions(fail_fast=fail_fast)
stats = EdgeStats(execution_count=10, success_count=9, avg_execution_time_ms=150.0)
return WorkflowEdge(
edge_id=edge_id,
from_node=from_node,
to_node=to_node,
action=action,
constraints=constraints,
post_conditions=post_conditions,
stats=stats,
)
def _make_workflow(
workflow_id: str = "wf_test_001",
name: str = "Workflow Test",
with_vectors: bool = True,
) -> Workflow:
"""Créer un Workflow complet minimal pour les tests."""
vec_a = np.random.randn(512).tolist() if with_vectors else None
vec_b = np.random.randn(512).tolist() if with_vectors else None
node_a = _make_node("node_a", "Écran principal", prototype_vector=vec_a)
node_b = _make_node(
"node_b", "Dialogue Enregistrer",
process_name="Notepad.exe",
title_pattern=".*Enregistrer.*",
prototype_vector=vec_b,
)
edge_ab = _make_edge(
"edge_ab", "node_a", "node_b",
fail_fast_texts=["Accès refusé", "Fichier introuvable"],
)
now = datetime.now()
return Workflow(
workflow_id=workflow_id,
name=name,
description="Workflow de test pour Learning Pack",
version=1,
learning_state="COACHING",
created_at=now,
updated_at=now,
entry_nodes=["node_a"],
end_nodes=["node_b"],
nodes=[node_a, node_b],
edges=[edge_ab],
safety_rules=Workflow.from_dict({
"workflow_id": "tmp", "name": "tmp", "nodes": [], "edges": [],
"safety_rules": {}, "stats": {}, "learning": {},
"entry_nodes": [], "end_nodes": [], "created_at": now.isoformat(),
"updated_at": now.isoformat(),
}).safety_rules,
stats=Workflow.from_dict({
"workflow_id": "tmp", "name": "tmp", "nodes": [], "edges": [],
"safety_rules": {}, "stats": {}, "learning": {},
"entry_nodes": [], "end_nodes": [], "created_at": now.isoformat(),
"updated_at": now.isoformat(),
}).stats,
learning=Workflow.from_dict({
"workflow_id": "tmp", "name": "tmp", "nodes": [], "edges": [],
"safety_rules": {}, "stats": {}, "learning": {},
"entry_nodes": [], "end_nodes": [], "created_at": now.isoformat(),
"updated_at": now.isoformat(),
}).learning,
)
# ============================================================================
# Tests — Anonymisation
# ============================================================================
class TestAnonymisation:
"""Vérifier que l'anonymisation fonctionne correctement."""
def test_client_id_est_hashe(self):
"""Le client_id ne doit PAS apparaître en clair dans le pack."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="CHU-Lyon-001")
pack_json = json.dumps(pack.to_dict())
assert "CHU-Lyon-001" not in pack_json, \
"Le client_id apparaît en clair dans le pack !"
def test_source_hash_est_sha256(self):
"""Le source_hash doit être un hash SHA-256 du client_id."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="CHU-Lyon-001")
expected_hash = hashlib.sha256(b"CHU-Lyon-001").hexdigest()
assert pack.source_hash == expected_hash
def test_hash_client_id_deterministe(self):
"""Le même client_id doit toujours donner le même hash."""
h1 = _hash_client_id("Clinique-Pasteur")
h2 = _hash_client_id("Clinique-Pasteur")
assert h1 == h2
def test_hash_client_id_differents(self):
"""Deux client_id différents doivent donner des hash différents."""
h1 = _hash_client_id("CHU-Lyon")
h2 = _hash_client_id("CHU-Marseille")
assert h1 != h2
def test_pas_de_screenshots_dans_pack(self):
"""Le pack ne doit contenir aucun chemin de screenshot."""
wf = _make_workflow()
# Ajouter un chemin screenshot dans les métadonnées du node
wf.nodes[0].metadata["screenshot_path"] = "/tmp/capture_001.png"
wf.nodes[0].metadata["ocr_text"] = "Texte OCR brut avec données patient"
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
pack_json = json.dumps(pack.to_dict())
assert "/tmp/capture_001.png" not in pack_json
assert "données patient" not in pack_json
def test_texte_ocr_long_filtre(self):
"""Les textes OCR longs (> 120 chars) doivent être filtrés."""
assert _sanitize_text("OK") == "OK"
assert _sanitize_text("x" * 200) is None
assert _sanitize_text("") is None
def test_texte_patient_filtre(self):
"""Les textes contenant des identifiants patient doivent être filtrés."""
assert _sanitize_text("patient Dupont") is None
assert _sanitize_text("NIP: 123456") is None
assert _sanitize_text("Dossier n°789") is None
def test_texte_court_et_sur_passe(self):
"""Les textes courts et non-sensibles doivent passer."""
assert _sanitize_text("Enregistrer") == "Enregistrer"
assert _sanitize_text("Fichier") == "Fichier"
assert _sanitize_text("Erreur de connexion") == "Erreur de connexion"
# ============================================================================
# Tests — Export
# ============================================================================
class TestExport:
"""Vérifier l'export de workflows en Learning Pack."""
def test_export_basique(self):
"""Export d'un workflow simple doit produire un pack valide."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test_client")
assert pack.version == LEARNING_PACK_VERSION
assert pack.pack_id.startswith("lp_")
assert pack.source_hash # Non vide
assert pack.created_at # Non vide
def test_export_stats(self):
"""Les stats du pack doivent refléter le contenu."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
assert pack.stats["workflows_count"] == 1
assert pack.stats["total_nodes"] == 2
assert pack.stats["total_edges"] == 1
assert "Notepad.exe" in pack.stats["apps_seen"]
def test_export_prototypes_avec_vecteurs(self):
"""Les prototypes doivent contenir les vecteurs 512d."""
wf = _make_workflow(with_vectors=True)
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
assert len(pack.screen_prototypes) == 2
for proto in pack.screen_prototypes:
assert proto.vector is not None
assert len(proto.vector) == 512
def test_export_prototypes_sans_vecteurs(self):
"""L'export doit fonctionner même sans vecteurs prototype."""
wf = _make_workflow(with_vectors=False)
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
# Les prototypes sont exportés mais sans vecteur
assert len(pack.screen_prototypes) == 2
for proto in pack.screen_prototypes:
assert proto.vector is None
def test_export_app_signatures(self):
"""Les signatures d'application doivent être collectées."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
app_names = [sig.app_name for sig in pack.app_signatures]
assert "Notepad.exe" in app_names
def test_export_error_patterns(self):
"""Les patterns d'erreur des PostConditions doivent être extraits."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
error_texts = [ep.error_text for ep in pack.error_patterns]
assert "Accès refusé" in error_texts
assert "Fichier introuvable" in error_texts
def test_export_edge_statistics(self):
"""Les statistiques d'edges doivent être exportées."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
assert len(pack.edge_statistics) == 1
stat = pack.edge_statistics[0]
assert stat.action_type == "mouse_click"
assert stat.execution_count == 10
assert stat.success_rate == 0.9
def test_export_workflow_skeleton(self):
"""Le squelette du workflow doit refléter la structure."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
assert len(pack.workflow_skeletons) == 1
skel = pack.workflow_skeletons[0]
assert skel.node_count == 2
assert skel.edge_count == 1
assert "Écran principal" in skel.node_names
assert skel.learning_state == "COACHING"
def test_export_action_sans_texte_saisi(self):
"""L'export ne doit PAS inclure le texte saisi (action text_input)."""
wf = _make_workflow()
# Ajouter un edge text_input avec un texte sensible
edge_text = _make_edge(
"edge_text", "node_a", "node_b",
action_type="text_input", target_role="textfield",
)
edge_text.action.parameters["text"] = "mot_de_passe_secret_123"
wf.edges.append(edge_text)
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="test")
pack_json = json.dumps(pack.to_dict())
assert "mot_de_passe_secret_123" not in pack_json
# ============================================================================
# Tests — Sérialisation
# ============================================================================
class TestSerialisation:
"""Vérifier le round-trip JSON (to_dict → from_dict)."""
def test_round_trip_learning_pack(self):
"""Sérialisation → désérialisation doit être idempotente."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="round_trip_test")
# Sérialiser → désérialiser
data = pack.to_dict()
restored = LearningPack.from_dict(data)
assert restored.version == pack.version
assert restored.source_hash == pack.source_hash
assert restored.pack_id == pack.pack_id
assert len(restored.screen_prototypes) == len(pack.screen_prototypes)
assert len(restored.workflow_skeletons) == len(pack.workflow_skeletons)
assert len(restored.error_patterns) == len(pack.error_patterns)
assert len(restored.edge_statistics) == len(pack.edge_statistics)
def test_round_trip_json_string(self):
"""Le JSON doit être parseable et reproductible."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="json_test")
json_str = json.dumps(pack.to_dict(), sort_keys=True)
data = json.loads(json_str)
restored = LearningPack.from_dict(data)
assert json.dumps(restored.to_dict(), sort_keys=True) == json_str
def test_save_load_fichier(self, tmp_path):
"""Sauvegarde → chargement fichier doit être idempotent."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="file_test")
filepath = tmp_path / "test_pack.json"
pack.save(filepath)
loaded = LearningPack.load(filepath)
assert loaded.pack_id == pack.pack_id
assert loaded.source_hash == pack.source_hash
assert len(loaded.screen_prototypes) == len(pack.screen_prototypes)
def test_all_sub_dataclasses_round_trip(self):
"""Chaque sous-structure doit supporter le round-trip."""
sig = AppSignature(app_name="Chrome.exe", version="120.0", observation_count=5)
assert AppSignature.from_dict(sig.to_dict()).app_name == "Chrome.exe"
proto = ScreenPrototype(
prototype_id="test",
vector=[1.0, 2.0, 3.0],
provider="test_provider",
)
restored = ScreenPrototype.from_dict(proto.to_dict())
assert restored.vector == [1.0, 2.0, 3.0]
skel = WorkflowSkeleton(
skeleton_id="sk1", name="Test", description="",
learning_state="OBSERVATION", node_names=["A", "B"],
edge_summaries=[], entry_nodes=["A"], end_nodes=["B"],
)
assert WorkflowSkeleton.from_dict(skel.to_dict()).name == "Test"
err = ErrorPattern(pattern_id="e1", error_text="Timeout")
assert ErrorPattern.from_dict(err.to_dict()).error_text == "Timeout"
# ============================================================================
# Tests — Merge
# ============================================================================
class TestMerge:
"""Vérifier la fusion de plusieurs Learning Packs."""
def test_merge_deux_packs(self):
"""Fusionner 2 packs doit produire un pack combiné."""
wf1 = _make_workflow("wf_1", "Workflow A")
wf2 = _make_workflow("wf_2", "Workflow B")
exporter = LearningPackExporter()
pack_a = exporter.export([wf1], client_id="Client-A")
pack_b = exporter.export([wf2], client_id="Client-B")
merger = LearningPackMerger()
merged = merger.merge([pack_a, pack_b])
assert merged.stats["workflows_count"] == 2
assert merged.stats["source_packs_count"] == 2
assert merged.pack_id.startswith("lp_merged_")
def test_merge_deduplication_prototypes_identiques(self):
"""Deux prototypes avec le même vecteur doivent être fusionnés."""
# Créer un vecteur fixe pour les deux packs
fixed_vec = np.random.randn(512).tolist()
wf1 = _make_workflow("wf_same_1")
wf1.nodes[0].metadata["_prototype_vector"] = fixed_vec
wf2 = _make_workflow("wf_same_2")
wf2.nodes[0].metadata["_prototype_vector"] = fixed_vec
exporter = LearningPackExporter()
pack_a = exporter.export([wf1], client_id="A")
pack_b = exporter.export([wf2], client_id="B")
# Avant merge : 2 prototypes avec le même vecteur pour node_a
total_before = len(pack_a.screen_prototypes) + len(pack_b.screen_prototypes)
assert total_before == 4 # 2 nodes × 2 packs
merger = LearningPackMerger()
merged = merger.merge([pack_a, pack_b])
# Après merge : les prototypes identiques (node_a) doivent être dédupliqués
# node_b a des vecteurs différents (random), donc pas de dédup
# node_a est identique → fusionné en 1
# Résultat attendu : entre 2 et 3 prototypes (1 dédupliqué + 2 différents)
assert len(merged.screen_prototypes) < total_before
def test_merge_prototypes_differents_conserves(self):
"""Deux prototypes très différents ne doivent PAS être fusionnés."""
# Créer deux vecteurs orthogonaux
vec_a = np.zeros(512, dtype=np.float32)
vec_a[0] = 1.0
vec_b = np.zeros(512, dtype=np.float32)
vec_b[1] = 1.0
wf1 = _make_workflow("wf_diff_1")
wf1.nodes[0].metadata["_prototype_vector"] = vec_a.tolist()
# Supprimer node_b pour simplifier
wf1.nodes = [wf1.nodes[0]]
wf1.edges = []
wf2 = _make_workflow("wf_diff_2")
wf2.nodes[0].metadata["_prototype_vector"] = vec_b.tolist()
wf2.nodes = [wf2.nodes[0]]
wf2.edges = []
exporter = LearningPackExporter()
pack_a = exporter.export([wf1], client_id="A")
pack_b = exporter.export([wf2], client_id="B")
merger = LearningPackMerger()
merged = merger.merge([pack_a, pack_b])
# Les deux prototypes sont très différents → pas de dédup
assert len(merged.screen_prototypes) == 2
def test_merge_error_patterns_cross_clients(self):
"""Les patterns d'erreur vus par plusieurs clients ont un cross_client_count > 1."""
# Même erreur dans les deux packs
wf1 = _make_workflow("wf_err_1")
wf2 = _make_workflow("wf_err_2")
exporter = LearningPackExporter()
pack_a = exporter.export([wf1], client_id="Hôpital-A")
pack_b = exporter.export([wf2], client_id="Hôpital-B")
merger = LearningPackMerger()
merged = merger.merge([pack_a, pack_b])
# "Accès refusé" et "Fichier introuvable" sont dans les deux packs
for ep in merged.error_patterns:
if ep.error_text == "Accès refusé":
assert ep.cross_client_count == 2
assert ep.observation_count == 2 # 1 par pack
break
else:
pytest.fail("Pattern 'Accès refusé' non trouvé dans le merge")
def test_merge_app_signatures_union(self):
"""Les signatures d'application doivent être l'union des packs."""
wf1 = _make_workflow("wf_app_1")
wf2 = _make_workflow("wf_app_2")
# Changer l'app du deuxième workflow
wf2.nodes[0].template.window.process_name = "Chrome.exe"
exporter = LearningPackExporter()
pack_a = exporter.export([wf1], client_id="A")
pack_b = exporter.export([wf2], client_id="B")
merger = LearningPackMerger()
merged = merger.merge([pack_a, pack_b])
app_names = {sig.app_name for sig in merged.app_signatures}
assert "Notepad.exe" in app_names
assert "Chrome.exe" in app_names
def test_merge_liste_vide(self):
"""Merger une liste vide retourne un pack vide."""
merger = LearningPackMerger()
merged = merger.merge([])
assert merged.pack_id.startswith("lp_merged_")
assert len(merged.screen_prototypes) == 0
def test_merge_un_seul_pack(self):
"""Merger un seul pack le retourne avec un nouveau pack_id."""
wf = _make_workflow()
exporter = LearningPackExporter()
pack = exporter.export([wf], client_id="solo")
merger = LearningPackMerger()
merged = merger.merge([pack])
assert merged.pack_id != pack.pack_id
assert merged.pack_id.startswith("lp_merged_")
assert len(merged.screen_prototypes) == len(pack.screen_prototypes)
def test_merge_edge_statistics_moyennes(self):
"""Les statistiques d'edges doivent être combinées par moyenne pondérée."""
wf1 = _make_workflow("wf_stat_1")
wf2 = _make_workflow("wf_stat_2")
exporter = LearningPackExporter()
pack_a = exporter.export([wf1], client_id="A")
pack_b = exporter.export([wf2], client_id="B")
merger = LearningPackMerger()
merged = merger.merge([pack_a, pack_b])
# Les edges ont les mêmes noms de nodes → ils sont mergés
for stat in merged.edge_statistics:
if stat.from_node_name == "Écran principal":
# 10 exécutions par pack → 20 au total
assert stat.execution_count == 20
# success_rate = 0.9 pour les deux → moyenne = 0.9
assert abs(stat.success_rate - 0.9) < 0.01
break
# ============================================================================
# Tests — Index FAISS Global
# ============================================================================
class TestGlobalFAISSIndex:
"""Tests de l'index FAISS global (nécessite faiss-cpu)."""
@pytest.fixture
def sample_packs(self):
"""Créer deux packs de test avec des vecteurs."""
wf1 = _make_workflow("wf_faiss_1", "Workflow FAISS A")
wf2 = _make_workflow("wf_faiss_2", "Workflow FAISS B")
exporter = LearningPackExporter()
pack_a = exporter.export([wf1], client_id="Client-FAISS-A")
pack_b = exporter.export([wf2], client_id="Client-FAISS-B")
return [pack_a, pack_b]
def test_build_from_packs(self, sample_packs):
"""Construction de l'index depuis les packs."""
try:
from core.federation.faiss_global import GlobalFAISSIndex
except ImportError:
pytest.skip("FAISS non installé")
index = GlobalFAISSIndex(dimensions=512)
count = index.build_from_packs(sample_packs)
assert count > 0
assert index.total_vectors == count
def test_search(self, sample_packs):
"""Recherche dans l'index global."""
try:
from core.federation.faiss_global import GlobalFAISSIndex
except ImportError:
pytest.skip("FAISS non installé")
index = GlobalFAISSIndex(dimensions=512)
index.build_from_packs(sample_packs)
# Chercher avec un vecteur aléatoire
query = np.random.randn(512).astype(np.float32)
results = index.search(query, k=3)
assert len(results) > 0
assert len(results) <= 3
for r in results:
assert r.prototype_id
assert r.pack_source_hash
assert -1.0 <= r.similarity <= 1.0
def test_search_index_vide(self):
"""Recherche dans un index vide retourne une liste vide."""
try:
from core.federation.faiss_global import GlobalFAISSIndex
except ImportError:
pytest.skip("FAISS non installé")
index = GlobalFAISSIndex(dimensions=512)
results = index.search(np.random.randn(512).astype(np.float32))
assert results == []
def test_add_pack_incremental(self, sample_packs):
"""Ajout incrémental d'un pack à l'index."""
try:
from core.federation.faiss_global import GlobalFAISSIndex
except ImportError:
pytest.skip("FAISS non installé")
index = GlobalFAISSIndex(dimensions=512)
count1 = index.add_pack(sample_packs[0])
count2 = index.add_pack(sample_packs[1])
assert count1 > 0
assert count2 > 0
assert index.total_vectors == count1 + count2
def test_save_load(self, sample_packs, tmp_path):
"""Sauvegarde et chargement de l'index."""
try:
from core.federation.faiss_global import GlobalFAISSIndex
except ImportError:
pytest.skip("FAISS non installé")
index = GlobalFAISSIndex(dimensions=512)
index.build_from_packs(sample_packs)
base_path = tmp_path / "global_index"
index.save(base_path)
loaded = GlobalFAISSIndex.load(base_path)
assert loaded.total_vectors == index.total_vectors
assert loaded.dimensions == index.dimensions
# Vérifier que la recherche fonctionne sur l'index chargé
query = np.random.randn(512).astype(np.float32)
results = loaded.search(query, k=2)
assert len(results) > 0
def test_get_stats(self, sample_packs):
"""Statistiques de l'index global."""
try:
from core.federation.faiss_global import GlobalFAISSIndex
except ImportError:
pytest.skip("FAISS non installé")
index = GlobalFAISSIndex(dimensions=512)
index.build_from_packs(sample_packs)
stats = index.get_stats()
assert stats["dimensions"] == 512
assert stats["total_vectors"] > 0
assert stats["unique_sources"] >= 1

View File

@@ -55,6 +55,8 @@ def list_learned_workflows():
Query params: Query params:
machine_id: Filtrer par machine (optionnel) machine_id: Filtrer par machine (optionnel)
os: Filtrer par OS — 'windows' ou 'linux' (optionnel).
Filtre sur la portion OS du machine_id (ex: DESKTOP-58D5CAC_windows).
Response: Response:
{ {
@@ -76,6 +78,7 @@ def list_learned_workflows():
} }
""" """
machine_id = request.args.get('machine_id') machine_id = request.args.get('machine_id')
os_filter = request.args.get('os') # 'windows' ou 'linux'
from services.learned_workflow_bridge import list_learned_workflows_from_disk from services.learned_workflow_bridge import list_learned_workflows_from_disk
@@ -132,6 +135,14 @@ def list_learned_workflows():
if machine_id: if machine_id:
merged = [w for w in merged if w.get("machine_id") == machine_id] merged = [w for w in merged if w.get("machine_id") == machine_id]
# Filtrer par OS si demandé (cherche 'windows' ou 'linux' dans le machine_id)
if os_filter:
os_filter_lower = os_filter.lower()
merged = [
w for w in merged
if os_filter_lower in (w.get("machine_id") or "").lower()
]
# Enrichir : vérifier si déjà importé dans le VWB # Enrichir : vérifier si déjà importé dans le VWB
for wf in merged: for wf in merged:
existing = Workflow.query.filter( existing = Workflow.query.filter(

View File

@@ -58,9 +58,15 @@ db.init_app(app)
migrate = Migrate(app, db) migrate = Migrate(app, db)
cache = Cache(app) cache = Cache(app)
_ALLOWED_ORIGINS = [
"http://localhost:3002",
"http://localhost:5002",
"https://vwb.labs.laurinebazin.design",
"https://lea.labs.laurinebazin.design",
]
socketio = SocketIO( socketio = SocketIO(
app, app,
cors_allowed_origins="*", cors_allowed_origins=_ALLOWED_ORIGINS,
async_mode='threading', async_mode='threading',
logger=True, logger=True,
engineio_logger=True engineio_logger=True
@@ -204,6 +210,16 @@ def set_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"connect-src 'self' ws: wss: http://localhost:* https://vwb.labs.laurinebazin.design https://lea.labs.laurinebazin.design; "
"font-src 'self' data:; "
"frame-ancestors 'self'"
)
return response return response

View File

@@ -67,6 +67,11 @@ import os
import json import json
import requests import requests
import re import re
try:
from vlm_provider import vlm_hub
except ImportError:
from visual_workflow_builder.backend.vlm_provider import vlm_hub
try: try:
import cv2 import cv2
import numpy as np import numpy as np
@@ -624,21 +629,33 @@ def find_anchor_with_vlm(
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Utilise un VLM (Vision Language Model) pour trouver l'ancre sur l'écran. Utilise un VLM (Vision Language Model) pour trouver l'ancre sur l'écran.
En priorité via Gemini Cloud (vlm_hub), sinon via Ollama local.
Le VLM comprend le contexte visuel et peut distinguer:
- Une icône dans le dock vs le même logo dans la fenêtre principale
- Un bouton actif vs sa copie dans une miniature
Args:
screenshot_base64: Capture d'écran complète en base64
anchor_image_base64: Image de l'ancre à trouver en base64
anchor_description: Description textuelle de l'élément (optionnel)
screen_width: Largeur de l'écran
screen_height: Hauteur de l'écran
Returns:
Dict avec coordonnées {x, y, confidence} ou None si non trouvé
""" """
# 1. Essayer via le VLM Hub (Gemini Cloud)
if vlm_hub.use_cloud and vlm_hub.google_api_key:
print(f"🧠 [VLM Hub] Tentative via Gemini Cloud pour: '{anchor_description}'...")
coords = vlm_hub.detect_ui_element(
screenshot=screenshot_base64,
anchor_image=anchor_image_base64,
description=anchor_description or "l'élément UI spécifié"
)
if coords and coords.get('found'):
# Convertir bbox normalisée [ymin, xmin, ymax, xmax] (0-1000) en pixels [x, y]
bbox = coords.get('bbox')
if bbox and len(bbox) == 4:
# Gemini retourne souvent [ymin, xmin, ymax, xmax] sur une échelle 0-1000
y1, x1, y2, x2 = bbox
x = int((x1 + x2) / 2 * screen_width / 1000)
y = int((y1 + y2) / 2 * screen_height / 1000)
confidence = float(coords.get('confidence', 0.9))
print(f"✅ [VLM Hub] Gemini a trouvé l'élément à ({x}, {y}) avec confiance {confidence:.0%}")
return {
"found": True, "x": x, "y": y, "center_x": x, "center_y": y,
"confidence": confidence, "method": "gemini_cloud"
}
# 2. Fallback sur Ollama Local
if not check_ollama_available(): if not check_ollama_available():
print("⚠️ [VLM] Ollama/qwen2.5vl non disponible, fallback sur pyautogui") print("⚠️ [VLM] Ollama/qwen2.5vl non disponible, fallback sur pyautogui")
return None return None
@@ -976,7 +993,9 @@ def find_anchor_multiscale(anchor_image_base64: str, scales: List[float] = None,
Dict avec les coordonnées trouvées ou None Dict avec les coordonnées trouvées ou None
""" """
if scales is None: if scales is None:
scales = [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3] # Plage étendue 0.5x-2.0x pour couvrir les écarts de résolution importants
# (ex: apprentissage 2560x1600 → replay 1280x720 = ratio ~0.5x)
scales = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.75, 2.0]
if not CV2_AVAILABLE: if not CV2_AVAILABLE:
return None return None

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_01a7b6e80168_1769095224",
"bounding_box": {
"x": 52.666666666666664,
"y": 23.866658528645832,
"width": 100,
"height": 24
},
"original_size": {
"width": 120,
"height": 44
},
"thumbnail_size": {
"width": 120,
"height": 44
},
"created_at": "2026-01-22T16:20:24.448773",
"original_file_size": 4172,
"thumbnail_file_size": 1342
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_07961dc0aa07_1769032466",
"bounding_box": {
"x": 788.4444173177084,
"y": 89.7407145416744,
"width": 144,
"height": 141.33332055362771
},
"original_size": {
"width": 164,
"height": 161
},
"thumbnail_size": {
"width": 153,
"height": 150
},
"created_at": "2026-01-21T22:54:26.818654",
"original_file_size": 15929,
"thumbnail_file_size": 2733
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_0ad95fe4cd0d_1769094638",
"bounding_box": {
"x": 378,
"y": 95.86665852864583,
"width": 614.6666666666666,
"height": 68
},
"original_size": {
"width": 634,
"height": 88
},
"thumbnail_size": {
"width": 200,
"height": 28
},
"created_at": "2026-01-22T16:10:38.629806",
"original_file_size": 32381,
"thumbnail_file_size": 1690
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_0b04180c5773_1769093895",
"bounding_box": {
"x": 702,
"y": 55.866658528645836,
"width": 240,
"height": 21.333333333333332
},
"original_size": {
"width": 260,
"height": 41
},
"thumbnail_size": {
"width": 200,
"height": 32
},
"created_at": "2026-01-22T15:58:15.431474",
"original_file_size": 7749,
"thumbnail_file_size": 1693
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_109e03d0bd6e_1769027528",
"bounding_box": {
"x": 661.7777506510416,
"y": 37.648108574932316,
"width": 325.3333333333333,
"height": 49.33332887249268
},
"original_size": {
"width": 345,
"height": 69
},
"thumbnail_size": {
"width": 200,
"height": 40
},
"created_at": "2026-01-21T21:32:08.911570",
"original_file_size": 8040,
"thumbnail_file_size": 1021
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_154f2965ccd7_1769071042",
"bounding_box": {
"x": 6,
"y": 837.1999918619791,
"width": 52,
"height": 57.333333333333336
},
"original_size": {
"width": 72,
"height": 73
},
"thumbnail_size": {
"width": 72,
"height": 73
},
"created_at": "2026-01-22T09:37:23.002573",
"original_file_size": 3336,
"thumbnail_file_size": 1081
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_1f14f3421523_1769019671",
"bounding_box": {
"x": 7.511067708333333,
"y": 840,
"width": 41.333333333333336,
"height": 60
},
"original_size": {
"width": 61,
"height": 70
},
"thumbnail_size": {
"width": 61,
"height": 70
},
"created_at": "2026-01-21T19:21:11.133210",
"original_file_size": 3663,
"thumbnail_file_size": 1044
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_22071a702f14_1769019035",
"bounding_box": {
"x": 0.4444173177083333,
"y": 840.4073133312253,
"width": 53.333333333333336,
"height": 55.99999493634308
},
"original_size": {
"width": 73,
"height": 70
},
"thumbnail_size": {
"width": 73,
"height": 70
},
"created_at": "2026-01-21T19:10:35.916823",
"original_file_size": 3804,
"thumbnail_file_size": 1105
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_2dcada499503_1769026880",
"bounding_box": {
"x": 1.7777506510416667,
"y": 839.0739801184551,
"width": 53.333333333333336,
"height": 47.99999565972272
},
"original_size": {
"width": 73,
"height": 67
},
"thumbnail_size": {
"width": 73,
"height": 67
},
"created_at": "2026-01-21T21:21:20.684380",
"original_file_size": 3414,
"thumbnail_file_size": 1063
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_38b46eeb9aa7_1769108446",
"bounding_box": {
"x": 787.3333333333334,
"y": 86.5333251953125,
"width": 138.66666666666666,
"height": 142.66666666666666
},
"original_size": {
"width": 158,
"height": 162
},
"thumbnail_size": {
"width": 146,
"height": 150
},
"created_at": "2026-01-22T20:00:46.168811",
"original_file_size": 15894,
"thumbnail_file_size": 2792
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_3b097ff2f8e0_1769032447",
"bounding_box": {
"x": 57.777750651041664,
"y": 25.740720328710907,
"width": 96,
"height": 27.999997468171525
},
"original_size": {
"width": 116,
"height": 47
},
"thumbnail_size": {
"width": 116,
"height": 47
},
"created_at": "2026-01-21T22:54:07.560023",
"original_file_size": 3897,
"thumbnail_file_size": 1276
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_3bfad8fb87f6_1769071011",
"bounding_box": {
"x": 10,
"y": 846.5111083984375,
"width": 40,
"height": 44
},
"original_size": {
"width": 60,
"height": 64
},
"thumbnail_size": {
"width": 60,
"height": 64
},
"created_at": "2026-01-22T09:36:51.077375",
"original_file_size": 3265,
"thumbnail_file_size": 1012
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_3fa725d0afac_1769027605",
"bounding_box": {
"x": 681.7777506510416,
"y": 52.40738458411236,
"width": 185.33333333333334,
"height": 21.33333140432116
},
"original_size": {
"width": 205,
"height": 41
},
"thumbnail_size": {
"width": 200,
"height": 40
},
"created_at": "2026-01-21T21:33:25.656268",
"original_file_size": 6955,
"thumbnail_file_size": 1693
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_411d198f7d8d_1769026971",
"bounding_box": {
"x": 4.444417317708333,
"y": 825.7406479907545,
"width": 53.333333333333336,
"height": 59.99999457465325
},
"original_size": {
"width": 73,
"height": 79
},
"thumbnail_size": {
"width": 73,
"height": 79
},
"created_at": "2026-01-21T21:22:51.165280",
"original_file_size": 3422,
"thumbnail_file_size": 1059
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_41312810e9ab_1769018430",
"bounding_box": {
"x": 20,
"y": 20,
"width": 100,
"height": 80
},
"original_size": {
"width": 120,
"height": 100
},
"thumbnail_size": {
"width": 120,
"height": 100
},
"created_at": "2026-01-21T19:00:30.494847",
"original_file_size": 222,
"thumbnail_file_size": 798
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_41d6a1572597_1769026781",
"bounding_box": {
"x": 3.111083984375,
"y": 840.7591606301395,
"width": 50.666666666666664,
"height": 51.9999952980329
},
"original_size": {
"width": 70,
"height": 70
},
"thumbnail_size": {
"width": 70,
"height": 70
},
"created_at": "2026-01-21T21:19:41.943551",
"original_file_size": 3436,
"thumbnail_file_size": 1055
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_490e337ebcfc_1769086898",
"bounding_box": {
"x": 3.111083984375,
"y": 151.07404232909775,
"width": 50.666666666666664,
"height": 53.33332851080289
},
"original_size": {
"width": 70,
"height": 73
},
"thumbnail_size": {
"width": 70,
"height": 73
},
"created_at": "2026-01-22T14:01:38.851522",
"original_file_size": 7591,
"thumbnail_file_size": 1434
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_4e3067c7d77f_1769032292",
"bounding_box": {
"x": 673.7777506510416,
"y": 43.07405209472185,
"width": 305.3333333333333,
"height": 37.333329957562036
},
"original_size": {
"width": 325,
"height": 57
},
"thumbnail_size": {
"width": 200,
"height": 35
},
"created_at": "2026-01-21T22:51:32.559892",
"original_file_size": 7844,
"thumbnail_file_size": 1108
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_55e73b435685_1769027422",
"bounding_box": {
"x": 7.111083984375,
"y": 843.0739797567654,
"width": 45.333333333333336,
"height": 49.33332887249268
},
"original_size": {
"width": 65,
"height": 67
},
"thumbnail_size": {
"width": 65,
"height": 67
},
"created_at": "2026-01-21T21:30:22.544153",
"original_file_size": 3419,
"thumbnail_file_size": 1073
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_58b59356cf75_1769111447",
"bounding_box": {
"x": 615.3333333333334,
"y": 81.19999186197917,
"width": 144,
"height": 150.66666666666666
},
"original_size": {
"width": 164,
"height": 170
},
"thumbnail_size": {
"width": 145,
"height": 150
},
"created_at": "2026-01-22T20:50:47.554442",
"original_file_size": 11685,
"thumbnail_file_size": 2441
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_68b5b0da9a64_1769097688",
"bounding_box": {
"x": 67.33333333333333,
"y": 26.5333251953125,
"width": 22.666666666666668,
"height": 26.666666666666668
},
"original_size": {
"width": 42,
"height": 46
},
"thumbnail_size": {
"width": 42,
"height": 46
},
"created_at": "2026-01-22T17:01:28.364282",
"original_file_size": 1795,
"thumbnail_file_size": 707
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_6a859da4b8a2_1769091100",
"bounding_box": {
"x": -0.888916015625,
"y": 845.7406461823056,
"width": 57.333333333333336,
"height": 50.66666208526279
},
"original_size": {
"width": 77,
"height": 65
},
"thumbnail_size": {
"width": 77,
"height": 65
},
"created_at": "2026-01-22T15:11:40.315519",
"original_file_size": 3437,
"thumbnail_file_size": 1125
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_6ca93ca7659b_1769095422",
"bounding_box": {
"x": 68.66666666666667,
"y": 31.866658528645832,
"width": 85.33333333333333,
"height": 14.666666666666666
},
"original_size": {
"width": 105,
"height": 34
},
"thumbnail_size": {
"width": 105,
"height": 34
},
"created_at": "2026-01-22T16:23:42.687417",
"original_file_size": 3309,
"thumbnail_file_size": 1132
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_72dc58cdbbe2_1769079328",
"bounding_box": {
"x": 5.777750651041667,
"y": 843.0739797567654,
"width": 46.666666666666664,
"height": 53.33332851080286
},
"original_size": {
"width": 66,
"height": 67
},
"thumbnail_size": {
"width": 66,
"height": 67
},
"created_at": "2026-01-22T11:55:28.209469",
"original_file_size": 4025,
"thumbnail_file_size": 1237
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_74a09f5b2603_1769107519",
"bounding_box": {
"x": 56.666666666666664,
"y": 27.866658528645832,
"width": 98.66666666666667,
"height": 21.333333333333332
},
"original_size": {
"width": 118,
"height": 41
},
"thumbnail_size": {
"width": 118,
"height": 41
},
"created_at": "2026-01-22T19:45:19.515074",
"original_file_size": 3850,
"thumbnail_file_size": 1288
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_7677c82a68bc_1769093993",
"bounding_box": {
"x": 415.3333333333333,
"y": 107.86665852864583,
"width": 98.66666666666667,
"height": 110.66666666666667
},
"original_size": {
"width": 118,
"height": 130
},
"thumbnail_size": {
"width": 118,
"height": 130
},
"created_at": "2026-01-22T15:59:53.309677",
"original_file_size": 17093,
"thumbnail_file_size": 3009
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_79d84dda4257_1769079491",
"bounding_box": {
"x": 5.777750651041667,
"y": 847.0739793950756,
"width": 46.666666666666664,
"height": 50.66666208526279
},
"original_size": {
"width": 66,
"height": 63
},
"thumbnail_size": {
"width": 66,
"height": 63
},
"created_at": "2026-01-22T11:58:11.312760",
"original_file_size": 3813,
"thumbnail_file_size": 1097
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_7de070f7b4c5_1769091889",
"bounding_box": {
"x": 6,
"y": 847.8666585286459,
"width": 42.666666666666664,
"height": 41.333333333333336
},
"original_size": {
"width": 62,
"height": 61
},
"thumbnail_size": {
"width": 62,
"height": 61
},
"created_at": "2026-01-22T15:24:49.229883",
"original_file_size": 3265,
"thumbnail_file_size": 972
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_7f05480c1da2_1769086942",
"bounding_box": {
"x": 0.4444173177083333,
"y": 95.07404739275468,
"width": 50.666666666666664,
"height": 58.6666613618832
},
"original_size": {
"width": 70,
"height": 78
},
"thumbnail_size": {
"width": 70,
"height": 78
},
"created_at": "2026-01-22T14:02:22.124508",
"original_file_size": 6806,
"thumbnail_file_size": 1469
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_851447edda6a_1769094102",
"bounding_box": {
"x": 615.3333333333334,
"y": 83.86665852864583,
"width": 145.33333333333334,
"height": 150.66666666666666
},
"original_size": {
"width": 165,
"height": 170
},
"thumbnail_size": {
"width": 146,
"height": 150
},
"created_at": "2026-01-22T16:01:42.039695",
"original_file_size": 11696,
"thumbnail_file_size": 2474
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_8676ea613f0d_1769031704",
"bounding_box": {
"x": 405.7777506510417,
"y": 100.40738024383496,
"width": 120,
"height": 117.33332272376639
},
"original_size": {
"width": 140,
"height": 137
},
"thumbnail_size": {
"width": 140,
"height": 137
},
"created_at": "2026-01-21T22:41:44.732783",
"original_file_size": 17773,
"thumbnail_file_size": 3255
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_9b8fb0066648_1769088263",
"bounding_box": {
"x": 4.444417317708333,
"y": 512.4073429897875,
"width": 44,
"height": 46.666662446952536
},
"original_size": {
"width": 64,
"height": 66
},
"thumbnail_size": {
"width": 64,
"height": 66
},
"created_at": "2026-01-22T14:24:23.082162",
"original_file_size": 3870,
"thumbnail_file_size": 1015
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_a14d9a2ab6d5_1769031402",
"bounding_box": {
"x": 619.111083984375,
"y": 85.59256224748968,
"width": 138.66666666666666,
"height": 146.666653404708
},
"original_size": {
"width": 158,
"height": 166
},
"thumbnail_size": {
"width": 143,
"height": 150
},
"created_at": "2026-01-21T22:36:42.812376",
"original_file_size": 11642,
"thumbnail_file_size": 2554
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_a80b7accc764_1769095298",
"bounding_box": {
"x": 784.6666666666666,
"y": 89.17777506510417,
"width": 138.66666666666666,
"height": 142.66666666666666
},
"original_size": {
"width": 158,
"height": 162
},
"thumbnail_size": {
"width": 146,
"height": 150
},
"created_at": "2026-01-22T16:21:38.941443",
"original_file_size": 15842,
"thumbnail_file_size": 2829
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_af322c06f1ff_1769097662",
"bounding_box": {
"x": 64.66666666666667,
"y": 31.866658528645832,
"width": 80,
"height": 13.333333333333334
},
"original_size": {
"width": 100,
"height": 33
},
"thumbnail_size": {
"width": 100,
"height": 33
},
"created_at": "2026-01-22T17:01:02.148524",
"original_file_size": 3358,
"thumbnail_file_size": 1226
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_b75fa9a8f1dd_1769090648",
"bounding_box": {
"x": 0.4444173177083333,
"y": 847.0739793950756,
"width": 53.333333333333336,
"height": 41.33332959587233
},
"original_size": {
"width": 73,
"height": 61
},
"thumbnail_size": {
"width": 73,
"height": 61
},
"created_at": "2026-01-22T15:04:08.862025",
"original_file_size": 3821,
"thumbnail_file_size": 1097
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_b98b50f27a10_1769031253",
"bounding_box": {
"x": 764.4444173177084,
"y": 99.0740470310649,
"width": 121.33333333333333,
"height": 119.99998914930653
},
"original_size": {
"width": 141,
"height": 139
},
"thumbnail_size": {
"width": 141,
"height": 139
},
"created_at": "2026-01-21T22:34:13.654029",
"original_file_size": 18201,
"thumbnail_file_size": 3211
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_b9bc7ea3369b_1769070632",
"bounding_box": {
"x": 4.666666666666667,
"y": 841.1999918619791,
"width": 42.666666666666664,
"height": 49.333333333333336
},
"original_size": {
"width": 62,
"height": 69
},
"thumbnail_size": {
"width": 62,
"height": 69
},
"created_at": "2026-01-22T09:30:32.058188",
"original_file_size": 3302,
"thumbnail_file_size": 992
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_c5acc71c3066_1769032342",
"bounding_box": {
"x": 771.111083984375,
"y": 100.40738024383496,
"width": 114.66666666666667,
"height": 115.99998951099631
},
"original_size": {
"width": 134,
"height": 135
},
"thumbnail_size": {
"width": 134,
"height": 135
},
"created_at": "2026-01-21T22:52:22.390957",
"original_file_size": 18134,
"thumbnail_file_size": 3249
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_cb7bb23c8a14_1769032268",
"bounding_box": {
"x": 1.7777506510416667,
"y": 847.0739793950756,
"width": 46.666666666666664,
"height": 42.666662808642286
},
"original_size": {
"width": 66,
"height": 62
},
"thumbnail_size": {
"width": 66,
"height": 62
},
"created_at": "2026-01-21T22:51:08.672430",
"original_file_size": 3811,
"thumbnail_file_size": 1097
}

View File

@@ -0,0 +1,20 @@
{
"anchor_id": "anchor_cbbf8da48554_1769087932",
"bounding_box": {
"x": 3.111083984375,
"y": 513.7406762025574,
"width": 50.666666666666664,
"height": 50.66666208526279
},
"original_size": {
"width": 70,
"height": 70
},
"thumbnail_size": {
"width": 70,
"height": 70
},
"created_at": "2026-01-22T14:18:52.177268",
"original_file_size": 4500,
"thumbnail_file_size": 1161
}

Some files were not shown because too many files have changed in this diff Show More