v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution

- Frontend v4 accessible sur réseau local (192.168.1.40)
- Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard)
- Ollama GPU fonctionnel
- Self-healing interactif
- Dashboard confiance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-29 11:23:51 +01:00
parent 21bfa3b337
commit a27b74cf22
1595 changed files with 412691 additions and 400 deletions

View File

@@ -0,0 +1,15 @@
"""Recovery strategies for self-healing workflows."""
from .base_strategy import RecoveryStrategy
from .semantic_variants import SemanticVariantStrategy
from .spatial_fallback import SpatialFallbackStrategy
from .timing_adaptation import TimingAdaptationStrategy
from .format_transformation import FormatTransformationStrategy
__all__ = [
'RecoveryStrategy',
'SemanticVariantStrategy',
'SpatialFallbackStrategy',
'TimingAdaptationStrategy',
'FormatTransformationStrategy',
]

View File

@@ -0,0 +1,50 @@
"""Base class for recovery strategies."""
from abc import ABC, abstractmethod
from typing import Optional
from ..models import RecoveryContext, RecoveryResult
class RecoveryStrategy(ABC):
"""Abstract base class for all recovery strategies."""
def __init__(self):
self.name = self.__class__.__name__
@abstractmethod
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
"""
Attempt to recover from a workflow failure.
Args:
context: Recovery context with failure information
Returns:
RecoveryResult with outcome of recovery attempt
"""
pass
def can_handle(self, context: RecoveryContext) -> bool:
"""
Check if this strategy can handle the given failure context.
Args:
context: Recovery context
Returns:
True if strategy can handle this failure type
"""
return True
def get_priority(self, context: RecoveryContext) -> float:
"""
Get priority for this strategy given the context.
Higher values = higher priority.
Args:
context: Recovery context
Returns:
Priority score (0.0 to 1.0)
"""
return 0.5

View File

