Files
rpa_vision_v3/.kiro/specs/rpa-critical-fixes/design.md
Dom a7de6a488b feat: replay E2E fonctionnel — 25/25 actions, 0 retries, SomEngine via serveur
Validé sur PC Windows (DESKTOP-58D5CAC, 2560x1600) :
- 8 clics résolus visuellement (1 anchor_template, 1 som_text_match, 6 som_vlm)
- Score moyen 0.75, temps moyen 1.6s
- Texte tapé correctement (bonjour, test word, date, email)
- 0 retries, 2 actions non vérifiées (OK)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:04:41 +02:00

25 KiB

Design Document - Corrections Critiques RPA Vision V3

Vue d'Ensemble

Ce document spécifie le design pour corriger les problèmes critiques identifiés dans RPA Vision V3. Les corrections visent à établir l'exécution automatique des workflows, la stabilité en production, et des performances acceptables tout en préservant la qualité et la logique des fonctionnalités existantes.

Architecture

L'architecture des corrections suit une approche de refactoring progressif qui préserve les fonctionnalités existantes tout en corrigeant les problèmes structurels :

┌─────────────────────────────────────────────────────────────────┐
│                Architecture des Corrections Critiques           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────┐ │
│  │  WorkflowPipeline│    │  ActionExecutor │    │ TargetResolver│ │
│  │  Enhanced       │    │  Integration    │    │ Optimized   │ │
│  │                 │    │                 │    │             │ │
│  │ • execute_      │◄──►│ • Unified Error │◄──►│ • Spatial   │ │
│  │   workflow_step │    │   Handling      │    │   Index     │ │
│  │ • Error Recovery│    │ • Target        │    │   Cache     │ │
│  │ • State Mgmt    │    │   Resolution    │    │ • Memory    │ │
│  └─────────────────┘    └─────────────────┘    │   Mgmt      │ │
│           │                       │            └─────────────┘ │
│           │                       │                       │     │
│  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────┐ │
│  │  ErrorHandler   │    │  DataContracts  │    │ ConfigMgr   │ │
│  │  Centralized    │    │  Standardized   │    │ Centralized │ │
│  │                 │    │                 │    │             │ │
│  │ • Recovery      │    │ • BBox (x,y,w,h)│    │ • Validation│ │
│  │   Strategies    │    │ • datetime      │    │ • Security  │ │
│  │ • Escalation    │    │ • string IDs    │    │ • Env Adapt │ │
│  │ • Audit Trail   │    │ • Pydantic      │    │             │ │
│  └─────────────────┘    └─────────────────┘    └─────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Composants et Interfaces

1. Intégration WorkflowPipeline-ActionExecutor

Problème : WorkflowPipeline ne utilise pas ActionExecutor pour l'exécution automatique.

Solution : Extension de WorkflowPipeline avec une méthode d'exécution intégrée :

class WorkflowPipeline:
    def __init__(self, target_resolver: TargetResolver, 
                 action_executor: ActionExecutor,
                 error_handler: ErrorHandler):
        self.target_resolver = target_resolver
        self.action_executor = action_executor
        self.error_handler = error_handler
    
    def execute_workflow_step(self, workflow_id: str, 
                             current_state: ScreenState) -> ExecutionResult:
        """
        Exécute une étape de workflow de bout en bout
        
        Processus:
        1. Matcher l'état actuel avec le workflow
        2. Obtenir la prochaine action à exécuter
        3. Résoudre la cible avec TargetResolver
        4. Exécuter l'action avec ActionExecutor
        5. Gérer les erreurs avec ErrorHandler
        6. Retourner le résultat détaillé
        """
        try:
            # 1. Match current state
            match_result = self.match_current_state(current_state)
            if not match_result:
                return ExecutionResult.no_match()
            
            # 2. Get next action
            action = self.get_next_action(workflow_id, match_result.node_id)
            if not action:
                return ExecutionResult.workflow_complete()
            
            # 3. Execute with integrated error handling
            result = self.action_executor.execute_action(action, current_state)
            
            return ExecutionResult.success(result, match_result)
            
        except Exception as e:
            recovery = self.error_handler.handle_error(e, {
                'workflow_id': workflow_id,
                'current_state': current_state,
                'action': action if 'action' in locals() else None
            })
            
            if recovery.should_retry:
                return self.execute_workflow_step(workflow_id, current_state)
            else:
                return ExecutionResult.error(e, recovery)

