Files
rpa_vision_v3/core/federation/faiss_global.py
Dom d5deac3029 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>
2026-03-26 10:19:18 +01:00

355 lines
11 KiB
Python

"""
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