@@ -0,0 +1,222 @@
"""Format transformation recovery strategy."""
import re
import time
from typing import List, Optional
from datetime import datetime
from .base_strategy import RecoveryStrategy
from ..models import RecoveryContext, RecoveryResult
class FormatTransformationStrategy(RecoveryStrategy):
"""Transform input formats to match validation requirements."""
def __init__(self):
"""Initialize format transformation strategy."""
super().__init__()
# Date format patterns
self.date_formats = [
'%Y-%m-%d', # 2024-11-30
'%d/%m/%Y', # 30/11/2024
'%m/%d/%Y', # 11/30/2024
'%d-%m-%Y', # 30-11-2024
'%Y/%m/%d', # 2024/11/30
'%d.%m.%Y', # 30.11.2024
'%B %d, %Y', # November 30, 2024
'%d %B %Y', # 30 November 2024
]
# Phone format patterns
self.phone_formats = [
lambda p: p, # Original
lambda p: re.sub(r'\D', '', p), # Digits only
lambda p: "+" + re.sub(r"\D", "", p), # +digits
lambda p: self._format_phone_us(p), # (123) 456-7890
lambda p: self._format_phone_intl(p), # +1-123-456-7890
]
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
"""
Try to transform input format to match validation.
Args:
context: Recovery context
Returns:
RecoveryResult with outcome
"""
start_time = time.time()
# Only handle format validation failures
if context.failure_reason not in ['validation_failed', 'format_error']:
return RecoveryResult(
success=False,
strategy_used='format_transformation',
error_message='Strategy only handles format/validation failures'
)
# Get input value
input_value = context.metadata.get('input_value', '')
if not input_value:
return RecoveryResult(
success=False,
strategy_used='format_transformation',
error_message='No input value provided in context'
)
# Detect input type and try transformations
input_type = self._detect_input_type(input_value, context)
if input_type == 'date':
result = self._try_date_formats(input_value, context)
elif input_type == 'phone':
result = self._try_phone_formats(input_value, context)
elif input_type == 'text':
result = self._try_text_adaptations(input_value, context)
else:
result = None
execution_time = time.time() - start_time
if result:
return RecoveryResult(
success=True,
strategy_used='format_transformation',
new_element=result['formatted_value'],
confidence_score=result['confidence'],
execution_time=execution_time,
learned_pattern={
'input_type': input_type,
'original_format': input_value,
'new_format': result['formatted_value'],
'transformation': result['transformation']
}
)
return RecoveryResult(
success=False,
strategy_used='format_transformation',
execution_time=execution_time,
error_message=f'Could not find valid format transformation for: {input_value}'
)
def can_handle(self, context: RecoveryContext) -> bool:
"""Check if this strategy can handle the failure."""
return context.failure_reason in ['validation_failed', 'format_error', 'input_rejected']
def _detect_input_type(self, value: str, context: RecoveryContext) -> str:
"""Detect the type of input value."""
# Check metadata first
if 'input_type' in context.metadata:
return context.metadata['input_type']
# Try to detect from value
if self._looks_like_date(value):
return 'date'
elif self._looks_like_phone(value):
return 'phone'
else:
return 'text'
def _looks_like_date(self, value: str) -> bool:
"""Check if value looks like a date."""
# Contains date-like patterns
date_patterns = [
r'\d{4}[-/]\d{1,2}[-/]\d{1,2}', # YYYY-MM-DD
r'\d{1,2}[-/]\d{1,2}[-/]\d{4}', # DD-MM-YYYY or MM-DD-YYYY
r'\d{1,2}\s+\w+\s+\d{4}', # DD Month YYYY
]
return any(re.search(pattern, value) for pattern in date_patterns)
def _looks_like_phone(self, value: str) -> bool:
"""Check if value looks like a phone number."""
# Contains mostly digits with optional formatting
digits = re.sub(r'\D', '', value)
return len(digits) >= 7 and len(digits) <= 15
def _try_date_formats(self, value: str, context: RecoveryContext) -> Optional[dict]:
"""Try different date formats."""
# Try to parse the date
parsed_date = None
for fmt in self.date_formats:
try:
parsed_date = datetime.strptime(value, fmt)
break
except ValueError:
continue
if not parsed_date:
return None
# Try different output formats
for fmt in self.date_formats:
formatted = parsed_date.strftime(fmt)
# In real implementation, would try this format
# For now, assume first different format works
if formatted != value:
return {
'formatted_value': formatted,
'confidence': 0.85,
'transformation': f'date_format:{fmt}'
}
return None
def _try_phone_formats(self, value: str, context: RecoveryContext) -> Optional[dict]:
"""Try different phone formats."""
for i, formatter in enumerate(self.phone_formats):
try:
formatted = formatter(value)
if formatted != value:
return {
'formatted_value': formatted,
'confidence': 0.75,
'transformation': f'phone_format:{i}'
}
except:
continue
return None
def _try_text_adaptations(self, value: str, context: RecoveryContext) -> Optional[dict]:
"""Try text adaptations like truncation."""
# Check if there's a max length constraint
max_length = context.metadata.get('max_length')
if max_length and len(value) > max_length:
# Try truncation
truncated = value[:max_length]
return {
'formatted_value': truncated,
'confidence': 0.6,
'transformation': f'truncate:{max_length}'
}
# Try other adaptations
# Remove extra whitespace
cleaned = ' '.join(value.split())
if cleaned != value:
return {
'formatted_value': cleaned,
'confidence': 0.7,
'transformation': 'clean_whitespace'
}
return None
def _format_phone_us(self, phone: str) -> str:
"""Format phone number as US format: (123) 456-7890."""
digits = re.sub(r'\D', '', phone)
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
return phone
def _format_phone_intl(self, phone: str) -> str:
"""Format phone number as international: +1-123-456-7890."""
digits = re.sub(r'\D', '', phone)
if len(digits) == 10:
return f"+1-{digits[:3]}-{digits[3:6]}-{digits[6:]}"
elif len(digits) == 11 and digits[0] == '1':
return f"+{digits[0]}-{digits[1:4]}-{digits[4:7]}-{digits[7:]}"
return phone

View File