2. Gestion d'Erreurs Unifiée

Problème : Gestion d'erreurs fragmentée, ErrorHandler non utilisé partout.

Solution : Centralisation avec ErrorHandler et stratégies de récupération :

class ErrorHandler:
    def __init__(self):
        self.recovery_strategies = {
            TargetNotFoundError: SpatialFallbackStrategy(),
            UIElementChangedError: SemanticVariantStrategy(),
            NetworkError: RetryWithBackoffStrategy(),
            ValidationError: DataNormalizationStrategy()
        }
    
    def handle_error(self, error: Exception, context: Dict[str, Any]) -> RecoveryResult:
        """
        Gère une erreur avec stratégie de récupération appropriée
        
        Processus:
        1. Identifier le type d'erreur
        2. Sélectionner la stratégie de récupération
        3. Appliquer la stratégie avec le contexte
        4. Logger la tentative de récupération
        5. Retourner le résultat de récupération
        """
        error_type = type(error)
        strategy = self.recovery_strategies.get(error_type)
        
        if not strategy:
            return RecoveryResult.escalate(error, "No recovery strategy available")
        
        try:
            recovery = strategy.recover(error, context)
            self._log_recovery_attempt(error, strategy, recovery, context)
            return recovery
            
        except Exception as recovery_error:
            return RecoveryResult.escalate(
                error, 
                f"Recovery failed: {recovery_error}",
                original_error=error
            )

# Pattern d'utilisation dans tous les composants
def risky_operation():
    try:
        result = perform_operation()
        return result
    except Exception as e:
        recovery = error_handler.handle_error(e, get_context())
        if recovery.should_retry:
            return risky_operation()  # Retry with recovery
        else:
            raise ProcessingError(f"Operation failed after recovery: {e}")

3. Résolution des Imports Circulaires

Problème : Structure d'imports complexe avec risques de circularité.

Solution : Refactoring avec imports lazy et interfaces abstraites :

# core/models/__init__.py - Version corrigée
from typing import TYPE_CHECKING

# Imports directs pour les types de base
from .base_models import BaseModel, BBox, Timestamp
from .ui_element import UIElement
from .screen_state import ScreenState

# Imports lazy pour éviter les cycles
if TYPE_CHECKING:
    from .workflow_graph import WorkflowGraph, WorkflowNode
    from .resolved_target import ResolvedTarget

def get_workflow_graph():
    """Lazy import pour WorkflowGraph"""
    from .workflow_graph import WorkflowGraph
    return WorkflowGraph

def get_resolved_target():
    """Lazy import pour ResolvedTarget"""
    from .resolved_target import ResolvedTarget
    return ResolvedTarget

# Interfaces abstraites pour découpler les composants
from abc import ABC, abstractmethod

class ITargetResolver(ABC):
    @abstractmethod
    def resolve_target(self, target_spec: 'TargetSpec', 
                      ui_elements: List['UIElement']) -> Optional['ResolvedTarget']:
        pass

class IActionExecutor(ABC):
    @abstractmethod
    def execute_action(self, action: 'Action', 
                      screen_state: 'ScreenState') -> 'ExecutionResult':
        pass

4. Standardisation des Contrats de Données

Problème : Formats de données incohérents entre couches.

Solution : Standardisation avec Pydantic et utilitaires de conversion :

from pydantic import BaseModel, validator, Field
from typing import Tuple, Union
from datetime import datetime

