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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -58,6 +58,7 @@ Thumbs.db
|
||||
|
||||
# === Secrets ===
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
@@ -80,7 +80,13 @@ app = Flask(__name__)
|
||||
import secrets as _secrets
|
||||
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)
|
||||
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-Frame-Options'] = 'SAMEORIGIN'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
return response
|
||||
|
||||
|
||||
@@ -116,6 +123,16 @@ STREAMING_SERVER_URL = os.environ.get(
|
||||
"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 = {
|
||||
"running": False,
|
||||
"workflow": None,
|
||||
@@ -135,6 +152,7 @@ def _fetch_connected_machines() -> List[Dict[str, Any]]:
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/machines",
|
||||
headers=_streaming_headers(),
|
||||
timeout=3,
|
||||
)
|
||||
if resp.ok:
|
||||
@@ -384,7 +402,7 @@ def api_status():
|
||||
|
||||
@app.route('/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 :
|
||||
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,
|
||||
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:
|
||||
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()
|
||||
workflows = []
|
||||
|
||||
@@ -433,6 +461,21 @@ def api_workflows():
|
||||
workflows.append(vwb_wf)
|
||||
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
|
||||
machines = _fetch_connected_machines()
|
||||
|
||||
@@ -1128,6 +1171,7 @@ def _execute_gesture(gesture):
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/raw",
|
||||
headers=_streaming_headers(),
|
||||
json={
|
||||
"actions": [action],
|
||||
"session_id": "",
|
||||
@@ -1654,6 +1698,7 @@ def _try_streaming_server_replay(
|
||||
|
||||
resp = http_requests.post(
|
||||
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay",
|
||||
headers=_streaming_headers(),
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
@@ -1696,6 +1741,7 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}",
|
||||
headers=_streaming_headers(),
|
||||
timeout=3,
|
||||
)
|
||||
if not resp.ok:
|
||||
@@ -1968,6 +2014,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/single",
|
||||
headers=_streaming_headers(),
|
||||
json={
|
||||
"action": action,
|
||||
"session_id": "",
|
||||
|
||||
@@ -197,7 +197,8 @@ NOT_FOUND"""
|
||||
prompt=prompt,
|
||||
image=screenshot,
|
||||
temperature=0.1,
|
||||
max_tokens=100
|
||||
max_tokens=100,
|
||||
assistant_prefill="COORDINATES:",
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
|
||||
34
agent_rust/LISEZMOI.txt
Normal file
34
agent_rust/LISEZMOI.txt
Normal 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
22
agent_rust/build_demo.sh
Executable 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
12
agent_rust/config.txt
Normal 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
|
||||
@@ -100,6 +100,10 @@ pub fn image_hash(img: &DynamicImage) -> u64 {
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let monitors = xcap::Monitor::all().ok()?;
|
||||
let primary = monitors
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! Configuration de l'agent RPA.
|
||||
//!
|
||||
//! 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).
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Version de l'agent Rust
|
||||
pub const AGENT_VERSION: &str = "0.2.0-rust";
|
||||
@@ -37,11 +41,86 @@ pub struct Config {
|
||||
|
||||
/// Port du serveur de chat (defaut: 5004)
|
||||
pub chat_port: u16,
|
||||
|
||||
/// Token Bearer pour l'authentification API (defaut: vide = pas d'auth)
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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 :
|
||||
/// - `RPA_SERVER_URL` : URL du serveur (defaut: http://localhost:5005/api/v1)
|
||||
/// - `RPA_MACHINE_ID` : Identifiant machine (defaut: hostname_os)
|
||||
@@ -51,7 +130,10 @@ impl Config {
|
||||
/// - `RPA_BLUR_SENSITIVE` : Flouter les zones sensibles (defaut: true)
|
||||
/// - `RPA_LOG_RETENTION_DAYS` : Retention des logs en jours (defaut: 180)
|
||||
/// - `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 {
|
||||
// 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 host = hostname::get()
|
||||
.map(|h| h.to_string_lossy().to_string())
|
||||
@@ -98,6 +180,8 @@ impl Config {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5004);
|
||||
|
||||
let api_token = env::var("RPA_API_TOKEN").unwrap_or_default();
|
||||
|
||||
Config {
|
||||
server_url,
|
||||
machine_id,
|
||||
@@ -108,6 +192,7 @@ impl Config {
|
||||
blur_sensitive,
|
||||
log_retention_days,
|
||||
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 {
|
||||
write!(
|
||||
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.heartbeat_interval_s, self.jpeg_quality,
|
||||
self.blur_sensitive, self.log_retention_days, self.chat_port,
|
||||
if self.api_token.is_empty() { "none" } else { "Bearer" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ mod replay;
|
||||
mod server;
|
||||
#[allow(dead_code)]
|
||||
mod state;
|
||||
mod sysinfo;
|
||||
mod tray;
|
||||
mod visual;
|
||||
|
||||
@@ -40,12 +41,20 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Trouve Edge sur Windows
|
||||
/// Trouve un navigateur compatible sur Windows (Edge, Chrome, Brave, Firefox)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn find_edge() -> Option<String> {
|
||||
fn find_browser() -> Option<String> {
|
||||
let paths = [
|
||||
// Edge
|
||||
r"C:\Program Files (x86)\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 {
|
||||
if std::path::Path::new(p).exists() {
|
||||
@@ -56,6 +65,37 @@ fn find_edge() -> Option<String> {
|
||||
}
|
||||
|
||||
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
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("info"),
|
||||
@@ -135,15 +175,41 @@ fn main() {
|
||||
let chat_state = state.clone();
|
||||
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");
|
||||
|
||||
// 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")]
|
||||
{
|
||||
let chat_url = config.chat_url();
|
||||
if let Some(edge) = find_edge() {
|
||||
println!("[MAIN] Ouverture de Léa dans Edge...");
|
||||
let _ = std::process::Command::new(&edge)
|
||||
if let Some(browser) = find_browser() {
|
||||
let browser_name = if browser.contains("chrome") { "Chrome" }
|
||||
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(&[
|
||||
&format!("--app={}", chat_url),
|
||||
"--window-size=600,800",
|
||||
@@ -151,6 +217,8 @@ fn main() {
|
||||
"--no-first-run",
|
||||
])
|
||||
.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() {
|
||||
let url = format!("{}/stats", config.server_url);
|
||||
let connected = client
|
||||
.get(&url)
|
||||
.timeout(timeout)
|
||||
let request = client.get(&url).timeout(timeout);
|
||||
let connected = network::with_auth(request, config)
|
||||
.send()
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false);
|
||||
@@ -327,6 +394,8 @@ fn health_check_loop(config: &Config, state: &AgentState) {
|
||||
|
||||
/// Affiche la banniere de demarrage.
|
||||
fn print_banner(config: &Config) {
|
||||
let meta = sysinfo::get_screen_metadata();
|
||||
|
||||
println!("======================================================");
|
||||
println!(
|
||||
" RPA Vision Agent v{} (Rust)",
|
||||
@@ -342,6 +411,17 @@ fn print_banner(config: &Config) {
|
||||
println!(" JPEG : qualite {}", config.jpeg_quality);
|
||||
println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" });
|
||||
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!(" [IA] Cet agent utilise l'intelligence artificielle.");
|
||||
|
||||
@@ -5,9 +5,21 @@
|
||||
//! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005).
|
||||
|
||||
use crate::config::Config;
|
||||
use reqwest::blocking::Client;
|
||||
use crate::sysinfo;
|
||||
use reqwest::blocking::{Client, RequestBuilder};
|
||||
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.
|
||||
///
|
||||
/// Format identique à celui du Python executor (agent_v1/core/executor.py).
|
||||
@@ -102,6 +114,8 @@ impl ActionResult {
|
||||
/// Envoie un heartbeat (screenshot) au serveur streaming.
|
||||
///
|
||||
/// 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.
|
||||
pub fn send_heartbeat(
|
||||
client: &Client,
|
||||
@@ -112,6 +126,19 @@ pub fn send_heartbeat(
|
||||
let url = format!("{}/image", config.streaming_url());
|
||||
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())
|
||||
.file_name("screenshot.jpg")
|
||||
.mime_str("image/jpeg")
|
||||
@@ -122,17 +149,22 @@ pub fn send_heartbeat(
|
||||
|
||||
let form = reqwest::blocking::multipart::Form::new().part("file", part);
|
||||
|
||||
match client
|
||||
let request = client
|
||||
.post(&url)
|
||||
.query(&[
|
||||
("session_id", session_id),
|
||||
("shot_id", &shot_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)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
{
|
||||
.timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
match with_auth(request, config).send() {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
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 session_id = config.agent_session_id();
|
||||
|
||||
let resp = client
|
||||
let request = client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("session_id", session_id.as_str()),
|
||||
("machine_id", config.machine_id.as_str()),
|
||||
])
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.ok()?;
|
||||
.timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
let resp = with_auth(request, config).send().ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
@@ -184,6 +216,120 @@ pub fn poll_next_action(client: &Client, config: &Config) -> Option<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.
|
||||
///
|
||||
/// 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,
|
||||
};
|
||||
|
||||
match client
|
||||
let request = client
|
||||
.post(&url)
|
||||
.json(&report)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
{
|
||||
.timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
match with_auth(request, config).send() {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>() {
|
||||
|
||||
@@ -435,6 +435,10 @@ fn event_sender_loop(
|
||||
}
|
||||
|
||||
/// 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(
|
||||
client: &reqwest::blocking::Client,
|
||||
config: &Config,
|
||||
@@ -443,6 +447,7 @@ fn send_event_to_server(
|
||||
) {
|
||||
let url = format!("{}/traces/stream/event", config.server_url);
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let (screen_w, screen_h) = capture::screen_dimensions().unwrap_or((1920, 1080));
|
||||
|
||||
let payload = match event {
|
||||
CapturedEvent::Click {
|
||||
@@ -460,6 +465,7 @@ fn send_event_to_server(
|
||||
"session_name": session_name,
|
||||
"machine_id": config.machine_id,
|
||||
"timestamp": timestamp,
|
||||
"screen_resolution": [screen_w, screen_h],
|
||||
})
|
||||
}
|
||||
CapturedEvent::DoubleClick {
|
||||
@@ -476,6 +482,7 @@ fn send_event_to_server(
|
||||
"session_name": session_name,
|
||||
"machine_id": config.machine_id,
|
||||
"timestamp": timestamp,
|
||||
"screen_resolution": [screen_w, screen_h],
|
||||
})
|
||||
}
|
||||
CapturedEvent::Text {
|
||||
@@ -491,6 +498,7 @@ fn send_event_to_server(
|
||||
"session_name": session_name,
|
||||
"machine_id": config.machine_id,
|
||||
"timestamp": timestamp,
|
||||
"screen_resolution": [screen_w, screen_h],
|
||||
})
|
||||
}
|
||||
CapturedEvent::KeyCombo { keys } => {
|
||||
@@ -500,6 +508,7 @@ fn send_event_to_server(
|
||||
"session_name": session_name,
|
||||
"machine_id": config.machine_id,
|
||||
"timestamp": timestamp,
|
||||
"screen_resolution": [screen_w, screen_h],
|
||||
})
|
||||
}
|
||||
CapturedEvent::Scroll {
|
||||
@@ -515,6 +524,7 @@ fn send_event_to_server(
|
||||
"session_name": session_name,
|
||||
"machine_id": config.machine_id,
|
||||
"timestamp": timestamp,
|
||||
"screen_resolution": [screen_w, screen_h],
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
274
agent_rust/src/sysinfo.rs
Normal file
274
agent_rust/src/sysinfo.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,25 @@ import platform
|
||||
import socket
|
||||
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"
|
||||
|
||||
# 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"
|
||||
|
||||
# Paramètres Vision (Crops pour qwen3-vl)
|
||||
TARGETED_CROP_SIZE = (400, 400)
|
||||
TARGETED_CROP_SIZE = (150, 150)
|
||||
SCREENSHOT_QUALITY = 85
|
||||
|
||||
# Floutage des données sensibles (conformité AI Act)
|
||||
@@ -52,6 +71,22 @@ PERF_MONITOR_INTERVAL_S = 30
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
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
|
||||
os.makedirs(SESSIONS_ROOT, exist_ok=True)
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
|
||||
@@ -10,11 +10,20 @@ Fonctionnalités :
|
||||
- Buffer de saisie texte : accumule les frappes et émet un événement
|
||||
text_input après 500ms d'inactivité clavier
|
||||
- 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 time
|
||||
import logging
|
||||
import platform
|
||||
from typing import Callable, Optional, List, Dict, Any, Tuple
|
||||
from pynput import mouse, keyboard
|
||||
from pynput.mouse import Button
|
||||
@@ -22,10 +31,14 @@ from pynput.keyboard import Key, KeyCode
|
||||
|
||||
# Importation relative pour rester dans le module v1
|
||||
from ..vision.capturer import VisionCapturer
|
||||
from ..vision.system_info import get_screen_metadata
|
||||
# from ..monitoring.system import SystemMonitor
|
||||
|
||||
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)
|
||||
TEXT_FLUSH_DELAY = 0.5
|
||||
# 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
|
||||
# Timer pour le flush après inactivité
|
||||
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
|
||||
# au champ dans lequel l'utilisateur tape)
|
||||
self._last_mouse_pos: Tuple[int, int] = (0, 0)
|
||||
@@ -65,6 +83,17 @@ class EventCaptorV1:
|
||||
# Dernier clic : (x, y, timestamp, button)
|
||||
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):
|
||||
self.running = True
|
||||
self.mouse_listener = mouse.Listener(
|
||||
@@ -80,6 +109,9 @@ class EventCaptorV1:
|
||||
self.mouse_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)
|
||||
self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True)
|
||||
self._focus_thread.start()
|
||||
@@ -131,6 +163,7 @@ class EventCaptorV1:
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.on_event(event)
|
||||
# Réinitialiser pour éviter un triple-clic = 2 double-clics
|
||||
self._last_click = None
|
||||
@@ -144,6 +177,7 @@ class EventCaptorV1:
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.on_event(event)
|
||||
|
||||
def _on_scroll(self, x, y, dx, dy):
|
||||
@@ -168,7 +202,106 @@ class EventCaptorV1:
|
||||
return key.name
|
||||
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):
|
||||
# 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
|
||||
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
|
||||
self.modifiers.add("ctrl")
|
||||
@@ -176,26 +309,54 @@ class EventCaptorV1:
|
||||
self.modifiers.add("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
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) ---
|
||||
# Shift seul n'est pas un « vrai » modificateur pour les combos :
|
||||
# Shift+a = 'A' = saisie texte, pas un raccourci.
|
||||
# On considère un combo seulement si Ctrl ou Alt est enfoncé.
|
||||
has_real_modifier = self.modifiers & {"ctrl", "alt"}
|
||||
# On considère un combo seulement si Ctrl, Alt ou Win est enfoncé.
|
||||
has_real_modifier = self.modifiers & {"ctrl", "alt", "win"}
|
||||
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)
|
||||
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
|
||||
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 = {
|
||||
"type": "key_combo",
|
||||
"keys": list(self.modifiers) + [key_name],
|
||||
"raw_keys": raw_keys,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self._inject_screen_metadata(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
|
||||
|
||||
# --- Saisie texte (pas de Ctrl/Alt enfoncé) ---
|
||||
# --- Saisie texte (pas de Ctrl/Alt/Win enfoncé) ---
|
||||
self._handle_text_key(key)
|
||||
|
||||
def _handle_text_key(self, key):
|
||||
@@ -217,6 +378,7 @@ class EventCaptorV1:
|
||||
if key == Key.esc:
|
||||
# Annuler la saisie en cours
|
||||
self._text_buffer.clear()
|
||||
self._raw_key_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
@@ -234,31 +396,65 @@ class EventCaptorV1:
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
elif isinstance(key, KeyCode) and key.char is not None:
|
||||
elif isinstance(key, KeyCode):
|
||||
# Caractère alphanumérique / ponctuation
|
||||
# pynput renvoie déjà le bon caractère selon le layout
|
||||
# (AZERTY inclus) — on ne convertit rien.
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(key.char)
|
||||
self._reset_flush_timer()
|
||||
char = key.char
|
||||
|
||||
# AZERTY Windows : quand key.char est None (Shift+chiffres,
|
||||
# dead keys, etc.), utiliser ToUnicodeEx avec le layout clavier
|
||||
# actif pour obtenir le vrai caractère traduit par Windows.
|
||||
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
|
||||
else:
|
||||
# Touche spéciale non gérée (F1, Insert, etc.) — on ignore
|
||||
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()
|
||||
|
||||
# É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):
|
||||
"""Réarme le timer de flush après chaque frappe.
|
||||
|
||||
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:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_generation += 1
|
||||
gen = self._text_flush_generation
|
||||
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.start()
|
||||
@@ -272,18 +468,30 @@ class EventCaptorV1:
|
||||
self._text_flush_timer.cancel()
|
||||
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):
|
||||
"""Émet un événement text_input avec le contenu du buffer, puis
|
||||
le vide. Thread-safe — peut être appelé depuis le timer, le
|
||||
listener souris ou le listener clavier."""
|
||||
with self._text_lock:
|
||||
if not self._text_buffer:
|
||||
# Rien à émettre
|
||||
# Rien à émettre — purger aussi les raw_keys orphelins
|
||||
self._raw_key_buffer.clear()
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
text = "".join(self._text_buffer)
|
||||
pos = self._text_start_pos or self._last_mouse_pos
|
||||
raw_keys = list(self._raw_key_buffer)
|
||||
self._text_buffer.clear()
|
||||
self._raw_key_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
|
||||
@@ -295,32 +503,75 @@ class EventCaptorV1:
|
||||
"pos": pos,
|
||||
"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)
|
||||
|
||||
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):
|
||||
self.modifiers.discard("ctrl")
|
||||
elif key in (Key.alt, Key.alt_l, Key.alt_r):
|
||||
self.modifiers.discard("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
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):
|
||||
"""Surveille proactivement le changement de fenêtre pour le stagiaire."""
|
||||
# Importation relative simple
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
info = get_active_window_info()
|
||||
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 = {
|
||||
"type": "window_focus_change",
|
||||
"from": self.last_window,
|
||||
"to": info,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.last_window = info
|
||||
self.on_event(event)
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,17 +6,28 @@ Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (
|
||||
Supporte deux modes :
|
||||
- Watchdog fichier (command.json) — legacy
|
||||
- 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 hashlib
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
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
|
||||
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__)
|
||||
|
||||
@@ -68,6 +79,20 @@ class ActionExecutorV1:
|
||||
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."""
|
||||
@@ -180,11 +205,30 @@ class ActionExecutorV1:
|
||||
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":
|
||||
# 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_y = int(y_pct * height)
|
||||
button = action.get("button", "left")
|
||||
mode = "VISUAL" if result["visual_resolved"] else "BLIND"
|
||||
mode = "VISUAL" if result.get("visual_resolved") else "COORD"
|
||||
print(
|
||||
f" [CLICK] [{mode}] ({x_pct:.3f}, {y_pct:.3f}) -> "
|
||||
f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}"
|
||||
@@ -198,7 +242,10 @@ class ActionExecutorV1:
|
||||
|
||||
elif action_type == "type":
|
||||
text = action.get("text", "")
|
||||
raw_keys = action.get("raw_keys")
|
||||
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)
|
||||
if x_pct > 0 and y_pct > 0:
|
||||
real_x = int(x_pct * width)
|
||||
@@ -206,16 +253,26 @@ class ActionExecutorV1:
|
||||
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
|
||||
self._click((real_x, real_y), "left")
|
||||
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.")
|
||||
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":
|
||||
keys = action.get("keys", [])
|
||||
raw_keys = action.get("raw_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.")
|
||||
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":
|
||||
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.")
|
||||
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:
|
||||
result["error"] = f"Type d'action inconnu : {action_type}"
|
||||
logger.warning(result["error"])
|
||||
@@ -242,8 +318,33 @@ class ActionExecutorV1:
|
||||
|
||||
result["success"] = True
|
||||
|
||||
# Capturer un screenshot post-action
|
||||
time.sleep(0.5)
|
||||
# ---- Verification post-action : l'ecran a-t-il change ? ----
|
||||
# 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()
|
||||
|
||||
except Exception as e:
|
||||
@@ -257,62 +358,136 @@ class ActionExecutorV1:
|
||||
fallback_x: float, fallback_y: float,
|
||||
screen_width: int, screen_height: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Envoyer un screenshot au serveur pour resolution visuelle de la cible.
|
||||
"""Résoudre la position d'un clic visuellement.
|
||||
|
||||
Capture l'ecran en haute resolution (pas de downscale pour le template
|
||||
matching), l'encode en base64 JPEG, et POST au endpoint
|
||||
/replay/resolve_target. Retourne les coordonnees resolues.
|
||||
"""
|
||||
import requests
|
||||
Stratégie VLM-DIRECT : appelle Ollama directement depuis l'agent
|
||||
(pas via le serveur streaming) pour éviter les timeouts quand le
|
||||
serveur est occupé par le worker.
|
||||
|
||||
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:
|
||||
# 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"
|
||||
payload = {
|
||||
"session_id": "", # Pas critique pour la resolution
|
||||
"session_id": "",
|
||||
"screenshot_b64": screenshot_b64,
|
||||
"target_spec": target_spec,
|
||||
"fallback_x_pct": fallback_x,
|
||||
"fallback_y_pct": fallback_y,
|
||||
"screen_width": screen_width,
|
||||
"screen_height": screen_height,
|
||||
"strict_mode": True,
|
||||
}
|
||||
|
||||
resp = requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=60)
|
||||
resp = _requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=30)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
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')}"
|
||||
)
|
||||
print(f" [VISUAL] Serveur : resolved={data.get('resolved')}, method={data.get('method')}")
|
||||
return data
|
||||
else:
|
||||
logger.warning(f"Visual resolve HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("Visual resolve timeout (30s)")
|
||||
content = "{" + resp.json().get("message", {}).get("content", "")
|
||||
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
|
||||
except Exception as e:
|
||||
logger.warning(f"Visual resolve echoue: {e}")
|
||||
print(f" [VLM-DIRECT] Erreur: {e}")
|
||||
return None
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
# 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()
|
||||
action = data.get("action")
|
||||
if action is None:
|
||||
@@ -360,7 +546,7 @@ class ActionExecutorV1:
|
||||
self._poll_backoff * self._poll_backoff_factor,
|
||||
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
|
||||
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}")
|
||||
@@ -374,10 +560,6 @@ class ActionExecutorV1:
|
||||
logger.error(f"Erreur poll GET : {e}")
|
||||
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
|
||||
# TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution
|
||||
action_type = action.get('type', '?')
|
||||
@@ -412,6 +594,7 @@ class ActionExecutorV1:
|
||||
"action_id": result["action_id"],
|
||||
"success": result["success"],
|
||||
"error": result.get("error"),
|
||||
"warning": result.get("warning"),
|
||||
"screenshot": result.get("screenshot"),
|
||||
}
|
||||
try:
|
||||
@@ -438,10 +621,167 @@ class ActionExecutorV1:
|
||||
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
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):
|
||||
"""Deplacer la souris et cliquer.
|
||||
|
||||
@@ -500,6 +840,50 @@ class ActionExecutorV1:
|
||||
for mod in reversed(modifiers):
|
||||
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:
|
||||
"""
|
||||
Capturer l'ecran et retourner le screenshot en base64.
|
||||
@@ -512,8 +896,12 @@ class ActionExecutorV1:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
monitor = self.sct.monitors[1]
|
||||
raw = self.sct.grab(monitor)
|
||||
# Créer une instance mss locale (thread-safe)
|
||||
# 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")
|
||||
|
||||
# Redimensionner si max_width > 0
|
||||
@@ -530,5 +918,7 @@ class ActionExecutorV1:
|
||||
logger.debug("PIL non disponible, pas de screenshot base64")
|
||||
return ""
|
||||
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 ""
|
||||
|
||||
@@ -14,7 +14,10 @@ import uuid
|
||||
import time
|
||||
import logging
|
||||
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.executor import ActionExecutorV1
|
||||
from .network.streamer import TraceStreamer
|
||||
@@ -103,6 +106,14 @@ class AgentV1:
|
||||
self._capture_server = CaptureServer()
|
||||
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)
|
||||
self.ui = SmartTrayV1(
|
||||
self.start_session,
|
||||
@@ -142,8 +153,9 @@ class AgentV1:
|
||||
# Watchdog de Commandes (GHOST Replay — legacy fichier)
|
||||
threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
|
||||
|
||||
# Boucle de polling replay (P0-5 — pull depuis le serveur)
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
# Note: la boucle de polling replay est déjà lancée dans __init__ (ligne 102)
|
||||
# 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...")
|
||||
|
||||
@@ -159,7 +171,7 @@ class AgentV1:
|
||||
else:
|
||||
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
|
||||
if self._replay_active:
|
||||
time.sleep(1)
|
||||
@@ -197,8 +209,11 @@ class AgentV1:
|
||||
time.sleep(REPLAY_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# Utiliser la session active ou un ID par défaut pour le replay
|
||||
poll_session = self.session_id or f"agent_{self.user_id}"
|
||||
# TOUJOURS utiliser un session_id stable pour le replay.
|
||||
# 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)
|
||||
poll_count += 1
|
||||
@@ -290,18 +305,40 @@ class AgentV1:
|
||||
time.sleep(5)
|
||||
|
||||
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.streamer: self.streamer.stop()
|
||||
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 = ""
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
"""Capture périodique pour donner du contexte au stagiaire.
|
||||
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:
|
||||
full_path = self.vision.capture_full_context("heartbeat")
|
||||
if full_path:
|
||||
|
||||
@@ -413,10 +413,8 @@ class ChatWindow:
|
||||
|
||||
buttons = [
|
||||
("\U0001f393 Apprenez-moi", self._on_quick_record),
|
||||
("\u25b6\ufe0f Lancer", self._on_quick_tasks),
|
||||
("\U0001f4ca Donn\u00e9es", self._on_quick_import),
|
||||
("\u25b6\ufe0f Lancer une t\u00e2che", self._on_quick_tasks),
|
||||
("\u23f9\ufe0f Arr\u00eater", self._on_quick_stop),
|
||||
("\u2753 Aide", self._on_quick_help),
|
||||
]
|
||||
|
||||
for text, cmd in buttons:
|
||||
|
||||
195
agent_v0/agent_v1/vision/system_info.py
Normal file
195
agent_v0/agent_v1/vision/system_info.py
Normal 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(),
|
||||
}
|
||||
@@ -8,6 +8,23 @@ import platform
|
||||
import socket
|
||||
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"
|
||||
|
||||
# Identifiant unique de la machine (utilisé pour le multi-machine)
|
||||
|
||||
@@ -6,13 +6,25 @@ Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (
|
||||
Supporte deux modes :
|
||||
- Watchdog fichier (command.json) — legacy
|
||||
- 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 hashlib
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
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
|
||||
from pynput.mouse import Button, Controller as MouseController
|
||||
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_max = 30.0 # Delai maximal
|
||||
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
|
||||
def sct(self):
|
||||
@@ -171,6 +205,15 @@ class ActionExecutorV1:
|
||||
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":
|
||||
real_x = int(x_pct * width)
|
||||
real_y = int(y_pct * height)
|
||||
@@ -197,7 +240,7 @@ class ActionExecutorV1:
|
||||
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(0.3)
|
||||
self.keyboard.type(text)
|
||||
self._type_text(text)
|
||||
print(f" [TYPE] Termine.")
|
||||
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)")
|
||||
|
||||
@@ -226,6 +269,25 @@ class ActionExecutorV1:
|
||||
print(f" [WAIT] Termine.")
|
||||
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:
|
||||
result["error"] = f"Type d'action inconnu : {action_type}"
|
||||
logger.warning(result["error"])
|
||||
@@ -233,8 +295,41 @@ class ActionExecutorV1:
|
||||
|
||||
result["success"] = True
|
||||
|
||||
# Capturer un screenshot post-action
|
||||
time.sleep(0.5)
|
||||
# ---- Verification post-action : l'ecran a-t-il change ? ----
|
||||
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()
|
||||
|
||||
except Exception as e:
|
||||
@@ -251,17 +346,18 @@ class ActionExecutorV1:
|
||||
"""
|
||||
Envoyer un screenshot au serveur pour resolution visuelle de la cible.
|
||||
|
||||
Capture l'ecran en haute resolution (pas de downscale pour le template
|
||||
matching), l'encode en base64 JPEG, et POST au endpoint
|
||||
/replay/resolve_target. Retourne les coordonnees resolues.
|
||||
Capture l'ecran en resolution native (pas de downscale, necessaire pour
|
||||
le template matching precis cross-resolution), l'encode en base64 JPEG,
|
||||
et POST au endpoint /replay/resolve_target. Retourne les coordonnees resolues.
|
||||
"""
|
||||
import requests
|
||||
|
||||
try:
|
||||
# Capturer à 1280px max — assez pour le template matching
|
||||
# et raisonnable pour le transfert réseau (~200-400Ko)
|
||||
# Capturer à résolution native pour le template matching
|
||||
# (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(
|
||||
max_width=1280,
|
||||
max_width=0,
|
||||
quality=75,
|
||||
)
|
||||
if not screenshot_b64:
|
||||
@@ -283,9 +379,10 @@ class ActionExecutorV1:
|
||||
"fallback_y_pct": fallback_y,
|
||||
"screen_width": screen_width,
|
||||
"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:
|
||||
data = resp.json()
|
||||
method = data.get("method", "?")
|
||||
@@ -333,12 +430,24 @@ class ActionExecutorV1:
|
||||
resp = requests.get(
|
||||
replay_next_url,
|
||||
params={"session_id": session_id, "machine_id": machine_id},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
if not resp.ok:
|
||||
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
|
||||
|
||||
# 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()
|
||||
action = data.get("action")
|
||||
if action is None:
|
||||
@@ -350,7 +459,7 @@ class ActionExecutorV1:
|
||||
self._poll_backoff * self._poll_backoff_factor,
|
||||
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
|
||||
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}")
|
||||
@@ -364,10 +473,6 @@ class ActionExecutorV1:
|
||||
logger.error(f"Erreur poll GET : {e}")
|
||||
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
|
||||
# TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution
|
||||
action_type = action.get('type', '?')
|
||||
@@ -402,12 +507,14 @@ class ActionExecutorV1:
|
||||
"action_id": result["action_id"],
|
||||
"success": result["success"],
|
||||
"error": result.get("error"),
|
||||
"warning": result.get("warning"),
|
||||
"screenshot": result.get("screenshot"),
|
||||
}
|
||||
try:
|
||||
resp2 = requests.post(
|
||||
replay_result_url,
|
||||
json=report,
|
||||
headers=self._auth_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp2.ok:
|
||||
@@ -427,10 +534,167 @@ class ActionExecutorV1:
|
||||
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
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):
|
||||
"""Deplacer la souris et cliquer.
|
||||
|
||||
@@ -501,8 +765,12 @@ class ActionExecutorV1:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
monitor = self.sct.monitors[1]
|
||||
raw = self.sct.grab(monitor)
|
||||
# Créer une instance mss locale (thread-safe)
|
||||
# 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")
|
||||
|
||||
# Redimensionner si max_width > 0
|
||||
@@ -519,5 +787,7 @@ class ActionExecutorV1:
|
||||
logger.debug("PIL non disponible, pas de screenshot base64")
|
||||
return ""
|
||||
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 ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,16 +158,35 @@ class LiveSessionManager:
|
||||
session.events.append(event_data)
|
||||
session.last_activity = datetime.now()
|
||||
# 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")
|
||||
if window and isinstance(window, dict):
|
||||
session.last_window_info = window
|
||||
# Accumuler les titres/apps pour le nommage automatique
|
||||
title = window.get("title", "").strip()
|
||||
app_name = window.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
|
||||
elif event_data.get("window_title"):
|
||||
# Format Rust agent : extraire le titre et la résolution
|
||||
info = {
|
||||
"title": event_data["window_title"],
|
||||
"app_name": session.last_window_info.get("app_name", "unknown"),
|
||||
}
|
||||
# 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)
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
# 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 {
|
||||
"schema_version": "rawsession_v1",
|
||||
"session_id": session.session_id,
|
||||
"agent_version": "agent_v1_stream",
|
||||
"environment": {
|
||||
"os": platform.system().lower(),
|
||||
"hostname": socket.gethostname(),
|
||||
"machine_id": session.machine_id,
|
||||
"screen": {"primary_resolution": [1920, 1080]},
|
||||
},
|
||||
"environment": env_info,
|
||||
"user": {"id": "remote_agent"},
|
||||
"context": {
|
||||
"workflow": session.last_window_info.get("title", ""),
|
||||
|
||||
397
agent_v0/server_v1/run_worker.py
Normal file
397
agent_v0/server_v1/run_worker.py
Normal 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
6
core/auth/__init__.py
Normal 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
523
core/auth/auth_handler.py
Normal 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", ""),
|
||||
}
|
||||
298
core/auth/credential_vault.py
Normal file
298
core/auth/credential_vault.py
Normal 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
213
core/auth/manage_vault.py
Normal 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
183
core/auth/totp_generator.py
Normal 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)
|
||||
@@ -26,7 +26,7 @@ class OllamaClient:
|
||||
def __init__(self,
|
||||
endpoint: str = "http://localhost:11434",
|
||||
model: str = "qwen3-vl:8b",
|
||||
timeout: int = 60):
|
||||
timeout: int = 180):
|
||||
"""
|
||||
Initialiser le client Ollama
|
||||
|
||||
@@ -63,14 +63,21 @@ class OllamaClient:
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 0.1,
|
||||
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.
|
||||
|
||||
Note: On utilise /api/chat au lieu de /api/generate car qwen3-vl
|
||||
avec /api/generate consomme tous les tokens en thinking interne
|
||||
et retourne une réponse vide. L'API chat gère correctement
|
||||
le mode /no_think et sépare thinking/réponse.
|
||||
Pour les modèles thinking (qwen3-vl), on utilise la technique du
|
||||
"assistant prefill" : un message assistant pré-rempli est ajouté
|
||||
après le message user, forçant le modèle à continuer directement
|
||||
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:
|
||||
prompt: Prompt textuel
|
||||
@@ -80,6 +87,11 @@ class OllamaClient:
|
||||
temperature: Température de génération
|
||||
max_tokens: Nombre max de tokens
|
||||
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:
|
||||
Dict avec 'response', 'success', 'error'
|
||||
@@ -93,17 +105,19 @@ class OllamaClient:
|
||||
image_data = self._encode_image_from_pil(image)
|
||||
|
||||
# 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 = effective_prompt.replace("/nothink ", "").replace("/nothink", "")
|
||||
effective_prompt = effective_prompt.strip()
|
||||
|
||||
# Construire le message utilisateur
|
||||
user_message = {"role": "user", "content": effective_prompt}
|
||||
all_images = []
|
||||
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
|
||||
messages = []
|
||||
@@ -111,9 +125,37 @@ class OllamaClient:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
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()
|
||||
|
||||
# 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 = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
@@ -121,13 +163,13 @@ class OllamaClient:
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": 2048,
|
||||
"num_ctx": effective_num_ctx,
|
||||
"top_k": 1
|
||||
}
|
||||
}
|
||||
|
||||
# Désactiver le thinking pour les modèles qui le supportent
|
||||
# Cela réduit drastiquement la consommation de tokens et le temps
|
||||
# Garder think=false au cas où une future version d'Ollama le
|
||||
# corrige — le prefill reste le mécanisme principal
|
||||
if is_thinking_model:
|
||||
payload["think"] = False
|
||||
|
||||
@@ -144,6 +186,11 @@ class OllamaClient:
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
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 {
|
||||
"response": content,
|
||||
"success": True,
|
||||
@@ -181,8 +228,11 @@ For each element, provide:
|
||||
- Semantic role (primary_action, cancel, submit, form_input, search_field, navigation, settings, close)
|
||||
|
||||
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"]:
|
||||
try:
|
||||
@@ -214,14 +264,21 @@ Format your response as JSON."""
|
||||
Choose ONLY ONE from: {types_list}
|
||||
|
||||
Respond with just the type name, nothing else."""
|
||||
|
||||
|
||||
if 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"]:
|
||||
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
|
||||
valid_types = types_list.split(", ")
|
||||
if element_type in valid_types:
|
||||
@@ -255,14 +312,21 @@ Respond with just the type name, nothing else."""
|
||||
Choose ONLY ONE from: {roles_list}
|
||||
|
||||
Respond with just the role name, nothing else."""
|
||||
|
||||
|
||||
if 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"]:
|
||||
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
|
||||
valid_roles = roles_list.split(", ")
|
||||
if role in valid_roles:
|
||||
@@ -286,12 +350,19 @@ Respond with just the role name, nothing else."""
|
||||
Dict avec 'text' extrait
|
||||
"""
|
||||
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"]:
|
||||
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"]}
|
||||
|
||||
# Taille minimum pour une classification fiable par le VLM
|
||||
@@ -346,7 +417,8 @@ Your answer:"""
|
||||
system_prompt=system_prompt,
|
||||
temperature=0.1,
|
||||
max_tokens=300,
|
||||
force_json=False
|
||||
force_json=False,
|
||||
assistant_prefill="{"
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
|
||||
@@ -220,7 +220,7 @@ class UIDetector:
|
||||
# des centaines d'appels VLM inutiles (~2-3s chacun).
|
||||
# On garde max 80 candidats — suffisant pour obtenir ~50 éléments
|
||||
# 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:
|
||||
# Trier par confiance décroissante, puis par surface décroissante
|
||||
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:
|
||||
return None
|
||||
|
||||
# Construire la description des régions pour le prompt
|
||||
# Construire une description compacte des régions (économise les tokens)
|
||||
regions_desc_lines = []
|
||||
for i, r in enumerate(regions):
|
||||
regions_desc_lines.append(
|
||||
f" #{i}: position=({r.x},{r.y}), size={r.w}x{r.h}, source={r.source}"
|
||||
)
|
||||
regions_description = "\n".join(regions_desc_lines)
|
||||
regions_desc_lines.append(f"#{i}:({r.x},{r.y},{r.w}x{r.h})")
|
||||
regions_description = " ".join(regions_desc_lines)
|
||||
|
||||
prompt = f"""Analyze this screenshot. I have detected UI elements at these positions:
|
||||
{regions_description}
|
||||
prompt = f"""Classify UI elements at: {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:
|
||||
- "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."
|
||||
)
|
||||
system_prompt = "JSON-only UI classifier. No explanation."
|
||||
|
||||
# Appel VLM unique avec le screenshot complet
|
||||
for attempt in range(2):
|
||||
@@ -523,8 +509,10 @@ Your answer:"""
|
||||
image=pil_image,
|
||||
system_prompt=system_prompt,
|
||||
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,
|
||||
assistant_prefill="[", # Force JSON array direct, skip thinking
|
||||
num_ctx=2048, # 2048 suffit pour 10 régions compactes + image
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
|
||||
622
core/detection/ui_detector_old.py.bak
Normal file
622
core/detection/ui_detector_old.py.bak
Normal 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)
|
||||
@@ -125,18 +125,32 @@ class FusionEngine:
|
||||
weights: Dict[str, float]) -> np.ndarray:
|
||||
"""
|
||||
Fusion pondérée simple : somme pondérée des vecteurs
|
||||
|
||||
|
||||
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
|
||||
first_vector = next(iter(embeddings.values()))
|
||||
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():
|
||||
weight = weights.get(modality, 0.0)
|
||||
fused += weight * vector
|
||||
|
||||
raw_weight = weights.get(modality, 0.0)
|
||||
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
|
||||
|
||||
def _fuse_concat_projection(self,
|
||||
|
||||
@@ -112,7 +112,7 @@ class StateEmbeddingBuilder:
|
||||
metadata={
|
||||
"screen_state_id": screen_state.screen_state_id,
|
||||
"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()
|
||||
}
|
||||
)
|
||||
@@ -160,15 +160,16 @@ class StateEmbeddingBuilder:
|
||||
if ui_emb is not None:
|
||||
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:
|
||||
# Utiliser dimensions par défaut (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 = {
|
||||
"image": np.random.randn(default_dim).astype(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)
|
||||
"image": np.zeros(default_dim, dtype=np.float32)
|
||||
}
|
||||
|
||||
return embeddings
|
||||
@@ -243,7 +244,7 @@ class StateEmbeddingBuilder:
|
||||
|
||||
try:
|
||||
embedder = self.embedders["title"]
|
||||
title = getattr(screen_state.window, 'title', '')
|
||||
title = getattr(screen_state.window, 'window_title', '')
|
||||
|
||||
if not title:
|
||||
return None
|
||||
|
||||
24
core/federation/__init__.py
Normal file
24
core/federation/__init__.py
Normal 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",
|
||||
]
|
||||
354
core/federation/faiss_global.py
Normal file
354
core/federation/faiss_global.py
Normal 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
|
||||
961
core/federation/learning_pack.py
Normal file
961
core/federation/learning_pack.py
Normal 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
@@ -135,27 +135,48 @@ class ContextLevel:
|
||||
|
||||
@dataclass
|
||||
class WindowContext:
|
||||
"""Contexte de fenêtre"""
|
||||
"""Contexte de fenêtre avec métadonnées d'environnement graphique"""
|
||||
app_name: str
|
||||
window_title: str
|
||||
screen_resolution: List[int]
|
||||
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]:
|
||||
return {
|
||||
result = {
|
||||
"app_name": self.app_name,
|
||||
"window_title": self.window_title,
|
||||
"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
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'WindowContext':
|
||||
return cls(
|
||||
app_name=data["app_name"],
|
||||
window_title=data["window_title"],
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ class ScreenTemplate:
|
||||
|
||||
# Vérifier contraintes de fenêtre
|
||||
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', '')
|
||||
if not self.window.matches(window_title, process):
|
||||
return False, 0.0
|
||||
@@ -672,24 +672,94 @@ class Action:
|
||||
|
||||
@dataclass
|
||||
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)
|
||||
required_confidence: float = 0.8
|
||||
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]:
|
||||
return {
|
||||
"pre_conditions": self.pre_conditions,
|
||||
"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
|
||||
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(
|
||||
pre_conditions=data.get("pre_conditions", {}),
|
||||
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
|
||||
class PostConditions:
|
||||
"""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"
|
||||
timeout_ms: int = 2500
|
||||
poll_ms: int = 200
|
||||
|
||||
|
||||
success: 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
|
||||
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é)
|
||||
expected_node: Optional[str] = None # Node attendu après action
|
||||
window_change_expected: bool = False
|
||||
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]:
|
||||
return {
|
||||
"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],
|
||||
"retries": self.retries,
|
||||
"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
|
||||
"expected_node": self.expected_node,
|
||||
"window_change_expected": self.window_change_expected,
|
||||
"new_ui_elements_expected": self.new_ui_elements_expected
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PostConditions':
|
||||
success_checks = []
|
||||
for c in data.get("success", []):
|
||||
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))
|
||||
|
||||
|
||||
fail_fast_checks = []
|
||||
for c in data.get("fail_fast", []):
|
||||
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))
|
||||
|
||||
|
||||
return cls(
|
||||
success_mode=data.get("success_mode", "all"),
|
||||
timeout_ms=data.get("timeout_ms", 2500),
|
||||
@@ -761,6 +913,10 @@ class PostConditions:
|
||||
fail_fast=fail_fast_checks,
|
||||
retries=data.get("retries", 2),
|
||||
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
|
||||
expected_node=data.get("expected_node"),
|
||||
window_change_expected=data.get("window_change_expected", False),
|
||||
|
||||
@@ -321,6 +321,12 @@ class ScreenAnalyzer:
|
||||
window_title=window_info.get("title", "Unknown"),
|
||||
screen_resolution=window_info.get("screen_resolution", [1920, 1080]),
|
||||
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(
|
||||
app_name="unknown",
|
||||
|
||||
275
deploy/build_lea_exe.sh
Executable file
275
deploy/build_lea_exe.sh
Executable 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
166
deploy/build_package.sh
Executable 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}"
|
||||
85
deploy/lea_package/LISEZMOI.txt
Normal file
85
deploy/lea_package/LISEZMOI.txt
Normal 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".
|
||||
|
||||
============================================================
|
||||
54
deploy/lea_package/Lea.bat
Normal file
54
deploy/lea_package/Lea.bat
Normal 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
|
||||
)
|
||||
31
deploy/lea_package/config.txt
Normal file
31
deploy/lea_package/config.txt
Normal 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
|
||||
127
deploy/lea_package/install.bat
Normal file
127
deploy/lea_package/install.bat
Normal 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
|
||||
13
deploy/lea_package/requirements_agent.txt
Normal file
13
deploy/lea_package/requirements_agent.txt
Normal 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'
|
||||
1
docs/RAPPORT_CONFORMITE_AI_ACT.md
Normal file
1
docs/RAPPORT_CONFORMITE_AI_ACT.md
Normal file
File diff suppressed because one or more lines are too long
92
docs/architecture_data_extraction.md
Normal file
92
docs/architecture_data_extraction.md
Normal file
File diff suppressed because one or more lines are too long
@@ -18,4 +18,5 @@ vwb-backend|5002|visual_workflow_builder/backend/app.py|required
|
||||
monitoring|5003|monitoring_server.py|optional
|
||||
agent-chat|5004|agent_chat/app.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
|
||||
|
||||
22
svc.sh
22
svc.sh
@@ -54,6 +54,7 @@ declare -A PORTS=(
|
||||
[monitoring]=5003
|
||||
[agent-chat]=5004
|
||||
[streaming]=5005
|
||||
[worker]=5099
|
||||
[vwb-frontend]=3002
|
||||
)
|
||||
|
||||
@@ -63,14 +64,15 @@ declare -A SYSTEMD_UNITS=(
|
||||
[vwb-backend]="rpa-vwb-backend.service"
|
||||
[agent-chat]="rpa-agent-chat.service"
|
||||
[streaming]="rpa-streaming.service"
|
||||
[worker]="rpa-worker.service"
|
||||
[vwb-frontend]="rpa-vwb-frontend.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
|
||||
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=(
|
||||
[api]="$VENV_DIR/bin/python3 server/api_upload.py"
|
||||
@@ -79,6 +81,7 @@ declare -A COMMANDS=(
|
||||
[monitoring]="$VENV_DIR/bin/python3 monitoring_server.py"
|
||||
[agent-chat]="$VENV_DIR/bin/python3 -m agent_chat.app"
|
||||
[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"
|
||||
)
|
||||
|
||||
@@ -86,8 +89,8 @@ declare -A COMMANDS=(
|
||||
declare -A SVC_GROUPS=(
|
||||
[vwb]="vwb-backend vwb-frontend"
|
||||
[all]="api dashboard vwb-backend vwb-frontend"
|
||||
[full]="api dashboard vwb-backend vwb-frontend monitoring agent-chat streaming"
|
||||
[boot]="streaming agent-chat dashboard vwb-backend vwb-frontend"
|
||||
[full]="api dashboard vwb-backend vwb-frontend monitoring agent-chat streaming worker"
|
||||
[boot]="streaming worker agent-chat dashboard vwb-backend vwb-frontend"
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
@@ -350,7 +353,7 @@ do_install() {
|
||||
|
||||
# Vérifier que les fichiers existent
|
||||
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
|
||||
echo -e " ${GREEN}OK${NC} $unit"
|
||||
else
|
||||
@@ -394,7 +397,7 @@ do_enable() {
|
||||
echo -e "${CYAN}${BOLD}Activation du demarrage automatique au boot...${NC}"
|
||||
systemctl --user daemon-reload
|
||||
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
|
||||
echo -e " ${GREEN}OK${NC} $unit"
|
||||
done
|
||||
@@ -405,7 +408,7 @@ do_enable() {
|
||||
do_disable() {
|
||||
echo -e "${YELLOW}${BOLD}Desactivation du demarrage automatique...${NC}"
|
||||
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
|
||||
echo -e " ${GREEN}OK${NC} $unit"
|
||||
done
|
||||
@@ -429,7 +432,8 @@ show_help() {
|
||||
echo " disable Desactiver le demarrage auto au boot"
|
||||
echo ""
|
||||
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 " dashboard Web Dashboard (port 5001)"
|
||||
echo " vwb-backend VWB Backend Flask (port 5002)"
|
||||
@@ -438,7 +442,7 @@ show_help() {
|
||||
echo " monitoring Monitoring (port 5003) [legacy uniquement]"
|
||||
echo ""
|
||||
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 " all Core (api, dashboard, vwb)"
|
||||
echo " full Tous les services"
|
||||
|
||||
@@ -220,7 +220,7 @@ class TestStreamWorker:
|
||||
event_file = session_dir / "live_events.jsonl"
|
||||
event_file.write_text(
|
||||
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
|
||||
|
||||
576
tests/unit/test_auth.py
Normal file
576
tests/unit/test_auth.py
Normal 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
|
||||
721
tests/unit/test_learning_pack.py
Normal file
721
tests/unit/test_learning_pack.py
Normal 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
|
||||
@@ -55,6 +55,8 @@ def list_learned_workflows():
|
||||
|
||||
Query params:
|
||||
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:
|
||||
{
|
||||
@@ -76,6 +78,7 @@ def list_learned_workflows():
|
||||
}
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -132,6 +135,14 @@ def list_learned_workflows():
|
||||
if 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
|
||||
for wf in merged:
|
||||
existing = Workflow.query.filter(
|
||||
|
||||
@@ -58,9 +58,15 @@ db.init_app(app)
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
cache = Cache(app)
|
||||
_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3002",
|
||||
"http://localhost:5002",
|
||||
"https://vwb.labs.laurinebazin.design",
|
||||
"https://lea.labs.laurinebazin.design",
|
||||
]
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins="*",
|
||||
cors_allowed_origins=_ALLOWED_ORIGINS,
|
||||
async_mode='threading',
|
||||
logger=True,
|
||||
engineio_logger=True
|
||||
@@ -204,6 +210,16 @@ def set_security_headers(response):
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ import os
|
||||
import json
|
||||
import requests
|
||||
import re
|
||||
try:
|
||||
from vlm_provider import vlm_hub
|
||||
except ImportError:
|
||||
from visual_workflow_builder.backend.vlm_provider import vlm_hub
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -624,21 +629,33 @@ def find_anchor_with_vlm(
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Utilise un VLM (Vision Language Model) pour trouver l'ancre sur l'écran.
|
||||
|
||||
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é
|
||||
En priorité via Gemini Cloud (vlm_hub), sinon via Ollama local.
|
||||
"""
|
||||
# 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():
|
||||
print("⚠️ [VLM] Ollama/qwen2.5vl non disponible, fallback sur pyautogui")
|
||||
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
|
||||
"""
|
||||
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:
|
||||
return None
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user