@@ -0,0 +1,154 @@
"""Semantic variant recovery strategy."""
import re
from typing import List, Dict, Optional
from .base_strategy import RecoveryStrategy
from ..models import RecoveryContext, RecoveryResult
class SemanticVariantStrategy(RecoveryStrategy):
"""Find semantic variants of UI elements."""
def __init__(self):
"""Initialize semantic variant strategy."""
super().__init__()
# Predefined semantic mappings (English and French)
self.variant_mappings = {
'submit': ['send', 'ok', 'confirm', 'apply', 'save', 'envoyer', 'valider', 'soumettre'],
'cancel': ['close', 'abort', 'back', 'dismiss', 'annuler', 'fermer', 'retour'],
'login': ['sign in', 'log in', 'connect', 'connexion', 'se connecter', 'authentifier'],
'logout': ['sign out', 'log out', 'disconnect', 'déconnexion', 'se déconnecter'],
'search': ['find', 'lookup', 'query', 'chercher', 'rechercher', 'trouver'],
'delete': ['remove', 'trash', 'erase', 'supprimer', 'effacer', 'retirer'],
'edit': ['modify', 'change', 'update', 'modifier', 'changer', 'éditer'],
'add': ['create', 'new', 'insert', 'ajouter', 'créer', 'nouveau'],
'next': ['continue', 'forward', 'suivant', 'continuer', 'avancer'],
'previous': ['back', 'backward', 'précédent', 'retour', 'arrière'],
'yes': ['ok', 'confirm', 'accept', 'oui', 'confirmer', 'accepter'],
'no': ['cancel', 'decline', 'reject', 'non', 'refuser', 'décliner'],
}
# Build reverse mapping
self.reverse_mapping = {}
for key, variants in self.variant_mappings.items():
for variant in variants:
if variant not in self.reverse_mapping:
self.reverse_mapping[variant] = []
self.reverse_mapping[variant].append(key)
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
"""
Try to find semantic variants of the target element.
Args:
context: Recovery context
Returns:
RecoveryResult with outcome
"""
import time
start_time = time.time()
# Extract text from element
original_text = self._extract_text_from_element(context.target_element)
if not original_text:
return RecoveryResult(
success=False,
strategy_used='semantic_variant',
error_message='Could not extract text from element'
)
# Get semantic variants
variants = self._get_semantic_variants(original_text)
# Try each variant
for variant in variants:
# In real implementation, this would use UI detector
# For now, we simulate finding the element
element = self._find_element_by_text(variant, context)
if element:
confidence = self._calculate_semantic_confidence(original_text, variant)
execution_time = time.time() - start_time
return RecoveryResult(
success=True,
strategy_used='semantic_variant',
new_element=element,
confidence_score=confidence,
execution_time=execution_time,
learned_pattern={
'original_text': original_text,
'found_variant': variant
}
)
execution_time = time.time() - start_time
return RecoveryResult(
success=False,
strategy_used='semantic_variant',
execution_time=execution_time,
error_message=f'No semantic variants found for: {original_text}'
)
def can_handle(self, context: RecoveryContext) -> bool:
"""Check if this strategy can handle the failure."""
return context.failure_reason in ['element_not_found', 'element_changed']
def _extract_text_from_element(self, element: str) -> str:
"""Extract text from element identifier."""
# Simple extraction - in real implementation would parse element structure
if isinstance(element, str):
# Remove common prefixes/suffixes
text = re.sub(r'^(button|link|input|text):', '', element, flags=re.IGNORECASE)
text = text.strip()
return text
return str(element)
def _get_semantic_variants(self, text: str) -> List[str]:
"""Get semantic variants for the given text."""
text_lower = text.lower().strip()
variants = []
# Check direct mapping
if text_lower in self.variant_mappings:
variants.extend(self.variant_mappings[text_lower])
# Check reverse mapping
if text_lower in self.reverse_mapping:
for key in self.reverse_mapping[text_lower]:
variants.extend(self.variant_mappings[key])
# Remove duplicates and original text
variants = list(set(variants))
if text_lower in variants:
variants.remove(text_lower)
return variants
def _find_element_by_text(self, text: str, context: RecoveryContext) -> Optional[str]:
"""
Find element by text in screenshot.
This is a placeholder - real implementation would use UI detector.
"""
# TODO: Integrate with UIDetector to actually find elements
# For now, return None to indicate not found
return None
def _calculate_semantic_confidence(self, original: str, variant: str) -> float:
"""Calculate confidence score for semantic variant match."""
original_lower = original.lower().strip()
variant_lower = variant.lower().strip()
# Higher confidence for direct mappings
if original_lower in self.variant_mappings:
if variant_lower in self.variant_mappings[original_lower]:
return 0.85
# Medium confidence for reverse mappings
if original_lower in self.reverse_mapping:
return 0.75
# Lower confidence for fuzzy matches
return 0.6