class BBox(BaseModel):
    """Bounding box standardisée au format (x, y, width, height)"""
    x: int = Field(..., ge=0, description="Position X (coin supérieur gauche)")
    y: int = Field(..., ge=0, description="Position Y (coin supérieur gauche)")
    width: int = Field(..., gt=0, description="Largeur")
    height: int = Field(..., gt=0, description="Hauteur")
    
    @validator('*', pre=True)
    def validate_positive(cls, v):
        if isinstance(v, (int, float)) and v < 0:
            raise ValueError("Coordinates must be non-negative")
        return int(v)
    
    def to_tuple(self) -> Tuple[int, int, int, int]:
        """Conversion vers tuple (x, y, w, h)"""
        return (self.x, self.y, self.width, self.height)
    
    @classmethod
    def from_xyxy(cls, x1: int, y1: int, x2: int, y2: int) -> 'BBox':
        """Conversion depuis format (x1, y1, x2, y2)"""
        return cls(
            x=min(x1, x2),
            y=min(y1, y2),
            width=abs(x2 - x1),
            height=abs(y2 - y1)
        )

class UIElement(BaseModel):
    """Élément UI avec contrats de données standardisés"""
    element_id: str = Field(..., description="ID unique de l'élément")
    bbox: BBox = Field(..., description="Boîte englobante")
    timestamp: datetime = Field(default_factory=datetime.now, description="Timestamp de création")
    confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="Confiance de détection")
    
    @validator('element_id')
    def validate_element_id(cls, v):
        if not isinstance(v, str) or not v.strip():
            raise ValueError("element_id must be a non-empty string")
        return v.strip()

# Utilitaires de conversion sûrs
class DataConverter:
    @staticmethod
    def ensure_bbox_xywh(bbox: Union[BBox, Tuple, List, Dict]) -> BBox:
        """Assure que bbox est au format (x, y, w, h)"""
        if isinstance(bbox, BBox):
            return bbox
        elif isinstance(bbox, (tuple, list)) and len(bbox) == 4:
            return BBox(x=bbox[0], y=bbox[1], width=bbox[2], height=bbox[3])
        elif isinstance(bbox, dict):
            return BBox(**bbox)
        else:
            raise ValueError(f"Cannot convert {type(bbox)} to BBox")
    
    @staticmethod
    def ensure_datetime(timestamp: Union[datetime, str, int, float]) -> datetime:
        """Assure que timestamp est un objet datetime"""
        if isinstance(timestamp, datetime):
            return timestamp
        elif isinstance(timestamp, str):
            return datetime.fromisoformat(timestamp)
        elif isinstance(timestamp, (int, float)):
            return datetime.fromtimestamp(timestamp)
        else:
            raise ValueError(f"Cannot convert {type(timestamp)} to datetime")

5. Optimisation des Performances

Problème : Goulots d'étranglement dans TargetResolver et gestion des embeddings.

Solution : Cache intelligent et réutilisation des ressources :

from functools import lru_cache
from typing import Optional, Dict, Any
import weakref

