- Add CoachingDecision enum (ACCEPT, REJECT, CORRECT, EXECUTE_MANUAL, SKIP) - Add CoachingResponse dataclass for user decisions - Add WAITING_COACHING state to ExecutionState - Implement _request_coaching_decision() with callback or polling support - Implement submit_coaching_decision() for external API/UI submission - Implement _apply_coaching_correction() for applying user corrections - Implement _record_coaching_feedback() integrating with: - TrainingDataCollector for session recording - FeedbackProcessor for statistics - CorrectionPackIntegration for automatic correction capture - Add get_coaching_stats() for session statistics - Add 17 unit tests for COACHING functionality COACHING mode now: 1. Suggests actions to user 2. Waits for user decision (accept/reject/correct/manual/skip) 3. Applies corrections if provided 4. Records all feedback for learning 5. Propagates corrections to Correction Packs automatically Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1362 lines
53 KiB
Python
1362 lines
53 KiB
Python
"""
|
|
ExecutionLoop - Boucle d'exécution temps réel du RPA Vision V3
|
|
|
|
C'est le cœur du système qui fait tourner le RPA :
|
|
1. Capture l'écran en continu
|
|
2. Identifie l'état actuel (matching)
|
|
3. Détermine la prochaine action
|
|
4. Exécute l'action
|
|
5. Vérifie le résultat
|
|
6. Boucle jusqu'à la fin du workflow
|
|
|
|
Modes d'exécution:
|
|
- OBSERVATION: Observe sans agir (shadow mode)
|
|
- COACHING: Suggère les actions, l'utilisateur exécute
|
|
- SUPERVISED: Exécute avec confirmation à chaque étape
|
|
- AUTOMATIC: Exécute automatiquement (pilote automatique)
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
import threading
|
|
from typing import Optional, Dict, Any, Callable, List
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
from core.models.workflow_graph import Workflow, WorkflowEdge, LearningState
|
|
from core.models.screen_state import ScreenState
|
|
from core.execution.action_executor import ActionExecutor, ExecutionResult, ExecutionStatus
|
|
from core.capture.screen_capturer import ScreenCapturer, CaptureFrame
|
|
from core.execution.target_resolver import TargetResolver, ResolvedTarget
|
|
|
|
# Import tardif pour éviter import circulaire
|
|
from typing import TYPE_CHECKING
|
|
if TYPE_CHECKING:
|
|
from core.pipeline.workflow_pipeline import WorkflowPipeline
|
|
|
|
# GPU Resource Manager integration
|
|
try:
|
|
from core.gpu import GPUResourceManager, ExecutionMode as GPUExecutionMode, get_gpu_resource_manager
|
|
GPU_MANAGER_AVAILABLE = True
|
|
except ImportError:
|
|
GPU_MANAGER_AVAILABLE = False
|
|
|
|
# Continuous Learner integration
|
|
try:
|
|
from core.learning.continuous_learner import ContinuousLearner, DriftStatus
|
|
CONTINUOUS_LEARNER_AVAILABLE = True
|
|
except ImportError:
|
|
CONTINUOUS_LEARNER_AVAILABLE = False
|
|
|
|
# Execution Robustness integration
|
|
try:
|
|
from core.execution.execution_robustness import ExecutionRobustness, RetryManager
|
|
ROBUSTNESS_AVAILABLE = True
|
|
except ImportError:
|
|
ROBUSTNESS_AVAILABLE = False
|
|
|
|
# Analytics integration
|
|
try:
|
|
from core.analytics.analytics_system import get_analytics_system
|
|
from core.analytics.integration.execution_integration import AnalyticsExecutionIntegration
|
|
ANALYTICS_AVAILABLE = True
|
|
except ImportError:
|
|
ANALYTICS_AVAILABLE = False
|
|
|
|
# Feedback and Correction integration
|
|
try:
|
|
from core.learning.feedback_processor import FeedbackProcessor, FeedbackType
|
|
from core.corrections import get_correction_pack_integration, CorrectionPackIntegration
|
|
FEEDBACK_AVAILABLE = True
|
|
except ImportError:
|
|
FEEDBACK_AVAILABLE = False
|
|
|
|
# Training Data Collector integration
|
|
try:
|
|
from core.training.training_data_collector import TrainingDataCollector
|
|
TRAINING_COLLECTOR_AVAILABLE = True
|
|
except ImportError:
|
|
TRAINING_COLLECTOR_AVAILABLE = False
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ExecutionMode(str, Enum):
|
|
"""Modes d'exécution du RPA"""
|
|
OBSERVATION = "observation" # Observer sans agir
|
|
COACHING = "coaching" # Suggérer, utilisateur exécute
|
|
SUPERVISED = "supervised" # Exécuter avec confirmation
|
|
AUTOMATIC = "automatic" # Pilote automatique
|
|
|
|
|
|
class ExecutionState(str, Enum):
|
|
"""États de la boucle d'exécution"""
|
|
IDLE = "idle" # En attente
|
|
RUNNING = "running" # En cours d'exécution
|
|
PAUSED = "paused" # En pause
|
|
WAITING_CONFIRMATION = "waiting" # Attend confirmation utilisateur
|
|
WAITING_COACHING = "coaching" # Attend décision coaching utilisateur
|
|
COMPLETED = "completed" # Terminé avec succès
|
|
FAILED = "failed" # Terminé en erreur
|
|
STOPPED = "stopped" # Arrêté manuellement
|
|
|
|
|
|
class CoachingDecision(str, Enum):
|
|
"""Décisions possibles en mode COACHING"""
|
|
ACCEPT = "accept" # Accepter et exécuter l'action suggérée
|
|
REJECT = "reject" # Rejeter l'action (passer ou terminer)
|
|
CORRECT = "correct" # Fournir une correction
|
|
EXECUTE_MANUAL = "manual" # L'utilisateur a exécuté manuellement
|
|
SKIP = "skip" # Sauter cette action
|
|
|
|
|
|
@dataclass
|
|
class CoachingResponse:
|
|
"""Réponse de l'utilisateur en mode COACHING"""
|
|
decision: CoachingDecision
|
|
correction: Optional[Dict[str, Any]] = None # Correction si decision == CORRECT
|
|
feedback: Optional[str] = None # Commentaire utilisateur
|
|
executed_manually: bool = False # True si l'utilisateur a fait l'action lui-même
|
|
|
|
|
|
@dataclass
|
|
class ExecutionContext:
|
|
"""Contexte d'une exécution de workflow"""
|
|
workflow_id: str
|
|
execution_id: str
|
|
mode: ExecutionMode
|
|
started_at: datetime
|
|
current_node_id: Optional[str] = None
|
|
current_edge_id: Optional[str] = None
|
|
steps_executed: int = 0
|
|
steps_succeeded: int = 0
|
|
steps_failed: int = 0
|
|
last_screenshot_path: Optional[str] = None
|
|
last_match_confidence: float = 0.0
|
|
variables: Dict[str, Any] = field(default_factory=dict)
|
|
history: List[Dict[str, Any]] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class StepResult:
|
|
"""Résultat d'une étape d'exécution"""
|
|
success: bool
|
|
node_id: str
|
|
edge_id: Optional[str]
|
|
action_result: Optional[ExecutionResult]
|
|
match_confidence: float
|
|
duration_ms: float
|
|
message: str
|
|
screenshot_path: Optional[str] = None
|
|
|
|
|
|
class ExecutionLoop:
|
|
"""
|
|
Boucle d'exécution principale du RPA.
|
|
|
|
Gère le cycle complet :
|
|
Capture → Matching → Action → Vérification → Boucle
|
|
|
|
Example:
|
|
>>> loop = ExecutionLoop(pipeline)
|
|
>>> loop.start("workflow_login", mode=ExecutionMode.SUPERVISED)
|
|
>>> # ... le RPA s'exécute ...
|
|
>>> loop.stop()
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
pipeline: "WorkflowPipeline",
|
|
action_executor: Optional[ActionExecutor] = None,
|
|
screen_capturer: Optional[ScreenCapturer] = None,
|
|
capture_interval_ms: int = 500,
|
|
max_no_match_retries: int = 5,
|
|
confirmation_callback: Optional[Callable[[str, Dict], bool]] = None,
|
|
coaching_callback: Optional[Callable[[str, Dict], "CoachingResponse"]] = None
|
|
):
|
|
"""
|
|
Initialiser la boucle d'exécution.
|
|
|
|
Args:
|
|
pipeline: WorkflowPipeline pour matching et gestion workflows
|
|
action_executor: Exécuteur d'actions (créé si None)
|
|
screen_capturer: Capturer d'écran (créé si None)
|
|
capture_interval_ms: Intervalle entre captures (ms)
|
|
max_no_match_retries: Nombre max de tentatives si pas de match
|
|
confirmation_callback: Callback pour demander confirmation (SUPERVISED)
|
|
coaching_callback: Callback pour décisions coaching (COACHING)
|
|
"""
|
|
self.pipeline = pipeline
|
|
self.action_executor = action_executor or ActionExecutor()
|
|
self.screen_capturer = screen_capturer or ScreenCapturer(
|
|
buffer_size=20,
|
|
detect_changes=True
|
|
)
|
|
self.target_resolver = TargetResolver(
|
|
similarity_threshold=0.75,
|
|
use_embedding_fallback=True
|
|
)
|
|
self.capture_interval_ms = capture_interval_ms
|
|
self.max_no_match_retries = max_no_match_retries
|
|
self.confirmation_callback = confirmation_callback
|
|
self.coaching_callback = coaching_callback
|
|
|
|
# État interne
|
|
self.state = ExecutionState.IDLE
|
|
self.context: Optional[ExecutionContext] = None
|
|
self._stop_requested = False
|
|
self._pause_requested = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._lock = threading.Lock()
|
|
|
|
# Mode capture continue
|
|
self._use_continuous_capture = True
|
|
self._last_frame: Optional[CaptureFrame] = None
|
|
self._frame_queue: List[CaptureFrame] = []
|
|
self._frame_queue_lock = threading.Lock()
|
|
|
|
# Callbacks pour événements
|
|
self._on_step_complete: Optional[Callable[[StepResult], None]] = None
|
|
self._on_state_change: Optional[Callable[[ExecutionState], None]] = None
|
|
self._on_error: Optional[Callable[[str, Exception], None]] = None
|
|
|
|
# Répertoire temporaire pour screenshots
|
|
self._temp_dir = Path(tempfile.mkdtemp(prefix="rpa_vision_"))
|
|
|
|
# GPU Resource Manager
|
|
self._gpu_manager = None
|
|
if GPU_MANAGER_AVAILABLE:
|
|
try:
|
|
self._gpu_manager = get_gpu_resource_manager()
|
|
logger.info("GPU Resource Manager integrated")
|
|
except Exception as e:
|
|
logger.warning(f"GPU Resource Manager not available: {e}")
|
|
|
|
# Continuous Learner pour apprentissage continu
|
|
self._continuous_learner = None
|
|
if CONTINUOUS_LEARNER_AVAILABLE:
|
|
try:
|
|
self._continuous_learner = ContinuousLearner()
|
|
logger.info("Continuous Learner integrated")
|
|
except Exception as e:
|
|
logger.warning(f"Continuous Learner not available: {e}")
|
|
|
|
# Historique des confidences pour détection de dérive
|
|
self._confidence_history: Dict[str, List[float]] = {}
|
|
|
|
# Execution Robustness pour retry et récupération
|
|
self._robustness = None
|
|
if ROBUSTNESS_AVAILABLE:
|
|
try:
|
|
self._robustness = ExecutionRobustness(pipeline)
|
|
logger.info("Execution Robustness integrated")
|
|
except Exception as e:
|
|
logger.warning(f"Execution Robustness not available: {e}")
|
|
|
|
# Analytics integration pour métriques et insights
|
|
self._analytics_integration = None
|
|
if ANALYTICS_AVAILABLE:
|
|
try:
|
|
analytics_system = get_analytics_system()
|
|
self._analytics_integration = AnalyticsExecutionIntegration(analytics_system)
|
|
logger.info("Analytics System integrated")
|
|
except Exception as e:
|
|
logger.warning(f"Analytics System not available: {e}")
|
|
|
|
# Feedback and Correction Pack integration pour mode COACHING
|
|
self._feedback_processor = None
|
|
self._correction_integration = None
|
|
if FEEDBACK_AVAILABLE:
|
|
try:
|
|
self._feedback_processor = FeedbackProcessor(auto_integrate_corrections=True)
|
|
self._correction_integration = get_correction_pack_integration()
|
|
logger.info("Feedback and Correction Pack integration enabled")
|
|
except Exception as e:
|
|
logger.warning(f"Feedback integration not available: {e}")
|
|
|
|
# Training Data Collector pour enregistrement des sessions COACHING
|
|
self._training_collector = None
|
|
if TRAINING_COLLECTOR_AVAILABLE:
|
|
try:
|
|
self._training_collector = TrainingDataCollector(
|
|
output_dir="data/coaching_sessions",
|
|
auto_integrate_corrections=True
|
|
)
|
|
logger.info("Training Data Collector integrated")
|
|
except Exception as e:
|
|
logger.warning(f"Training Data Collector not available: {e}")
|
|
|
|
# Coaching state
|
|
self._coaching_response: Optional[CoachingResponse] = None
|
|
self._coaching_stats = {
|
|
'suggestions_made': 0,
|
|
'accepted': 0,
|
|
'rejected': 0,
|
|
'corrected': 0,
|
|
'manual_executions': 0
|
|
}
|
|
|
|
logger.info("ExecutionLoop initialized")
|
|
|
|
# =========================================================================
|
|
# Contrôle de l'exécution
|
|
# =========================================================================
|
|
|
|
def start(
|
|
self,
|
|
workflow_id: str,
|
|
mode: Optional[ExecutionMode] = None,
|
|
start_node_id: Optional[str] = None,
|
|
variables: Optional[Dict[str, Any]] = None
|
|
) -> bool:
|
|
"""
|
|
Démarrer l'exécution d'un workflow.
|
|
|
|
Args:
|
|
workflow_id: ID du workflow à exécuter
|
|
mode: Mode d'exécution (auto-détecté si None)
|
|
start_node_id: Node de départ (entry_node si None)
|
|
variables: Variables initiales
|
|
|
|
Returns:
|
|
True si démarré avec succès
|
|
"""
|
|
with self._lock:
|
|
if self.state == ExecutionState.RUNNING:
|
|
logger.warning("Execution already running")
|
|
return False
|
|
|
|
# Charger le workflow
|
|
workflow = self.pipeline.load_workflow(workflow_id)
|
|
if not workflow:
|
|
logger.error(f"Workflow not found: {workflow_id}")
|
|
return False
|
|
|
|
# Déterminer le mode d'exécution
|
|
if mode is None:
|
|
mode = self._determine_execution_mode(workflow)
|
|
|
|
# Créer le contexte
|
|
self.context = ExecutionContext(
|
|
workflow_id=workflow_id,
|
|
execution_id=f"exec_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
|
mode=mode,
|
|
started_at=datetime.now(),
|
|
current_node_id=start_node_id or (workflow.entry_nodes[0] if workflow.entry_nodes else None),
|
|
variables=variables or {}
|
|
)
|
|
|
|
self._stop_requested = False
|
|
self._pause_requested = False
|
|
self.state = ExecutionState.RUNNING
|
|
|
|
# Notify GPU Resource Manager about execution mode
|
|
self._update_gpu_mode(mode)
|
|
|
|
# Notify Analytics about execution start
|
|
if self._analytics_integration:
|
|
try:
|
|
self._analytics_integration.on_execution_start(
|
|
workflow_id=workflow_id,
|
|
execution_id=self.context.execution_id,
|
|
mode=mode.value
|
|
)
|
|
# Start resource monitoring for this execution
|
|
self._analytics_integration.start_resource_monitoring(
|
|
execution_id=self.context.execution_id
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Analytics notification failed: {e}")
|
|
|
|
# Démarrer le thread d'exécution
|
|
self._thread = threading.Thread(target=self._execution_loop, daemon=True)
|
|
self._thread.start()
|
|
|
|
logger.info(f"Started execution of {workflow_id} in {mode.value} mode")
|
|
self._notify_state_change(ExecutionState.RUNNING)
|
|
|
|
return True
|
|
|
|
def stop(self) -> None:
|
|
"""Arrêter l'exécution."""
|
|
with self._lock:
|
|
self._stop_requested = True
|
|
logger.info("Stop requested")
|
|
|
|
def pause(self) -> None:
|
|
"""Mettre en pause l'exécution."""
|
|
with self._lock:
|
|
self._pause_requested = True
|
|
self.state = ExecutionState.PAUSED
|
|
logger.info("Pause requested")
|
|
self._notify_state_change(ExecutionState.PAUSED)
|
|
|
|
def resume(self) -> None:
|
|
"""Reprendre l'exécution après pause."""
|
|
with self._lock:
|
|
self._pause_requested = False
|
|
self.state = ExecutionState.RUNNING
|
|
logger.info("Resumed")
|
|
self._notify_state_change(ExecutionState.RUNNING)
|
|
|
|
def confirm_action(self, approved: bool = True) -> None:
|
|
"""Confirmer ou rejeter une action en attente."""
|
|
with self._lock:
|
|
if self.state == ExecutionState.WAITING_CONFIRMATION:
|
|
self._confirmation_result = approved
|
|
self.state = ExecutionState.RUNNING
|
|
|
|
# =========================================================================
|
|
# Boucle principale
|
|
# =========================================================================
|
|
|
|
def _execution_loop(self) -> None:
|
|
"""Boucle principale d'exécution (tourne dans un thread)."""
|
|
no_match_count = 0
|
|
|
|
# Démarrer la capture continue si activée
|
|
if self._use_continuous_capture:
|
|
self.screen_capturer.start_continuous(
|
|
callback=self._on_frame_captured,
|
|
interval_ms=self.capture_interval_ms,
|
|
skip_unchanged=True
|
|
)
|
|
logger.info("Started continuous screen capture")
|
|
|
|
try:
|
|
while not self._stop_requested:
|
|
# Vérifier pause
|
|
while self._pause_requested and not self._stop_requested:
|
|
time.sleep(0.1)
|
|
|
|
if self._stop_requested:
|
|
break
|
|
|
|
# Exécuter une étape
|
|
step_result = self._execute_step()
|
|
|
|
if step_result is None:
|
|
# Pas de match trouvé
|
|
no_match_count += 1
|
|
if no_match_count >= self.max_no_match_retries:
|
|
logger.error(f"No match found after {no_match_count} retries")
|
|
self._handle_no_match()
|
|
break
|
|
time.sleep(self.capture_interval_ms / 1000.0)
|
|
continue
|
|
|
|
no_match_count = 0 # Reset counter
|
|
|
|
# Notifier le résultat
|
|
if self._on_step_complete:
|
|
self._on_step_complete(step_result)
|
|
|
|
# Enregistrer dans l'historique
|
|
self.context.history.append({
|
|
"timestamp": datetime.now().isoformat(),
|
|
"node_id": step_result.node_id,
|
|
"edge_id": step_result.edge_id,
|
|
"success": step_result.success,
|
|
"confidence": step_result.match_confidence
|
|
})
|
|
|
|
# Notify Analytics about step completion
|
|
if self._analytics_integration and step_result:
|
|
try:
|
|
self._analytics_integration.on_step_complete(
|
|
workflow_id=self.context.workflow_id,
|
|
execution_id=self.context.execution_id,
|
|
step_id=step_result.node_id,
|
|
success=step_result.success,
|
|
duration_ms=step_result.duration_ms,
|
|
confidence=step_result.match_confidence
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Analytics step notification failed: {e}")
|
|
|
|
# Vérifier si workflow terminé
|
|
if self._is_workflow_complete():
|
|
logger.info("Workflow completed successfully")
|
|
self.state = ExecutionState.COMPLETED
|
|
self._notify_state_change(ExecutionState.COMPLETED)
|
|
break
|
|
|
|
# Attendre avant prochaine capture
|
|
time.sleep(self.capture_interval_ms / 1000.0)
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Execution loop error: {e}")
|
|
self.state = ExecutionState.FAILED
|
|
self._notify_state_change(ExecutionState.FAILED)
|
|
if self._on_error:
|
|
self._on_error("execution_loop", e)
|
|
|
|
finally:
|
|
# Arrêter la capture continue
|
|
if self._use_continuous_capture:
|
|
self.screen_capturer.stop_continuous()
|
|
logger.info("Stopped continuous screen capture")
|
|
|
|
if self._stop_requested:
|
|
self.state = ExecutionState.STOPPED
|
|
self._notify_state_change(ExecutionState.STOPPED)
|
|
|
|
# Notify Analytics about execution completion
|
|
if self._analytics_integration and self.context:
|
|
try:
|
|
success = self.state == ExecutionState.COMPLETED
|
|
duration_ms = (datetime.now() - self.context.started_at).total_seconds() * 1000
|
|
|
|
# Stop resource monitoring
|
|
self._analytics_integration.stop_resource_monitoring(
|
|
execution_id=self.context.execution_id
|
|
)
|
|
|
|
self._analytics_integration.on_execution_complete(
|
|
workflow_id=self.context.workflow_id,
|
|
execution_id=self.context.execution_id,
|
|
success=success,
|
|
duration_ms=duration_ms,
|
|
steps_executed=self.context.steps_executed,
|
|
steps_succeeded=self.context.steps_succeeded,
|
|
steps_failed=self.context.steps_failed,
|
|
error_message=None if success else f"Execution ended in state: {self.state.value}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Analytics completion notification failed: {e}")
|
|
|
|
logger.info(f"Execution loop ended: {self.state.value}")
|
|
|
|
def _execute_step(self) -> Optional[StepResult]:
|
|
"""
|
|
Exécuter une étape du workflow.
|
|
|
|
Returns:
|
|
StepResult ou None si pas de match
|
|
"""
|
|
start_time = time.time()
|
|
|
|
# 1. Capturer l'écran
|
|
screenshot_path = self._capture_screen()
|
|
if not screenshot_path:
|
|
logger.warning("Failed to capture screen")
|
|
return None
|
|
|
|
self.context.last_screenshot_path = screenshot_path
|
|
|
|
# 2. Identifier l'état actuel (matching)
|
|
match = self.pipeline.match_current_state(
|
|
screenshot_path,
|
|
workflow_id=self.context.workflow_id
|
|
)
|
|
|
|
if not match:
|
|
logger.debug("No match found for current screen")
|
|
return None
|
|
|
|
current_node_id = match["node_id"]
|
|
confidence = match["confidence"]
|
|
self.context.current_node_id = current_node_id
|
|
self.context.last_match_confidence = confidence
|
|
|
|
logger.info(f"Matched node: {current_node_id} (confidence: {confidence:.3f})")
|
|
|
|
# 3. Obtenir la prochaine action
|
|
next_action = self.pipeline.get_next_action(
|
|
self.context.workflow_id,
|
|
current_node_id
|
|
)
|
|
|
|
if not next_action:
|
|
# Pas d'action suivante = fin du workflow ou node terminal
|
|
return StepResult(
|
|
success=True,
|
|
node_id=current_node_id,
|
|
edge_id=None,
|
|
action_result=None,
|
|
match_confidence=confidence,
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
message="No next action (terminal node)",
|
|
screenshot_path=screenshot_path
|
|
)
|
|
|
|
edge_id = next_action["edge_id"]
|
|
self.context.current_edge_id = edge_id
|
|
|
|
# 4. Selon le mode, exécuter ou suggérer
|
|
action_result = None
|
|
|
|
if self.context.mode == ExecutionMode.OBSERVATION:
|
|
# Mode observation : ne rien faire
|
|
logger.info(f"[OBSERVATION] Would execute: {next_action['action']}")
|
|
|
|
elif self.context.mode == ExecutionMode.COACHING:
|
|
# Mode coaching : suggérer l'action et attendre décision utilisateur
|
|
logger.info(f"[COACHING] Suggested action: {next_action['action']}")
|
|
self._coaching_stats['suggestions_made'] += 1
|
|
|
|
# Demander la décision à l'utilisateur
|
|
coaching_response = self._request_coaching_decision(next_action, screenshot_path)
|
|
|
|
if coaching_response.decision == CoachingDecision.ACCEPT:
|
|
# Utilisateur accepte : exécuter l'action suggérée
|
|
self._coaching_stats['accepted'] += 1
|
|
action_result = self._execute_action(next_action)
|
|
self._record_coaching_feedback(
|
|
next_action, coaching_response, action_result, success=True
|
|
)
|
|
|
|
elif coaching_response.decision == CoachingDecision.REJECT:
|
|
# Utilisateur rejette : ne pas exécuter
|
|
self._coaching_stats['rejected'] += 1
|
|
self._record_coaching_feedback(
|
|
next_action, coaching_response, None, success=False
|
|
)
|
|
return StepResult(
|
|
success=False,
|
|
node_id=current_node_id,
|
|
edge_id=edge_id,
|
|
action_result=None,
|
|
match_confidence=confidence,
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
message="Action rejected by user in COACHING mode",
|
|
screenshot_path=screenshot_path
|
|
)
|
|
|
|
elif coaching_response.decision == CoachingDecision.CORRECT:
|
|
# Utilisateur fournit une correction
|
|
self._coaching_stats['corrected'] += 1
|
|
corrected_action = self._apply_coaching_correction(
|
|
next_action, coaching_response.correction
|
|
)
|
|
action_result = self._execute_action(corrected_action)
|
|
self._record_coaching_feedback(
|
|
next_action, coaching_response, action_result,
|
|
success=action_result.status == ExecutionStatus.SUCCESS if action_result else False
|
|
)
|
|
|
|
elif coaching_response.decision == CoachingDecision.EXECUTE_MANUAL:
|
|
# Utilisateur a exécuté manuellement
|
|
self._coaching_stats['manual_executions'] += 1
|
|
self._record_coaching_feedback(
|
|
next_action, coaching_response, None, success=True, manual=True
|
|
)
|
|
# Pas besoin d'exécuter, l'utilisateur l'a fait
|
|
|
|
elif coaching_response.decision == CoachingDecision.SKIP:
|
|
# Sauter cette action
|
|
self._record_coaching_feedback(
|
|
next_action, coaching_response, None, success=True
|
|
)
|
|
# Continuer sans exécuter
|
|
|
|
|
|
elif self.context.mode == ExecutionMode.SUPERVISED:
|
|
# Mode supervisé : demander confirmation
|
|
if not self._request_confirmation(next_action):
|
|
logger.info("Action rejected by user")
|
|
return StepResult(
|
|
success=False,
|
|
node_id=current_node_id,
|
|
edge_id=edge_id,
|
|
action_result=None,
|
|
match_confidence=confidence,
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
message="Action rejected by user",
|
|
screenshot_path=screenshot_path
|
|
)
|
|
|
|
# Exécuter l'action
|
|
action_result = self._execute_action(next_action)
|
|
|
|
elif self.context.mode == ExecutionMode.AUTOMATIC:
|
|
# Mode automatique : exécuter directement
|
|
action_result = self._execute_action(next_action)
|
|
|
|
# 5. Mettre à jour les compteurs
|
|
self.context.steps_executed += 1
|
|
if action_result and action_result.status == ExecutionStatus.SUCCESS:
|
|
self.context.steps_succeeded += 1
|
|
elif action_result:
|
|
self.context.steps_failed += 1
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
|
|
return StepResult(
|
|
success=action_result.status == ExecutionStatus.SUCCESS if action_result else True,
|
|
node_id=current_node_id,
|
|
edge_id=edge_id,
|
|
action_result=action_result,
|
|
match_confidence=confidence,
|
|
duration_ms=duration_ms,
|
|
message=action_result.message if action_result else "Observed",
|
|
screenshot_path=screenshot_path
|
|
)
|
|
|
|
# =========================================================================
|
|
# Méthodes utilitaires
|
|
# =========================================================================
|
|
|
|
def _capture_screen(self) -> Optional[str]:
|
|
"""Capturer l'écran et sauvegarder dans un fichier temporaire."""
|
|
try:
|
|
screenshot = self.screen_capturer.capture_screen()
|
|
if screenshot is None:
|
|
return None
|
|
|
|
# Sauvegarder dans fichier temporaire
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
screenshot_path = self._temp_dir / f"capture_{timestamp}.png"
|
|
screenshot.save(str(screenshot_path))
|
|
|
|
return str(screenshot_path)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Screen capture failed: {e}")
|
|
return None
|
|
|
|
def _execute_action(self, action_info: Dict[str, Any]) -> ExecutionResult:
|
|
"""Exécuter une action via l'ActionExecutor."""
|
|
try:
|
|
# Charger le workflow et l'edge
|
|
workflow = self.pipeline.load_workflow(self.context.workflow_id)
|
|
edge = workflow.get_edge(action_info["edge_id"])
|
|
|
|
if not edge:
|
|
return ExecutionResult(
|
|
status=ExecutionStatus.FAILED,
|
|
message=f"Edge not found: {action_info['edge_id']}",
|
|
duration_ms=0
|
|
)
|
|
|
|
# Créer un ScreenState minimal pour l'exécution
|
|
from core.models.screen_state import (
|
|
ScreenState, WindowContext, RawLevel, PerceptionLevel,
|
|
ContextLevel, EmbeddingRef
|
|
)
|
|
|
|
screen_state = ScreenState(
|
|
screen_state_id=f"exec_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
|
timestamp=datetime.now(),
|
|
session_id=self.context.execution_id,
|
|
window=WindowContext(
|
|
app_name="unknown",
|
|
window_title="Unknown",
|
|
screen_resolution=[1920, 1080],
|
|
workspace="main"
|
|
),
|
|
raw=RawLevel(
|
|
screenshot_path=self.context.last_screenshot_path or "",
|
|
capture_method="execution",
|
|
file_size_bytes=0
|
|
),
|
|
perception=PerceptionLevel(
|
|
embedding=EmbeddingRef(provider="", vector_id="", dimensions=512),
|
|
detected_text=[],
|
|
text_detection_method="none",
|
|
confidence_avg=0.0
|
|
),
|
|
context=ContextLevel(),
|
|
ui_elements=[]
|
|
)
|
|
|
|
# Exécuter l'action
|
|
result = self.action_executor.execute_edge(
|
|
edge,
|
|
screen_state,
|
|
context=self.context.variables
|
|
)
|
|
|
|
logger.info(f"Action executed: {result.status.value} - {result.message}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Action execution failed: {e}")
|
|
return ExecutionResult(
|
|
status=ExecutionStatus.FAILED,
|
|
message=str(e),
|
|
duration_ms=0,
|
|
error=e
|
|
)
|
|
|
|
def _request_confirmation(self, action_info: Dict[str, Any]) -> bool:
|
|
"""Demander confirmation à l'utilisateur."""
|
|
if self.confirmation_callback:
|
|
return self.confirmation_callback(
|
|
f"Execute action: {action_info['action']}?",
|
|
action_info
|
|
)
|
|
|
|
# Sans callback, attendre la confirmation via confirm_action()
|
|
self.state = ExecutionState.WAITING_CONFIRMATION
|
|
self._notify_state_change(ExecutionState.WAITING_CONFIRMATION)
|
|
self._confirmation_result = None
|
|
|
|
# Attendre la confirmation (timeout 60s)
|
|
timeout = 60
|
|
start = time.time()
|
|
while self._confirmation_result is None and (time.time() - start) < timeout:
|
|
if self._stop_requested:
|
|
return False
|
|
time.sleep(0.1)
|
|
|
|
return self._confirmation_result or False
|
|
|
|
# =========================================================================
|
|
# Méthodes COACHING
|
|
# =========================================================================
|
|
|
|
def _request_coaching_decision(
|
|
self,
|
|
action_info: Dict[str, Any],
|
|
screenshot_path: Optional[str] = None
|
|
) -> CoachingResponse:
|
|
"""
|
|
Demander une décision à l'utilisateur en mode COACHING.
|
|
|
|
Args:
|
|
action_info: Information sur l'action suggérée
|
|
screenshot_path: Chemin vers la capture d'écran actuelle
|
|
|
|
Returns:
|
|
CoachingResponse avec la décision de l'utilisateur
|
|
"""
|
|
# Si callback fourni, l'utiliser
|
|
if self.coaching_callback:
|
|
try:
|
|
return self.coaching_callback(
|
|
f"Suggested action: {action_info.get('action', 'unknown')}",
|
|
{
|
|
**action_info,
|
|
'screenshot_path': screenshot_path,
|
|
'context': {
|
|
'workflow_id': self.context.workflow_id if self.context else None,
|
|
'execution_id': self.context.execution_id if self.context else None,
|
|
'step': self.context.steps_executed if self.context else 0
|
|
}
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Coaching callback error: {e}")
|
|
# En cas d'erreur, retourner SKIP par défaut
|
|
return CoachingResponse(decision=CoachingDecision.SKIP)
|
|
|
|
# Sans callback, attendre la réponse via submit_coaching_decision()
|
|
self.state = ExecutionState.WAITING_COACHING
|
|
self._notify_state_change(ExecutionState.WAITING_COACHING)
|
|
self._coaching_response = None
|
|
|
|
# Attendre la réponse (timeout 120s pour COACHING car plus complexe)
|
|
timeout = 120
|
|
start = time.time()
|
|
while self._coaching_response is None and (time.time() - start) < timeout:
|
|
if self._stop_requested:
|
|
return CoachingResponse(decision=CoachingDecision.REJECT)
|
|
time.sleep(0.1)
|
|
|
|
# Timeout = SKIP par défaut
|
|
if self._coaching_response is None:
|
|
logger.warning("Coaching decision timeout - skipping action")
|
|
return CoachingResponse(decision=CoachingDecision.SKIP)
|
|
|
|
return self._coaching_response
|
|
|
|
def submit_coaching_decision(self, response: CoachingResponse) -> bool:
|
|
"""
|
|
Soumettre une décision COACHING depuis l'extérieur (API, UI).
|
|
|
|
Args:
|
|
response: Réponse COACHING de l'utilisateur
|
|
|
|
Returns:
|
|
True si la décision a été acceptée
|
|
"""
|
|
if self.state != ExecutionState.WAITING_COACHING:
|
|
logger.warning(f"Cannot submit coaching decision in state {self.state}")
|
|
return False
|
|
|
|
self._coaching_response = response
|
|
logger.info(f"Coaching decision received: {response.decision.value}")
|
|
return True
|
|
|
|
def _apply_coaching_correction(
|
|
self,
|
|
original_action: Dict[str, Any],
|
|
correction: Optional[Dict[str, Any]]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Appliquer une correction utilisateur à une action.
|
|
|
|
Args:
|
|
original_action: Action originale suggérée
|
|
correction: Correction fournie par l'utilisateur
|
|
|
|
Returns:
|
|
Action corrigée
|
|
"""
|
|
if not correction:
|
|
return original_action
|
|
|
|
corrected = {**original_action}
|
|
|
|
# Appliquer les corrections de cible
|
|
if 'target' in correction:
|
|
corrected['target'] = correction['target']
|
|
if 'corrected_target' in correction:
|
|
corrected['target'] = correction['corrected_target']
|
|
|
|
# Appliquer les corrections de paramètres
|
|
if 'params' in correction:
|
|
corrected['params'] = {**corrected.get('params', {}), **correction['params']}
|
|
if 'corrected_params' in correction:
|
|
corrected['params'] = {**corrected.get('params', {}), **correction['corrected_params']}
|
|
|
|
# Appliquer changement d'action
|
|
if 'action' in correction:
|
|
corrected['action'] = correction['action']
|
|
|
|
# Appliquer timing
|
|
if 'wait_before' in correction:
|
|
corrected['wait_before'] = correction['wait_before']
|
|
if 'wait_after' in correction:
|
|
corrected['wait_after'] = correction['wait_after']
|
|
|
|
logger.info(f"Applied coaching correction: {list(correction.keys())}")
|
|
return corrected
|
|
|
|
def _record_coaching_feedback(
|
|
self,
|
|
action_info: Dict[str, Any],
|
|
response: CoachingResponse,
|
|
result: Optional[ExecutionResult],
|
|
success: bool,
|
|
manual: bool = False
|
|
) -> None:
|
|
"""
|
|
Enregistrer le feedback COACHING pour apprentissage.
|
|
|
|
Args:
|
|
action_info: Action suggérée
|
|
response: Réponse de l'utilisateur
|
|
result: Résultat d'exécution (si exécuté)
|
|
success: Si l'action a réussi
|
|
manual: Si l'utilisateur a exécuté manuellement
|
|
"""
|
|
if not self.context:
|
|
return
|
|
|
|
# Préparer les données de correction
|
|
correction_data = {
|
|
'action_type': action_info.get('action', 'unknown'),
|
|
'element_type': action_info.get('target', {}).get('type', 'unknown'),
|
|
'decision': response.decision.value,
|
|
'success': success,
|
|
'manual_execution': manual,
|
|
'feedback': response.feedback,
|
|
'workflow_id': self.context.workflow_id,
|
|
'node_id': self.context.current_node_id,
|
|
'step': self.context.steps_executed
|
|
}
|
|
|
|
# Si correction fournie, l'ajouter
|
|
if response.correction:
|
|
correction_data['correction_type'] = 'user_correction'
|
|
correction_data['original_target'] = action_info.get('target')
|
|
correction_data['original_params'] = action_info.get('params')
|
|
correction_data['corrected_target'] = response.correction.get('target') or response.correction.get('corrected_target')
|
|
correction_data['corrected_params'] = response.correction.get('params') or response.correction.get('corrected_params')
|
|
|
|
# Enregistrer via TrainingDataCollector
|
|
if self._training_collector:
|
|
try:
|
|
# S'assurer qu'une session est active
|
|
if not self._training_collector.current_session:
|
|
self._training_collector.start_session(
|
|
session_id=self.context.execution_id,
|
|
workflow_id=self.context.workflow_id
|
|
)
|
|
|
|
# Enregistrer l'action
|
|
self._training_collector.record_action({
|
|
'action': action_info.get('action'),
|
|
'target': action_info.get('target'),
|
|
'decision': response.decision.value,
|
|
'success': success
|
|
})
|
|
|
|
# Si correction, l'enregistrer (propagera auto vers Correction Packs)
|
|
if response.correction or response.decision == CoachingDecision.REJECT:
|
|
self._training_collector.record_correction(correction_data)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error recording coaching feedback: {e}")
|
|
|
|
# Enregistrer via FeedbackProcessor pour statistiques
|
|
if self._feedback_processor and not success:
|
|
try:
|
|
feedback_type = FeedbackType.INCORRECT if response.decision == CoachingDecision.REJECT else FeedbackType.PARTIAL
|
|
self._feedback_processor.process_feedback(
|
|
workflow_id=self.context.workflow_id,
|
|
execution_id=self.context.execution_id,
|
|
feedback_type=feedback_type,
|
|
confidence=self.context.last_match_confidence,
|
|
corrections=response.correction,
|
|
context={
|
|
'action': action_info.get('action'),
|
|
'element_type': action_info.get('target', {}).get('type'),
|
|
'failure_reason': response.feedback or 'user_rejected'
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Error processing coaching feedback: {e}")
|
|
|
|
logger.info(
|
|
f"Coaching feedback recorded: decision={response.decision.value}, "
|
|
f"success={success}, has_correction={response.correction is not None}"
|
|
)
|
|
|
|
def get_coaching_stats(self) -> Dict[str, Any]:
|
|
"""Récupérer les statistiques COACHING de la session courante."""
|
|
stats = {**self._coaching_stats}
|
|
if stats['suggestions_made'] > 0:
|
|
stats['acceptance_rate'] = stats['accepted'] / stats['suggestions_made']
|
|
stats['correction_rate'] = stats['corrected'] / stats['suggestions_made']
|
|
else:
|
|
stats['acceptance_rate'] = 0.0
|
|
stats['correction_rate'] = 0.0
|
|
return stats
|
|
|
|
def _determine_execution_mode(self, workflow: Workflow) -> ExecutionMode:
|
|
"""Déterminer le mode d'exécution basé sur le learning_state."""
|
|
learning_state = workflow.learning_state
|
|
|
|
if learning_state == "OBSERVATION":
|
|
return ExecutionMode.OBSERVATION
|
|
elif learning_state == "COACHING":
|
|
return ExecutionMode.COACHING
|
|
elif learning_state == "AUTO_CANDIDATE":
|
|
return ExecutionMode.SUPERVISED
|
|
elif learning_state == "AUTO_CONFIRMED":
|
|
return ExecutionMode.AUTOMATIC
|
|
else:
|
|
return ExecutionMode.OBSERVATION
|
|
|
|
def _is_workflow_complete(self) -> bool:
|
|
"""Vérifier si le workflow est terminé."""
|
|
if not self.context or not self.context.current_node_id:
|
|
return False
|
|
|
|
workflow = self.pipeline.load_workflow(self.context.workflow_id)
|
|
if not workflow:
|
|
return True
|
|
|
|
# Vérifier si on est sur un node terminal
|
|
return self.context.current_node_id in workflow.end_nodes
|
|
|
|
def _handle_no_match(self) -> None:
|
|
"""Gérer le cas où aucun match n'est trouvé."""
|
|
logger.warning("No match found - pausing execution")
|
|
self.state = ExecutionState.PAUSED
|
|
self._notify_state_change(ExecutionState.PAUSED)
|
|
|
|
if self._on_error:
|
|
self._on_error("no_match", Exception("No matching node found"))
|
|
|
|
def _notify_state_change(self, new_state: ExecutionState) -> None:
|
|
"""Notifier un changement d'état."""
|
|
if self._on_state_change:
|
|
try:
|
|
self._on_state_change(new_state)
|
|
except Exception as e:
|
|
logger.error(f"State change callback error: {e}")
|
|
|
|
# =========================================================================
|
|
# Callbacks et événements
|
|
# =========================================================================
|
|
|
|
def on_step_complete(self, callback: Callable[[StepResult], None]) -> None:
|
|
"""Enregistrer un callback pour chaque étape complétée."""
|
|
self._on_step_complete = callback
|
|
|
|
def on_state_change(self, callback: Callable[[ExecutionState], None]) -> None:
|
|
"""Enregistrer un callback pour les changements d'état."""
|
|
self._on_state_change = callback
|
|
|
|
def on_error(self, callback: Callable[[str, Exception], None]) -> None:
|
|
"""Enregistrer un callback pour les erreurs."""
|
|
self._on_error = callback
|
|
|
|
# =========================================================================
|
|
# Getters
|
|
# =========================================================================
|
|
|
|
def get_state(self) -> ExecutionState:
|
|
"""Obtenir l'état actuel."""
|
|
return self.state
|
|
|
|
def get_context(self) -> Optional[ExecutionContext]:
|
|
"""Obtenir le contexte d'exécution."""
|
|
return self.context
|
|
|
|
def get_progress(self) -> Dict[str, Any]:
|
|
"""Obtenir la progression de l'exécution."""
|
|
if not self.context:
|
|
return {"status": "idle"}
|
|
|
|
return {
|
|
"status": self.state.value,
|
|
"workflow_id": self.context.workflow_id,
|
|
"execution_id": self.context.execution_id,
|
|
"mode": self.context.mode.value,
|
|
"current_node": self.context.current_node_id,
|
|
"steps_executed": self.context.steps_executed,
|
|
"steps_succeeded": self.context.steps_succeeded,
|
|
"steps_failed": self.context.steps_failed,
|
|
"last_confidence": self.context.last_match_confidence,
|
|
"duration_seconds": (datetime.now() - self.context.started_at).total_seconds()
|
|
}
|
|
|
|
def cleanup(self) -> None:
|
|
"""Nettoyer les ressources temporaires."""
|
|
import shutil
|
|
try:
|
|
if self._temp_dir.exists():
|
|
shutil.rmtree(self._temp_dir)
|
|
except Exception as e:
|
|
logger.warning(f"Cleanup failed: {e}")
|
|
|
|
# =========================================================================
|
|
# Capture continue
|
|
# =========================================================================
|
|
|
|
def _on_frame_captured(self, frame: CaptureFrame) -> None:
|
|
"""Callback appelé pour chaque frame capturé en mode continu."""
|
|
with self._frame_queue_lock:
|
|
self._last_frame = frame
|
|
self._frame_queue.append(frame)
|
|
# Garder seulement les 10 derniers frames en queue
|
|
if len(self._frame_queue) > 10:
|
|
self._frame_queue.pop(0)
|
|
|
|
logger.debug(f"Frame captured: {frame.frame_id} (changed={frame.changed_from_previous})")
|
|
|
|
def _get_latest_frame(self) -> Optional[CaptureFrame]:
|
|
"""Obtenir le dernier frame capturé."""
|
|
with self._frame_queue_lock:
|
|
return self._last_frame
|
|
|
|
def get_capture_stats(self) -> Dict[str, Any]:
|
|
"""Obtenir les statistiques de capture."""
|
|
stats = self.screen_capturer.get_stats()
|
|
return {
|
|
"total_captures": stats.total_captures,
|
|
"captures_per_second": stats.captures_per_second,
|
|
"unchanged_skipped": stats.unchanged_frames_skipped,
|
|
"avg_capture_time_ms": stats.average_capture_time_ms,
|
|
"buffer_size": stats.buffer_size,
|
|
"memory_mb": stats.memory_usage_mb
|
|
}
|
|
|
|
def set_continuous_capture(self, enabled: bool) -> None:
|
|
"""Activer/désactiver la capture continue."""
|
|
self._use_continuous_capture = enabled
|
|
logger.info(f"Continuous capture {'enabled' if enabled else 'disabled'}")
|
|
|
|
# =========================================================================
|
|
# Target Resolution avancée
|
|
# =========================================================================
|
|
|
|
def resolve_target_advanced(
|
|
self,
|
|
target_spec: Any,
|
|
screen_state: ScreenState
|
|
) -> Optional[ResolvedTarget]:
|
|
"""
|
|
Résoudre une cible avec le resolver avancé.
|
|
|
|
Args:
|
|
target_spec: Spécification de la cible
|
|
screen_state: État actuel de l'écran
|
|
|
|
Returns:
|
|
ResolvedTarget ou None
|
|
"""
|
|
return self.target_resolver.resolve_target(target_spec, screen_state)
|
|
|
|
def get_resolver_stats(self) -> Dict[str, Any]:
|
|
"""Obtenir les statistiques du resolver."""
|
|
return self.target_resolver.get_stats()
|
|
|
|
# =========================================================================
|
|
# GPU Resource Management
|
|
# =========================================================================
|
|
|
|
def _update_gpu_mode(self, mode: ExecutionMode) -> None:
|
|
"""
|
|
Update GPU Resource Manager based on execution mode.
|
|
|
|
Args:
|
|
mode: Current execution mode
|
|
"""
|
|
if not self._gpu_manager or not GPU_MANAGER_AVAILABLE:
|
|
return
|
|
|
|
try:
|
|
import asyncio
|
|
|
|
# Map ExecutionLoop mode to GPU mode
|
|
if mode == ExecutionMode.AUTOMATIC:
|
|
gpu_mode = GPUExecutionMode.AUTOPILOT
|
|
elif mode in [ExecutionMode.OBSERVATION, ExecutionMode.COACHING]:
|
|
gpu_mode = GPUExecutionMode.RECORDING
|
|
else:
|
|
gpu_mode = GPUExecutionMode.IDLE
|
|
|
|
# Run async mode change
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
loop.run_until_complete(self._gpu_manager.set_execution_mode(gpu_mode))
|
|
loop.close()
|
|
|
|
logger.info(f"GPU mode updated to {gpu_mode.value}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to update GPU mode: {e}")
|
|
|
|
# =========================================================================
|
|
# Continuous Learning Integration
|
|
# =========================================================================
|
|
|
|
def _update_prototype_on_success(
|
|
self,
|
|
node_id: str,
|
|
embedding: Any
|
|
) -> None:
|
|
"""
|
|
Mettre à jour le prototype après une exécution réussie.
|
|
|
|
Args:
|
|
node_id: ID du node
|
|
embedding: Embedding de l'état actuel
|
|
"""
|
|
if not self._continuous_learner or not CONTINUOUS_LEARNER_AVAILABLE:
|
|
return
|
|
|
|
try:
|
|
import numpy as np
|
|
if hasattr(embedding, 'get_vector'):
|
|
vector = embedding.get_vector()
|
|
elif isinstance(embedding, np.ndarray):
|
|
vector = embedding
|
|
else:
|
|
return
|
|
|
|
self._continuous_learner.update_prototype(
|
|
node_id=node_id,
|
|
new_embedding=vector,
|
|
execution_success=True
|
|
)
|
|
logger.debug(f"Prototype updated for node {node_id}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to update prototype: {e}")
|
|
|
|
def _check_drift(
|
|
self,
|
|
node_id: str,
|
|
confidence: float
|
|
) -> Optional[DriftStatus]:
|
|
"""
|
|
Vérifier la dérive UI après un match.
|
|
|
|
Args:
|
|
node_id: ID du node matché
|
|
confidence: Confiance du match
|
|
|
|
Returns:
|
|
DriftStatus si dérive détectée
|
|
"""
|
|
if not self._continuous_learner or not CONTINUOUS_LEARNER_AVAILABLE:
|
|
return None
|
|
|
|
try:
|
|
# Ajouter à l'historique
|
|
if node_id not in self._confidence_history:
|
|
self._confidence_history[node_id] = []
|
|
self._confidence_history[node_id].append(confidence)
|
|
|
|
# Garder les 10 dernières
|
|
self._confidence_history[node_id] = self._confidence_history[node_id][-10:]
|
|
|
|
# Vérifier dérive
|
|
drift_status = self._continuous_learner.detect_drift(
|
|
node_id=node_id,
|
|
recent_confidences=self._confidence_history[node_id]
|
|
)
|
|
|
|
if drift_status.is_drifting:
|
|
logger.warning(
|
|
f"Drift detected for node {node_id}: "
|
|
f"severity={drift_status.drift_severity:.2f}, "
|
|
f"action={drift_status.recommended_action}"
|
|
)
|
|
|
|
# Notifier via callback si disponible
|
|
if self._on_error:
|
|
self._on_error(
|
|
"drift_detected",
|
|
Exception(f"UI drift detected for node {node_id}")
|
|
)
|
|
|
|
return drift_status
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to check drift: {e}")
|
|
return None
|
|
|
|
def get_learning_stats(self) -> Dict[str, Any]:
|
|
"""Obtenir les statistiques d'apprentissage continu."""
|
|
stats = {
|
|
"continuous_learner_available": CONTINUOUS_LEARNER_AVAILABLE,
|
|
"confidence_history": {}
|
|
}
|
|
|
|
for node_id, confidences in self._confidence_history.items():
|
|
if confidences:
|
|
import numpy as np
|
|
stats["confidence_history"][node_id] = {
|
|
"count": len(confidences),
|
|
"mean": float(np.mean(confidences)),
|
|
"min": float(min(confidences)),
|
|
"max": float(max(confidences))
|
|
}
|
|
|
|
return stats
|
|
|
|
|
|
# =============================================================================
|
|
# Factory function
|
|
# =============================================================================
|
|
|
|
def create_execution_loop(
|
|
pipeline: "WorkflowPipeline",
|
|
capture_interval_ms: int = 500
|
|
) -> ExecutionLoop:
|
|
"""
|
|
Créer une boucle d'exécution avec configuration par défaut.
|
|
|
|
Args:
|
|
pipeline: WorkflowPipeline
|
|
capture_interval_ms: Intervalle de capture
|
|
|
|
Returns:
|
|
ExecutionLoop configuré
|
|
"""
|
|
return ExecutionLoop(
|
|
pipeline=pipeline,
|
|
capture_interval_ms=capture_interval_ms
|
|
)
|