View File

@@ -0,0 +1,174 @@
"""Spatial fallback recovery strategy."""
import time
from typing import Optional, List, Tuple
from .base_strategy import RecoveryStrategy
from ..models import RecoveryContext, RecoveryResult
class SpatialFallbackStrategy(RecoveryStrategy):
"""Search in expanded areas around the original element position."""
def __init__(self):
"""Initialize spatial fallback strategy."""
super().__init__()
self.search_radii = [50, 100, 200, 400] # pixels
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
"""
Search in progressively larger areas around original position.
Args:
context: Recovery context
Returns:
RecoveryResult with outcome
"""
start_time = time.time()
# Get original position
original_pos = self._get_original_position(context)
if not original_pos:
return RecoveryResult(
success=False,
strategy_used='spatial_fallback',
error_message='Could not determine original element position'
)
# Try progressively larger search areas
for radius in self.search_radii:
search_area = self._expand_search_area(original_pos, radius)
elements = self._find_similar_elements_in_area(search_area, context)
if elements:
best_match = self._select_best_spatial_match(elements, original_pos)
confidence = self._calculate_spatial_confidence(best_match, original_pos, radius)
execution_time = time.time() - start_time
return RecoveryResult(
success=True,
strategy_used='spatial_fallback',
new_element=best_match['element'],
confidence_score=confidence,
execution_time=execution_time,
learned_pattern={
'original_position': original_pos,
'found_position': best_match['position'],
'search_radius': radius
}
)
execution_time = time.time() - start_time
return RecoveryResult(
success=False,
strategy_used='spatial_fallback',
execution_time=execution_time,
error_message='No similar elements found in expanded search areas'
)
def can_handle(self, context: RecoveryContext) -> bool:
"""Check if this strategy can handle the failure."""
return context.failure_reason in ['element_not_found', 'element_moved']
def _get_original_position(self, context: RecoveryContext) -> Optional[Tuple[int, int]]:
"""Extract original element position from context."""
# Try to get position from metadata
if 'position' in context.metadata:
pos = context.metadata['position']
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
return (int(pos[0]), int(pos[1]))
# Try to parse from element string
# Format: "element@(x,y)"
if '@(' in context.target_element:
try:
pos_str = context.target_element.split('@(')[1].split(')')[0]
x, y = pos_str.split(',')
return (int(x.strip()), int(y.strip()))
except:
pass
return None
def _expand_search_area(
self,
center: Tuple[int, int],
radius: int
) -> Tuple[int, int, int, int]:
"""
Expand search area around center point.
Returns:
(x1, y1, x2, y2) bounding box
"""
x, y = center
return (
max(0, x - radius),
max(0, y - radius),
x + radius,
y + radius
)
def _find_similar_elements_in_area(
self,
search_area: Tuple[int, int, int, int],
context: RecoveryContext
) -> List[dict]:
"""
Find similar elements in the search area.
This is a placeholder - real implementation would use UI detector.
"""
# TODO: Integrate with UIDetector to find elements in area
# For now, return empty list
return []
def _select_best_spatial_match(
self,
elements: List[dict],
original_pos: Tuple[int, int]
) -> dict:
"""Select the best matching element based on distance and similarity."""
if not elements:
return None
# Score each element
scored_elements = []
for element in elements:
distance = self._calculate_distance(original_pos, element['position'])
similarity = element.get('similarity', 0.5)
# Combined score (closer and more similar = better)
score = similarity * (1.0 / (1.0 + distance / 100.0))
scored_elements.append((score, element))
# Return element with highest score
scored_elements.sort(key=lambda x: x[0], reverse=True)
return scored_elements[0][1]
def _calculate_spatial_confidence(
self,
match: dict,
original_pos: Tuple[int, int],
radius: int
) -> float:
"""Calculate confidence score for spatial match."""
distance = self._calculate_distance(original_pos, match['position'])
similarity = match.get('similarity', 0.5)
# Distance factor (closer = higher confidence)
distance_factor = 1.0 - (distance / (radius * 2))
distance_factor = max(0.0, min(1.0, distance_factor))
# Combined confidence
confidence = (similarity * 0.6 + distance_factor * 0.4)
return max(0.0, min(1.0, confidence))
def _calculate_distance(
self,
pos1: Tuple[int, int],
pos2: Tuple[int, int]
) -> float:
"""Calculate Euclidean distance between two positions."""
return ((pos1[0] - pos2[0])**2 + (pos1[1] - pos2[1])**2)**0.5