class OptimizedTargetResolver:
    def __init__(self):
        self._spatial_index_cache: Dict[str, Any] = {}
        self._embedding_cache = weakref.WeakValueDictionary()
        self._model_cache: Dict[str, Any] = {}
    
    @lru_cache(maxsize=100)
    def get_spatial_index(self, screen_signature: str) -> 'SpatialIndex':
        """Cache de l'index spatial par signature d'écran"""
        if screen_signature not in self._spatial_index_cache:
            self._spatial_index_cache[screen_signature] = self._build_spatial_index()
        return self._spatial_index_cache[screen_signature]
    
    def get_embedding(self, element_id: str, force_reload: bool = False) -> Optional[np.ndarray]:
        """Lazy loading des embeddings avec cache"""
        if not force_reload and element_id in self._embedding_cache:
            return self._embedding_cache[element_id]
        
        embedding = self._load_embedding_from_disk(element_id)
        if embedding is not None:
            self._embedding_cache[element_id] = embedding
        return embedding
    
    def get_model(self, model_name: str) -> Any:
        """Cache des modèles ML chargés"""
        if model_name not in self._model_cache:
            self._model_cache[model_name] = self._load_model(model_name)
        return self._model_cache[model_name]
    
    def resolve_target(self, target_spec: TargetSpec, 
                      ui_elements: List[UIElement]) -> Optional[ResolvedTarget]:
        """Résolution optimisée avec réutilisation des ressources"""
        # Réutiliser l'index spatial
        screen_sig = self._compute_screen_signature(ui_elements)
        spatial_index = self.get_spatial_index(screen_sig)
        
        # Éviter les calculs redondants
        cache_key = self._compute_resolution_cache_key(target_spec, screen_sig)
        if cache_key in self._resolution_cache:
            return self._resolution_cache[cache_key]
        
        # Résolution normale avec cache
        result = self._resolve_with_cache(target_spec, ui_elements, spatial_index)
        self._resolution_cache[cache_key] = result
        return result

6. Gestion Mémoire Robuste

Problème : Fuites mémoire et consommation croissante.

Solution : Nettoyage automatique et limites effectives :

import gc
import psutil
from threading import Timer
from typing import Dict, Any

class MemoryManager:
    def __init__(self, max_memory_mb: int = 1024):
        self.max_memory_mb = max_memory_mb
        self.cleanup_timer: Optional[Timer] = None
        self.resource_registry: Dict[str, Any] = {}
        
    def register_resource(self, resource_id: str, resource: Any, 
                         cleanup_func: callable = None):
        """Enregistre une ressource pour nettoyage automatique"""
        self.resource_registry[resource_id] = {
            'resource': resource,
            'cleanup_func': cleanup_func or (lambda x: None),
            'created_at': datetime.now()
        }
    
    def check_memory_usage(self) -> float:
        """Vérifie l'utilisation mémoire actuelle"""
        process = psutil.Process()
        memory_mb = process.memory_info().rss / 1024 / 1024
        return memory_mb
    
    def cleanup_if_needed(self):
        """Nettoyage préventif si nécessaire"""
        current_memory = self.check_memory_usage()
        
        if current_memory > self.max_memory_mb * 0.8:  # 80% threshold
            self._perform_cleanup()
    
    def _perform_cleanup(self):
        """Effectue le nettoyage des ressources"""
        # 1. Nettoyer les embeddings anciens
        self._cleanup_old_embeddings()
        
        # 2. Libérer les ressources GPU
        self._cleanup_gpu_resources()
        
        # 3. Vider les caches LRU
        self._clear_lru_caches()
        
        # 4. Force garbage collection
        gc.collect()
    
    def _cleanup_old_embeddings(self):
        """Nettoie les embeddings anciens"""
        cutoff_time = datetime.now() - timedelta(hours=1)
        to_remove = []
        
        for resource_id, info in self.resource_registry.items():
            if (info['created_at'] < cutoff_time and 
                'embedding' in resource_id.lower()):
                info['cleanup_func'](info['resource'])
                to_remove.append(resource_id)
        
        for resource_id in to_remove:
            del self.resource_registry[resource_id]
    
    def shutdown(self):
        """Nettoyage complet à l'arrêt"""
        for info in self.resource_registry.values():
            try:
                info['cleanup_func'](info['resource'])
            except Exception as e:
                logger.warning(f"Error during resource cleanup: {e}")
        
        self.resource_registry.clear()
        gc.collect()