View File

@@ -0,0 +1,150 @@
"""Timing adaptation recovery strategy."""
import time
from typing import Dict
from .base_strategy import RecoveryStrategy
from ..models import RecoveryContext, RecoveryResult
class TimingAdaptationStrategy(RecoveryStrategy):
"""Adapt wait times and timeouts based on performance."""
def __init__(self):
"""Initialize timing adaptation strategy."""
super().__init__()
self.performance_history: Dict[str, list] = {}
self.min_wait = 0.5
self.max_wait = 30.0
self.adaptation_factor = 1.5
def attempt_recovery(self, context: RecoveryContext) -> RecoveryResult:
"""
Adapt timing based on historical performance.
Args:
context: Recovery context
Returns:
RecoveryResult with outcome
"""
start_time = time.time()
# Only handle timeout failures
if context.failure_reason != 'timeout':
return RecoveryResult(
success=False,
strategy_used='timing_adaptation',
error_message='Strategy only handles timeout failures'
)
# Get current wait time
current_wait = self._get_current_wait_time(context)
# Calculate adapted wait time
adapted_wait = min(current_wait * self.adaptation_factor, self.max_wait)
# Try with adapted timing
success = self._retry_with_timing(context, adapted_wait)
execution_time = time.time() - start_time
if success:
# Update performance history
self._update_performance_history(context, adapted_wait)
return RecoveryResult(
success=True,
strategy_used='timing_adaptation',
confidence_score=0.8,
execution_time=execution_time,
learned_pattern={
'original_wait': current_wait,
'new_wait_time': adapted_wait,
'element': context.target_element
}
)
return RecoveryResult(
success=False,
strategy_used='timing_adaptation',
execution_time=execution_time,
error_message=f'Timeout even with adapted wait time: {adapted_wait}s'
)
def can_handle(self, context: RecoveryContext) -> bool:
"""Check if this strategy can handle the failure."""
return context.failure_reason == 'timeout'
def get_optimized_wait_time(self, element_key: str, default: float = 5.0) -> float:
"""
Get optimized wait time based on historical performance.
Args:
element_key: Key identifying the element/action
default: Default wait time if no history
Returns:
Optimized wait time in seconds
"""
if element_key not in self.performance_history:
return default
history = self.performance_history[element_key]
if not history:
return default
# Use average of recent successful timings
recent = history[-10:] # Last 10 attempts
avg_time = sum(recent) / len(recent)
# Add 20% buffer for safety
optimized = avg_time * 1.2
return max(self.min_wait, min(optimized, self.max_wait))
def _get_current_wait_time(self, context: RecoveryContext) -> float:
"""Extract current wait time from context."""
# Try to get from metadata
if 'wait_time' in context.metadata:
return float(context.metadata['wait_time'])
# Try to get from performance history
element_key = self._get_element_key(context)
if element_key in self.performance_history:
history = self.performance_history[element_key]
if history:
return history[-1]
# Default
return 5.0
def _retry_with_timing(self, context: RecoveryContext, wait_time: float) -> bool:
"""
Retry the action with adapted timing.
This is a placeholder - real implementation would retry the actual action.
"""
# TODO: Integrate with execution loop to actually retry
# For now, simulate with sleep
time.sleep(min(wait_time, 1.0)) # Cap at 1s for testing
# Simulate success based on wait time
# In real implementation, this would actually retry the action
return wait_time >= 3.0
def _update_performance_history(self, context: RecoveryContext, wait_time: float):
"""Update performance history with successful timing."""
element_key = self._get_element_key(context)
if element_key not in self.performance_history:
self.performance_history[element_key] = []
self.performance_history[element_key].append(wait_time)
# Keep only recent history (last 50 entries)
if len(self.performance_history[element_key]) > 50:
self.performance_history[element_key] = self.performance_history[element_key][-50:]
def _get_element_key(self, context: RecoveryContext) -> str:
"""Generate a key for the element/action."""
return f"{context.workflow_id}:{context.node_id}:{context.target_element}"