# Cache LRU avec limite effective
class EffectiveLRUCache:
    def __init__(self, maxsize: int = 128, memory_limit_mb: int = 100):
        self.maxsize = maxsize
        self.memory_limit_mb = memory_limit_mb
        self.cache: Dict[Any, Any] = {}
        self.access_order: List[Any] = []
    
    def get(self, key: Any) -> Any:
        if key in self.cache:
            self.access_order.remove(key)
            self.access_order.append(key)
            return self.cache[key]
        return None
    
    def put(self, key: Any, value: Any):
        # Vérifier les limites avant d'ajouter
        self._enforce_limits()
        
        if key in self.cache:
            self.access_order.remove(key)
        elif len(self.cache) >= self.maxsize:
            self._evict_lru()
        
        self.cache[key] = value
        self.access_order.append(key)
    
    def _enforce_limits(self):
        """Applique les limites de taille et mémoire"""
        current_memory = self._estimate_memory_usage()
        
        while (len(self.cache) >= self.maxsize or 
               current_memory > self.memory_limit_mb):
            if not self.access_order:
                break
            self._evict_lru()
            current_memory = self._estimate_memory_usage()

Modèles de Données

ExecutionResult Étendu

@dataclass
class ExecutionResult:
    """Résultat d'exécution avec métadonnées complètes"""
    success: bool
    action_executed: Optional[str] = None
    target_resolved: Optional[ResolvedTarget] = None
    error: Optional[Exception] = None
    recovery_applied: Optional[RecoveryResult] = None
    performance_metrics: Dict[str, float] = field(default_factory=dict)
    correlation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    
    @classmethod
    def success(cls, result: Any, match_result: Any) -> 'ExecutionResult':
        return cls(
            success=True,
            action_executed=str(result.action),
            target_resolved=result.target,
            performance_metrics=result.metrics
        )
    
    @classmethod
    def error(cls, error: Exception, recovery: RecoveryResult) -> 'ExecutionResult':
        return cls(
            success=False,
            error=error,
            recovery_applied=recovery
        )

@dataclass
class RecoveryResult:
    """Résultat d'une tentative de récupération"""
    should_retry: bool
    strategy_used: str
    recovery_data: Dict[str, Any] = field(default_factory=dict)
    escalation_reason: Optional[str] = None
    
    @classmethod
    def retry(cls, strategy: str, data: Dict[str, Any] = None) -> 'RecoveryResult':
        return cls(should_retry=True, strategy_used=strategy, recovery_data=data or {})
    
    @classmethod
    def escalate(cls, error: Exception, reason: str, **kwargs) -> 'RecoveryResult':
        return cls(
            should_retry=False, 
            strategy_used="escalation",
            escalation_reason=reason,
            recovery_data=kwargs
        )

Propriétés de Correction

Une propriété est une caractéristique ou un comportement qui doit être vrai dans toutes les exécutions valides d'un système - essentiellement, une déclaration formelle sur ce que le système doit faire. Les propriétés servent de pont entre les spécifications lisibles par l'homme et les garanties de correction vérifiables par machine.

Propriété 1: Intégration WorkflowPipeline-ActionExecutor

Pour tout workflow traité par WorkflowPipeline, ActionExecutor doit être utilisé pour l'exécution des actions Valide: Exigences 1.1, 1.2

Propriété 2: Gestion d'erreurs unifiée

Pour toute erreur survenant dans le système, ErrorHandler doit être utilisé pour appliquer une stratégie de récupération appropriée Valide: Exigences 2.1, 2.2

Propriété 3: Absence d'imports circulaires

Pour tout module du système, l'importation ne doit pas créer de dépendances cycliques Valide: Exigences 3.1, 3.3

Propriété 4: Cohérence des contrats de données BBox

Pour toute coordonnée BBox utilisée dans le système, le format doit être exclusivement (x,y,w,h) Valide: Exigences 4.1

Propriété 5: Cohérence des timestamps

Pour tout timestamp manipulé dans le système, il doit être un objet datetime Valide: Exigences 4.2

Propriété 6: Réutilisation de l'index spatial

Pour toute résolution de TargetResolver avec la même signature d'écran, l'index spatial doit être réutilisé Valide: Exigences 5.1

Propriété 7: Limites effectives des caches LRU

Pour tout cache LRU utilisé, les limites de taille configurées doivent être respectées Valide: Exigences 6.1

Propriété 8: Libération des ressources GPU

Pour toute ressource GPU allouée, elle doit être libérée après utilisation Valide: Exigences 6.3

Propriété 9: Validation des inputs de sécurité

Pour tout input utilisateur reçu, il doit être validé contre les injections malveillantes Valide: Exigences 7.2

Propriété 10: Interfaces abstraites pour le découplage

Pour toute interaction entre composants, elle doit passer par des interfaces abstraites Valide: Exigences 8.1

Propriété 11: Correlation IDs uniques

Pour toute opération effectuée, un correlation ID unique doit être assigné Valide: Exigences 9.1

Propriété 12: Centralisation des constantes

Pour toute constante nécessaire au système, elle doit être centralisée dans core/config.py Valide: Exigences 10.1

Gestion d'Erreurs

Stratégies de Récupération

  1. SpatialFallbackStrategy : Utilise des critères spatiaux alternatifs
  2. SemanticVariantStrategy : Essaie des variantes sémantiques du texte
  3. RetryWithBackoffStrategy : Retry avec délai exponentiel
  4. DataNormalizationStrategy : Normalise et convertit les données
  5. GracefulDegradationStrategy : Continue avec fonctionnalité réduite

Escalade d'Erreurs

class ErrorEscalation:
    def escalate_error(self, error: Exception, context: Dict[str, Any], 
                      recovery_attempts: List[RecoveryResult]) -> None:
        """
        Escalade une erreur après échec des récupérations
        
        Processus:
        1. Collecter le contexte complet
        2. Documenter les tentatives de récupération
        3. Créer un rapport d'erreur détaillé
        4. Notifier les systèmes de monitoring
        5. Préserver l'état pour diagnostic
        """

Stratégie de Test

Tests Unitaires

  • Test d'intégration WorkflowPipeline-ActionExecutor
  • Test des stratégies de récupération d'erreurs
  • Test de résolution des imports circulaires
  • Test de conversion des contrats de données
  • Test des optimisations de performance
  • Test de gestion mémoire

Tests de Propriétés

Utilisation du framework Hypothesis avec 100+ itérations par propriété :

  • Propriété 1 : Intégration WorkflowPipeline-ActionExecutor
  • Propriété 2 : Gestion d'erreurs unifiée
  • Propriété 3 : Absence d'imports circulaires
  • Propriété 4 : Cohérence des contrats de données BBox
  • Propriété 5 : Cohérence des timestamps
  • Propriété 6 : Réutilisation de l'index spatial
  • Propriété 7 : Limites effectives des caches LRU
  • Propriété 8 : Libération des ressources GPU
  • Propriété 9 : Validation des inputs de sécurité
  • Propriété 10 : Interfaces abstraites pour le découplage
  • Propriété 11 : Correlation IDs uniques
  • Propriété 12 : Centralisation des constantes

Tests d'Intégration

  • Exécution de bout en bout des workflows
  • Test de charge avec gestion mémoire
  • Test de sécurité en environnement de production
  • Test de performance avec métriques détaillées
  • Test de récupération d'erreurs en conditions réelles

Validation de Non-Régression

Principe Critique : Chaque correction doit préserver la qualité et la logique des fonctionnalités existantes.

class RegressionValidator:
    def validate_no_regression(self, before_state: SystemState, 
                              after_state: SystemState) -> ValidationResult:
        """
        Valide qu'aucune régression n'a été introduite
        
        Vérifications:
        1. Toutes les fonctionnalités existantes fonctionnent
        2. Les performances ne se dégradent pas
        3. Les contrats d'API sont préservés
        4. Les tests existants continuent de passer
        5. La qualité du code est maintenue ou améliorée
        """