feat(coaching): Implement complete COACHING mode infrastructure

Add comprehensive COACHING mode system with:

Backend:
- core/coaching module with session persistence and metrics
- CoachingSessionPersistence for pause/resume sessions
- CoachingMetricsCollector with learning progress tracking
- REST API blueprint for coaching sessions management
- Execution integration with COACHING mode support

Frontend:
- CoachingPanel component with keyboard shortcuts
- Decision buttons (accept/reject/correct/manual/skip)
- Real-time stats display and correction editor
- CorrectionPacksDashboard for pack visualization
- WebSocket hooks for real-time COACHING events

Metrics & Monitoring:
- WorkflowLearningMetrics with confidence scoring
- GlobalCoachingMetrics for system-wide analytics
- AUTO mode readiness detection (85% acceptance threshold)
- Learning progress levels (OBSERVATION → COACHING → AUTO)

Tests:
- E2E tests for complete OBSERVATION → AUTO journey
- Session persistence and recovery tests
- Metrics threshold validation tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-19 08:40:54 +01:00
parent d6e2530f2a
commit 38a1a5ddd8
21 changed files with 7269 additions and 0 deletions

40
core/coaching/__init__.py Normal file
View File

@@ -0,0 +1,40 @@
"""
COACHING Mode Module
Provides functionality for COACHING mode including:
- Session persistence and recovery
- Session state management
- Statistics tracking
- Metrics and monitoring
"""
from .session_persistence import (
CoachingSessionPersistence,
CoachingSessionState,
get_coaching_persistence,
SessionStatus,
CoachingDecisionRecord,
)
from .metrics import (
CoachingMetricsCollector,
WorkflowLearningMetrics,
GlobalCoachingMetrics,
LearningProgress,
get_metrics_collector,
)
__all__ = [
# Session persistence
'CoachingSessionPersistence',
'CoachingSessionState',
'get_coaching_persistence',
'SessionStatus',
'CoachingDecisionRecord',
# Metrics
'CoachingMetricsCollector',
'WorkflowLearningMetrics',
'GlobalCoachingMetrics',
'LearningProgress',
'get_metrics_collector',
]

462
core/coaching/metrics.py Normal file
View File

@@ -0,0 +1,462 @@
"""
COACHING Metrics Module
Provides comprehensive metrics and monitoring for COACHING mode:
- Session statistics aggregation
- Learning progress tracking
- Performance analytics
- Recommendations for mode transitions
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any, Tuple
from enum import Enum
from .session_persistence import (
CoachingSessionPersistence,
CoachingSessionState,
SessionStatus,
get_coaching_persistence
)
class LearningProgress(str, Enum):
"""Learning progress levels for workflow."""
NOT_STARTED = "not_started"
OBSERVATION = "observation" # Still collecting data
LEARNING = "learning" # Actively learning from corrections
COACHING = "coaching" # User coaching mode
READY_FOR_AUTO = "ready" # Ready for autonomous mode
AUTONOMOUS = "autonomous" # Running autonomously
@dataclass
class WorkflowLearningMetrics:
"""Metrics for a single workflow's learning progress."""
workflow_id: str
total_sessions: int = 0
completed_sessions: int = 0
total_steps_coached: int = 0
total_decisions: int = 0
accepted: int = 0
rejected: int = 0
corrected: int = 0
manual_executions: int = 0
skipped: int = 0
# Computed metrics
acceptance_rate: float = 0.0
correction_rate: float = 0.0
completion_rate: float = 0.0
# Time metrics
avg_session_duration_seconds: float = 0.0
avg_decision_time_seconds: float = 0.0
# Learning progress
learning_progress: LearningProgress = LearningProgress.NOT_STARTED
confidence_score: float = 0.0
ready_for_auto: bool = False
# Recommendations
recommendations: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
'workflow_id': self.workflow_id,
'total_sessions': self.total_sessions,
'completed_sessions': self.completed_sessions,
'total_steps_coached': self.total_steps_coached,
'total_decisions': self.total_decisions,
'accepted': self.accepted,
'rejected': self.rejected,
'corrected': self.corrected,
'manual_executions': self.manual_executions,
'skipped': self.skipped,
'acceptance_rate': self.acceptance_rate,
'correction_rate': self.correction_rate,
'completion_rate': self.completion_rate,
'avg_session_duration_seconds': self.avg_session_duration_seconds,
'avg_decision_time_seconds': self.avg_decision_time_seconds,
'learning_progress': self.learning_progress.value,
'confidence_score': self.confidence_score,
'ready_for_auto': self.ready_for_auto,
'recommendations': self.recommendations
}
@dataclass
class GlobalCoachingMetrics:
"""Global metrics across all workflows."""
total_workflows: int = 0
total_sessions: int = 0
active_sessions: int = 0
completed_sessions: int = 0
failed_sessions: int = 0
total_decisions: int = 0
total_accepted: int = 0
total_rejected: int = 0
total_corrected: int = 0
overall_acceptance_rate: float = 0.0
overall_correction_rate: float = 0.0
workflows_ready_for_auto: int = 0
workflows_in_learning: int = 0
# Time-based metrics
sessions_last_24h: int = 0
decisions_last_24h: int = 0
# Top workflows
top_workflows_by_sessions: List[Tuple[str, int]] = field(default_factory=list)
top_workflows_by_corrections: List[Tuple[str, int]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
'total_workflows': self.total_workflows,
'total_sessions': self.total_sessions,
'active_sessions': self.active_sessions,
'completed_sessions': self.completed_sessions,
'failed_sessions': self.failed_sessions,
'total_decisions': self.total_decisions,
'total_accepted': self.total_accepted,
'total_rejected': self.total_rejected,
'total_corrected': self.total_corrected,
'overall_acceptance_rate': self.overall_acceptance_rate,
'overall_correction_rate': self.overall_correction_rate,
'workflows_ready_for_auto': self.workflows_ready_for_auto,
'workflows_in_learning': self.workflows_in_learning,
'sessions_last_24h': self.sessions_last_24h,
'decisions_last_24h': self.decisions_last_24h,
'top_workflows_by_sessions': self.top_workflows_by_sessions,
'top_workflows_by_corrections': self.top_workflows_by_corrections
}
class CoachingMetricsCollector:
"""
Collector and analyzer for COACHING metrics.
Provides methods to:
- Calculate workflow-specific learning metrics
- Determine readiness for autonomous mode
- Generate recommendations for improvement
- Track global system health
"""
# Thresholds for auto mode readiness
MIN_SESSIONS_FOR_AUTO = 5
MIN_ACCEPTANCE_RATE_FOR_AUTO = 0.85
MAX_CORRECTION_RATE_FOR_AUTO = 0.10
MIN_CONFIDENCE_FOR_AUTO = 0.80
def __init__(self, persistence: Optional[CoachingSessionPersistence] = None):
"""
Initialize metrics collector.
Args:
persistence: Session persistence instance
"""
self.persistence = persistence or get_coaching_persistence()
def get_workflow_metrics(self, workflow_id: str) -> WorkflowLearningMetrics:
"""
Calculate comprehensive metrics for a workflow.
Args:
workflow_id: Workflow ID
Returns:
WorkflowLearningMetrics with all computed values
"""
# Get all sessions for this workflow
sessions = self.persistence.list_sessions(workflow_id=workflow_id, limit=1000)
metrics = WorkflowLearningMetrics(workflow_id=workflow_id)
metrics.total_sessions = len(sessions)
if not sessions:
metrics.learning_progress = LearningProgress.NOT_STARTED
metrics.recommendations = ["Demarrez une premiere session COACHING"]
return metrics
# Load full sessions for detailed analysis
full_sessions: List[CoachingSessionState] = []
for session_info in sessions:
session = self.persistence.load_session(session_info['session_id'])
if session:
full_sessions.append(session)
# Calculate basic stats
total_duration = 0.0
for session in full_sessions:
if session.status == SessionStatus.COMPLETED:
metrics.completed_sessions += 1
# Aggregate decision stats
metrics.total_steps_coached += len(session.decisions)
metrics.total_decisions += session.stats.get('suggestions_made', 0)
metrics.accepted += session.stats.get('accepted', 0)
metrics.rejected += session.stats.get('rejected', 0)
metrics.corrected += session.stats.get('corrected', 0)
metrics.manual_executions += session.stats.get('manual_executions', 0)
metrics.skipped += session.stats.get('skipped', 0)
# Calculate duration
if session.started_at and session.completed_at:
try:
start = datetime.fromisoformat(session.started_at)
end = datetime.fromisoformat(session.completed_at)
total_duration += (end - start).total_seconds()
except:
pass
# Calculate rates
total_decisions = metrics.accepted + metrics.rejected + metrics.corrected
if total_decisions > 0:
metrics.acceptance_rate = metrics.accepted / total_decisions
metrics.correction_rate = metrics.corrected / total_decisions
if metrics.total_sessions > 0:
metrics.completion_rate = metrics.completed_sessions / metrics.total_sessions
if metrics.completed_sessions > 0:
metrics.avg_session_duration_seconds = total_duration / metrics.completed_sessions
if metrics.total_decisions > 0 and total_duration > 0:
metrics.avg_decision_time_seconds = total_duration / metrics.total_decisions
# Determine learning progress
metrics.learning_progress = self._determine_learning_progress(metrics)
# Calculate confidence score
metrics.confidence_score = self._calculate_confidence_score(metrics)
# Check if ready for auto
metrics.ready_for_auto = self._check_ready_for_auto(metrics)
# Generate recommendations
metrics.recommendations = self._generate_recommendations(metrics)
return metrics
def get_global_metrics(self) -> GlobalCoachingMetrics:
"""
Calculate global metrics across all workflows.
Returns:
GlobalCoachingMetrics with aggregated data
"""
metrics = GlobalCoachingMetrics()
# Get all sessions
all_sessions = self.persistence.list_sessions(limit=10000)
metrics.total_sessions = len(all_sessions)
# Track unique workflows
workflow_stats: Dict[str, Dict] = {}
now = datetime.now()
last_24h = now - timedelta(hours=24)
for session_info in all_sessions:
workflow_id = session_info.get('workflow_id', 'unknown')
status = session_info.get('status', 'unknown')
# Initialize workflow stats
if workflow_id not in workflow_stats:
workflow_stats[workflow_id] = {
'sessions': 0,
'corrections': 0
}
workflow_stats[workflow_id]['sessions'] += 1
# Count by status
if status == 'active':
metrics.active_sessions += 1
elif status == 'completed':
metrics.completed_sessions += 1
elif status == 'failed':
metrics.failed_sessions += 1
# Check last 24h
try:
updated_at = datetime.fromisoformat(session_info.get('updated_at', ''))
if updated_at > last_24h:
metrics.sessions_last_24h += 1
except:
pass
# Load full session for decision stats
session = self.persistence.load_session(session_info['session_id'])
if session:
metrics.total_decisions += session.stats.get('suggestions_made', 0)
metrics.total_accepted += session.stats.get('accepted', 0)
metrics.total_rejected += session.stats.get('rejected', 0)
metrics.total_corrected += session.stats.get('corrected', 0)
workflow_stats[workflow_id]['corrections'] += session.stats.get('corrected', 0)
# Decisions in last 24h
for decision in session.decisions:
try:
decision_time = datetime.fromisoformat(decision.timestamp)
if decision_time > last_24h:
metrics.decisions_last_24h += 1
except:
pass
metrics.total_workflows = len(workflow_stats)
# Calculate overall rates
total_decided = metrics.total_accepted + metrics.total_rejected + metrics.total_corrected
if total_decided > 0:
metrics.overall_acceptance_rate = metrics.total_accepted / total_decided
metrics.overall_correction_rate = metrics.total_corrected / total_decided
# Count workflows by learning state
for workflow_id in workflow_stats:
wf_metrics = self.get_workflow_metrics(workflow_id)
if wf_metrics.ready_for_auto:
metrics.workflows_ready_for_auto += 1
elif wf_metrics.learning_progress in [LearningProgress.LEARNING, LearningProgress.COACHING]:
metrics.workflows_in_learning += 1
# Top workflows
sorted_by_sessions = sorted(
workflow_stats.items(),
key=lambda x: x[1]['sessions'],
reverse=True
)[:5]
metrics.top_workflows_by_sessions = [
(wf_id, stats['sessions']) for wf_id, stats in sorted_by_sessions
]
sorted_by_corrections = sorted(
workflow_stats.items(),
key=lambda x: x[1]['corrections'],
reverse=True
)[:5]
metrics.top_workflows_by_corrections = [
(wf_id, stats['corrections']) for wf_id, stats in sorted_by_corrections
]
return metrics
def _determine_learning_progress(self, metrics: WorkflowLearningMetrics) -> LearningProgress:
"""Determine the learning progress level."""
if metrics.total_sessions == 0:
return LearningProgress.NOT_STARTED
if metrics.total_sessions < 3:
return LearningProgress.OBSERVATION
if metrics.acceptance_rate < 0.5:
return LearningProgress.LEARNING
if metrics.acceptance_rate >= self.MIN_ACCEPTANCE_RATE_FOR_AUTO and \
metrics.correction_rate <= self.MAX_CORRECTION_RATE_FOR_AUTO and \
metrics.total_sessions >= self.MIN_SESSIONS_FOR_AUTO:
return LearningProgress.READY_FOR_AUTO
return LearningProgress.COACHING
def _calculate_confidence_score(self, metrics: WorkflowLearningMetrics) -> float:
"""Calculate overall confidence score (0-1)."""
if metrics.total_decisions == 0:
return 0.0
# Weighted factors
acceptance_weight = 0.4
correction_weight = 0.3
completion_weight = 0.2
volume_weight = 0.1
# Acceptance component (higher is better)
acceptance_score = metrics.acceptance_rate
# Correction component (lower is better)
correction_score = max(0, 1 - metrics.correction_rate * 2)
# Completion component
completion_score = metrics.completion_rate
# Volume component (normalized, caps at 10 sessions)
volume_score = min(1, metrics.total_sessions / 10)
confidence = (
acceptance_weight * acceptance_score +
correction_weight * correction_score +
completion_weight * completion_score +
volume_weight * volume_score
)
return round(confidence, 3)
def _check_ready_for_auto(self, metrics: WorkflowLearningMetrics) -> bool:
"""Check if workflow is ready for autonomous mode."""
return (
metrics.total_sessions >= self.MIN_SESSIONS_FOR_AUTO and
metrics.acceptance_rate >= self.MIN_ACCEPTANCE_RATE_FOR_AUTO and
metrics.correction_rate <= self.MAX_CORRECTION_RATE_FOR_AUTO and
metrics.confidence_score >= self.MIN_CONFIDENCE_FOR_AUTO
)
def _generate_recommendations(self, metrics: WorkflowLearningMetrics) -> List[str]:
"""Generate actionable recommendations."""
recommendations = []
if metrics.total_sessions == 0:
recommendations.append("Demarrez votre premiere session COACHING pour commencer l'apprentissage")
return recommendations
if metrics.total_sessions < self.MIN_SESSIONS_FOR_AUTO:
remaining = self.MIN_SESSIONS_FOR_AUTO - metrics.total_sessions
recommendations.append(f"Completez {remaining} session(s) supplementaire(s) pour atteindre le minimum requis")
if metrics.acceptance_rate < self.MIN_ACCEPTANCE_RATE_FOR_AUTO:
current_pct = round(metrics.acceptance_rate * 100, 1)
target_pct = round(self.MIN_ACCEPTANCE_RATE_FOR_AUTO * 100, 1)
recommendations.append(
f"Ameliorez le taux d'acceptation de {current_pct}% a {target_pct}% "
"en ajustant les selecteurs d'elements"
)
if metrics.correction_rate > self.MAX_CORRECTION_RATE_FOR_AUTO:
recommendations.append(
"Le taux de correction est eleve. Verifiez les elements visuels "
"qui necessitent souvent des corrections"
)
if metrics.rejected > metrics.total_sessions * 2:
recommendations.append(
"Beaucoup d'actions rejetees. Revisez le workflow pour supprimer "
"les etapes incorrectes"
)
if metrics.manual_executions > metrics.total_decisions * 0.1:
recommendations.append(
"Plusieurs executions manuelles detectees. Considerez automatiser "
"ces actions frequentes"
)
if metrics.ready_for_auto:
recommendations.append(
"Ce workflow est pret pour le mode autonome ! "
"Vous pouvez le passer en mode AUTO"
)
return recommendations
# Singleton instance
_metrics_collector: Optional[CoachingMetricsCollector] = None
def get_metrics_collector(persistence: Optional[CoachingSessionPersistence] = None) -> CoachingMetricsCollector:
"""Get or create the global metrics collector."""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = CoachingMetricsCollector(persistence)
return _metrics_collector

View File

@@ -0,0 +1,553 @@
"""
COACHING Session Persistence Module
Provides persistence layer for COACHING sessions to enable:
- Save session state for recovery after interruption
- Resume sessions from last known state
- Track session history and statistics
"""
import json
import os
import shutil
from dataclasses import dataclass, field, asdict
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, Any
import threading
import uuid
class SessionStatus(str, Enum):
"""Status of a COACHING session."""
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
ABANDONED = "abandoned"
@dataclass
class CoachingDecisionRecord:
"""Record of a single coaching decision."""
step_index: int
node_id: str
action_type: str
decision: str # accept, reject, correct, manual, skip
correction: Optional[Dict[str, Any]] = None
feedback: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
execution_success: Optional[bool] = None
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'CoachingDecisionRecord':
return cls(**data)
@dataclass
class CoachingSessionState:
"""
Complete state of a COACHING session.
This state can be persisted and recovered to resume an interrupted session.
"""
session_id: str
workflow_id: str
execution_id: str
status: SessionStatus = SessionStatus.ACTIVE
current_step_index: int = 0
total_steps: int = 0
decisions: List[CoachingDecisionRecord] = field(default_factory=list)
stats: Dict[str, int] = field(default_factory=lambda: {
'suggestions_made': 0,
'accepted': 0,
'rejected': 0,
'corrected': 0,
'manual_executions': 0,
'skipped': 0
})
variables: Dict[str, Any] = field(default_factory=dict)
started_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
completed_at: Optional[str] = None
error_message: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
data = {
'session_id': self.session_id,
'workflow_id': self.workflow_id,
'execution_id': self.execution_id,
'status': self.status.value if isinstance(self.status, SessionStatus) else self.status,
'current_step_index': self.current_step_index,
'total_steps': self.total_steps,
'decisions': [d.to_dict() for d in self.decisions],
'stats': self.stats,
'variables': self.variables,
'started_at': self.started_at,
'updated_at': self.updated_at,
'completed_at': self.completed_at,
'error_message': self.error_message,
'metadata': self.metadata
}
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'CoachingSessionState':
decisions = [
CoachingDecisionRecord.from_dict(d)
for d in data.get('decisions', [])
]
status = data.get('status', 'active')
if isinstance(status, str):
status = SessionStatus(status)
return cls(
session_id=data['session_id'],
workflow_id=data['workflow_id'],
execution_id=data['execution_id'],
status=status,
current_step_index=data.get('current_step_index', 0),
total_steps=data.get('total_steps', 0),
decisions=decisions,
stats=data.get('stats', {}),
variables=data.get('variables', {}),
started_at=data.get('started_at', datetime.now().isoformat()),
updated_at=data.get('updated_at', datetime.now().isoformat()),
completed_at=data.get('completed_at'),
error_message=data.get('error_message'),
metadata=data.get('metadata', {})
)
def update_timestamp(self) -> None:
"""Update the updated_at timestamp."""
self.updated_at = datetime.now().isoformat()
def add_decision(self, decision: CoachingDecisionRecord) -> None:
"""Add a decision and update stats."""
self.decisions.append(decision)
self.stats['suggestions_made'] += 1
if decision.decision == 'accept':
self.stats['accepted'] += 1
elif decision.decision == 'reject':
self.stats['rejected'] += 1
elif decision.decision == 'correct':
self.stats['corrected'] += 1
elif decision.decision == 'manual':
self.stats['manual_executions'] += 1
elif decision.decision == 'skip':
self.stats['skipped'] += 1
self.current_step_index += 1
self.update_timestamp()
def get_acceptance_rate(self) -> float:
"""Calculate acceptance rate."""
total = self.stats['accepted'] + self.stats['rejected'] + self.stats['corrected']
if total == 0:
return 0.0
return self.stats['accepted'] / total
def get_correction_rate(self) -> float:
"""Calculate correction rate."""
total = self.stats['accepted'] + self.stats['rejected'] + self.stats['corrected']
if total == 0:
return 0.0
return self.stats['corrected'] / total
def can_resume(self) -> bool:
"""Check if session can be resumed."""
return self.status in [SessionStatus.ACTIVE, SessionStatus.PAUSED]
class CoachingSessionPersistence:
"""
Persistence layer for COACHING sessions.
Handles saving, loading, and managing COACHING session states.
"""
def __init__(self, storage_path: Optional[Path] = None):
"""
Initialize persistence layer.
Args:
storage_path: Path to store session data. Defaults to data/coaching_sessions
"""
if storage_path is None:
storage_path = Path(__file__).parent.parent.parent / 'data' / 'coaching_sessions'
self.storage_path = Path(storage_path)
self.storage_path.mkdir(parents=True, exist_ok=True)
# Index file for quick lookup
self._index_file = self.storage_path / 'sessions_index.json'
self._index: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
self._load_index()
def _load_index(self) -> None:
"""Load the sessions index."""
if self._index_file.exists():
try:
with open(self._index_file, 'r') as f:
self._index = json.load(f)
except Exception as e:
print(f"Warning: Could not load sessions index: {e}")
self._index = {}
def _save_index(self) -> None:
"""Save the sessions index."""
try:
temp_file = self._index_file.with_suffix('.tmp')
with open(temp_file, 'w') as f:
json.dump(self._index, f, indent=2)
shutil.move(str(temp_file), str(self._index_file))
except Exception as e:
print(f"Warning: Could not save sessions index: {e}")
def _session_file(self, session_id: str) -> Path:
"""Get the path for a session file."""
return self.storage_path / f"{session_id}.json"
def create_session(
self,
workflow_id: str,
execution_id: str,
total_steps: int = 0,
variables: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None
) -> CoachingSessionState:
"""
Create a new COACHING session.
Args:
workflow_id: ID of the workflow being coached
execution_id: ID of the execution
total_steps: Total number of steps in the workflow
variables: Initial variables
metadata: Additional metadata
Returns:
New session state
"""
session_id = f"coaching_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
session = CoachingSessionState(
session_id=session_id,
workflow_id=workflow_id,
execution_id=execution_id,
total_steps=total_steps,
variables=variables or {},
metadata=metadata or {}
)
self.save_session(session)
return session
def save_session(self, session: CoachingSessionState) -> None:
"""
Save a session state to disk.
Args:
session: Session state to save
"""
with self._lock:
session.update_timestamp()
# Save session file
session_file = self._session_file(session.session_id)
try:
temp_file = session_file.with_suffix('.tmp')
with open(temp_file, 'w') as f:
json.dump(session.to_dict(), f, indent=2)
shutil.move(str(temp_file), str(session_file))
except Exception as e:
raise RuntimeError(f"Failed to save session {session.session_id}: {e}")
# Update index
self._index[session.session_id] = {
'workflow_id': session.workflow_id,
'execution_id': session.execution_id,
'status': session.status.value if isinstance(session.status, SessionStatus) else session.status,
'started_at': session.started_at,
'updated_at': session.updated_at,
'current_step': session.current_step_index,
'total_steps': session.total_steps
}
self._save_index()
def load_session(self, session_id: str) -> Optional[CoachingSessionState]:
"""
Load a session state from disk.
Args:
session_id: ID of the session to load
Returns:
Session state or None if not found
"""
session_file = self._session_file(session_id)
if not session_file.exists():
return None
try:
with open(session_file, 'r') as f:
data = json.load(f)
return CoachingSessionState.from_dict(data)
except Exception as e:
print(f"Warning: Could not load session {session_id}: {e}")
return None
def delete_session(self, session_id: str) -> bool:
"""
Delete a session.
Args:
session_id: ID of the session to delete
Returns:
True if deleted, False otherwise
"""
with self._lock:
session_file = self._session_file(session_id)
if session_file.exists():
session_file.unlink()
if session_id in self._index:
del self._index[session_id]
self._save_index()
return True
return False
def list_sessions(
self,
workflow_id: Optional[str] = None,
status: Optional[SessionStatus] = None,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
List sessions with optional filters.
Args:
workflow_id: Filter by workflow ID
status: Filter by status
limit: Maximum number of sessions to return
Returns:
List of session summaries
"""
sessions = []
for session_id, info in self._index.items():
# Apply filters
if workflow_id and info.get('workflow_id') != workflow_id:
continue
if status:
session_status = info.get('status', 'active')
if session_status != status.value:
continue
sessions.append({
'session_id': session_id,
**info
})
# Sort by updated_at descending
sessions.sort(key=lambda x: x.get('updated_at', ''), reverse=True)
return sessions[:limit]
def get_resumable_sessions(self, workflow_id: str) -> List[CoachingSessionState]:
"""
Get all resumable sessions for a workflow.
Args:
workflow_id: Workflow ID to filter by
Returns:
List of resumable session states
"""
resumable = []
for session_info in self.list_sessions(workflow_id=workflow_id):
status = session_info.get('status', 'active')
if status in ['active', 'paused']:
session = self.load_session(session_info['session_id'])
if session and session.can_resume():
resumable.append(session)
return resumable
def pause_session(self, session_id: str) -> bool:
"""
Pause an active session.
Args:
session_id: Session to pause
Returns:
True if paused, False otherwise
"""
session = self.load_session(session_id)
if session and session.status == SessionStatus.ACTIVE:
session.status = SessionStatus.PAUSED
self.save_session(session)
return True
return False
def resume_session(self, session_id: str) -> Optional[CoachingSessionState]:
"""
Resume a paused session.
Args:
session_id: Session to resume
Returns:
Resumed session or None if cannot resume
"""
session = self.load_session(session_id)
if session and session.can_resume():
session.status = SessionStatus.ACTIVE
self.save_session(session)
return session
return None
def complete_session(
self,
session_id: str,
success: bool = True,
error_message: Optional[str] = None
) -> Optional[CoachingSessionState]:
"""
Mark a session as completed.
Args:
session_id: Session to complete
success: Whether the session completed successfully
error_message: Error message if failed
Returns:
Completed session or None
"""
session = self.load_session(session_id)
if session:
session.status = SessionStatus.COMPLETED if success else SessionStatus.FAILED
session.completed_at = datetime.now().isoformat()
session.error_message = error_message
self.save_session(session)
return session
return None
def abandon_session(self, session_id: str) -> bool:
"""
Mark a session as abandoned.
Args:
session_id: Session to abandon
Returns:
True if abandoned
"""
session = self.load_session(session_id)
if session:
session.status = SessionStatus.ABANDONED
session.completed_at = datetime.now().isoformat()
self.save_session(session)
return True
return False
def cleanup_old_sessions(self, max_age_days: int = 30) -> int:
"""
Remove sessions older than max_age_days.
Args:
max_age_days: Maximum age in days
Returns:
Number of sessions removed
"""
cutoff = datetime.now().timestamp() - (max_age_days * 24 * 3600)
removed = 0
with self._lock:
to_remove = []
for session_id, info in self._index.items():
try:
updated_at = datetime.fromisoformat(info.get('updated_at', '')).timestamp()
if updated_at < cutoff:
to_remove.append(session_id)
except:
pass
for session_id in to_remove:
session_file = self._session_file(session_id)
if session_file.exists():
session_file.unlink()
del self._index[session_id]
removed += 1
if removed > 0:
self._save_index()
return removed
def get_statistics(self) -> Dict[str, Any]:
"""
Get overall statistics about COACHING sessions.
Returns:
Statistics dictionary
"""
total = len(self._index)
by_status = {}
total_decisions = 0
total_accepted = 0
total_corrected = 0
for session_id, info in self._index.items():
status = info.get('status', 'unknown')
by_status[status] = by_status.get(status, 0) + 1
# Load full session for detailed stats
session = self.load_session(session_id)
if session:
total_decisions += session.stats.get('suggestions_made', 0)
total_accepted += session.stats.get('accepted', 0)
total_corrected += session.stats.get('corrected', 0)
return {
'total_sessions': total,
'by_status': by_status,
'total_decisions': total_decisions,
'total_accepted': total_accepted,
'total_corrected': total_corrected,
'overall_acceptance_rate': total_accepted / total_decisions if total_decisions > 0 else 0,
'overall_correction_rate': total_corrected / total_decisions if total_decisions > 0 else 0
}
# Global instance
_global_persistence: Optional[CoachingSessionPersistence] = None
def get_coaching_persistence(storage_path: Optional[Path] = None) -> CoachingSessionPersistence:
"""
Get or create the global coaching session persistence instance.
Args:
storage_path: Optional custom storage path
Returns:
CoachingSessionPersistence instance
"""
global _global_persistence
if _global_persistence is None:
_global_persistence = CoachingSessionPersistence(storage_path)
return _global_persistence

486
tests/test_coaching_e2e.py Normal file
View File

@@ -0,0 +1,486 @@
"""
End-to-End Tests for COACHING Mode
Tests the complete OBSERVATION -> COACHING -> AUTO workflow:
1. Start in OBSERVATION mode (record user actions)
2. Transition to COACHING mode (suggest actions, get user feedback)
3. Accumulate corrections in Correction Packs
4. Track metrics and determine readiness for AUTO mode
5. Transition to AUTO mode when confidence threshold is met
This test simulates the complete learning journey of a workflow.
"""
import pytest
import tempfile
import shutil
import time
from pathlib import Path
from datetime import datetime
from unittest.mock import MagicMock, patch
@pytest.fixture
def temp_storage():
"""Create temporary storage directories."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def coaching_persistence(temp_storage):
"""Create coaching persistence with temp storage."""
from core.coaching import CoachingSessionPersistence
return CoachingSessionPersistence(temp_storage / 'coaching_sessions')
@pytest.fixture
def correction_service(temp_storage):
"""Create correction pack service with temp storage."""
from core.corrections import CorrectionPackService
return CorrectionPackService(storage_path=temp_storage / 'correction_packs')
@pytest.fixture
def metrics_collector(coaching_persistence):
"""Create metrics collector."""
from core.coaching import CoachingMetricsCollector
return CoachingMetricsCollector(coaching_persistence)
class TestCoachingE2E:
"""End-to-end tests for the complete COACHING workflow."""
def test_complete_learning_journey(
self,
coaching_persistence,
correction_service,
metrics_collector
):
"""
Test the complete learning journey from OBSERVATION to AUTO.
Scenario:
1. Create workflow and start first COACHING session
2. Make decisions (mix of accept, correct, reject)
3. Corrections are captured in Correction Packs
4. Run multiple sessions to build confidence
5. Check metrics and readiness for AUTO
"""
workflow_id = "wf_e2e_test_001"
# =====================================================================
# Phase 1: First COACHING session - Learning phase
# =====================================================================
print("\n=== Phase 1: First COACHING Session ===")
session1 = coaching_persistence.create_session(
workflow_id=workflow_id,
execution_id="exec_001",
total_steps=5,
metadata={'phase': 'learning'}
)
# Simulate decisions with some corrections
from core.coaching.session_persistence import CoachingDecisionRecord
decisions_p1 = [
('accept', None),
('correct', {'target': {'id': 'new_btn'}}),
('accept', None),
('reject', None),
('accept', None),
]
for i, (decision, correction) in enumerate(decisions_p1):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision=decision,
correction=correction,
feedback=f"Decision {i+1}"
)
session1.add_decision(record)
coaching_persistence.complete_session(session1.session_id, success=True)
# Verify session stats
session1_reloaded = coaching_persistence.load_session(session1.session_id)
assert session1_reloaded.stats['accepted'] == 3
assert session1_reloaded.stats['corrected'] == 1
assert session1_reloaded.stats['rejected'] == 1
print(f"Session 1 completed: {session1_reloaded.stats}")
# =====================================================================
# Phase 2: Multiple sessions to improve acceptance rate
# =====================================================================
print("\n=== Phase 2: Multiple Training Sessions ===")
# Session 2: Better acceptance after learning
session2 = coaching_persistence.create_session(
workflow_id=workflow_id,
execution_id="exec_002",
total_steps=5
)
# Most actions accepted now (corrections are working)
decisions_p2 = [
('accept', None),
('accept', None),
('accept', None),
('accept', None),
('correct', {'target': {'text': 'Submit'}}),
]
for i, (decision, correction) in enumerate(decisions_p2):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision=decision,
correction=correction
)
session2.add_decision(record)
coaching_persistence.complete_session(session2.session_id, success=True)
print(f"Session 2 completed: {session2.stats}")
# Sessions 3-5: High acceptance rate
for sess_num in range(3, 6):
session = coaching_persistence.create_session(
workflow_id=workflow_id,
execution_id=f"exec_{sess_num:03d}",
total_steps=5
)
# All accepted
for i in range(5):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision='accept'
)
session.add_decision(record)
coaching_persistence.complete_session(session.session_id, success=True)
print(f"Session {sess_num} completed: all accepted")
# =====================================================================
# Phase 3: Check Metrics and Learning Progress
# =====================================================================
print("\n=== Phase 3: Checking Metrics ===")
metrics = metrics_collector.get_workflow_metrics(workflow_id)
print(f"Total sessions: {metrics.total_sessions}")
print(f"Total decisions: {metrics.total_decisions}")
print(f"Acceptance rate: {metrics.acceptance_rate:.2%}")
print(f"Correction rate: {metrics.correction_rate:.2%}")
print(f"Confidence score: {metrics.confidence_score:.2f}")
print(f"Learning progress: {metrics.learning_progress.value}")
print(f"Ready for AUTO: {metrics.ready_for_auto}")
print(f"Recommendations: {metrics.recommendations}")
# Assertions
assert metrics.total_sessions == 5
assert metrics.total_decisions == 25
assert metrics.acceptance_rate > 0.8 # Should be high after training
assert metrics.correction_rate < 0.15 # Should be low
# =====================================================================
# Phase 4: Verify Readiness for AUTO
# =====================================================================
print("\n=== Phase 4: AUTO Mode Readiness ===")
# The workflow should be ready for AUTO after successful training
assert metrics.ready_for_auto, "Workflow should be ready for AUTO mode"
assert metrics.learning_progress.value in ['ready', 'autonomous']
print("SUCCESS: Workflow is ready for autonomous execution!")
def test_session_persistence_and_recovery(self, coaching_persistence):
"""
Test that COACHING sessions can be paused and resumed.
"""
print("\n=== Testing Session Persistence ===")
workflow_id = "wf_persistence_test"
# Create and partially complete a session
session = coaching_persistence.create_session(
workflow_id=workflow_id,
execution_id="exec_persist",
total_steps=10
)
from core.coaching.session_persistence import CoachingDecisionRecord
# Add 3 decisions
for i in range(3):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision='accept'
)
session.add_decision(record)
coaching_persistence.save_session(session)
# Pause the session
coaching_persistence.pause_session(session.session_id)
# Verify paused
loaded = coaching_persistence.load_session(session.session_id)
assert loaded.status.value == 'paused'
assert len(loaded.decisions) == 3
assert loaded.current_step_index == 3
# Resume the session
resumed = coaching_persistence.resume_session(session.session_id)
assert resumed.status.value == 'active'
assert resumed.can_resume() is True
# Continue adding decisions
for i in range(3, 6):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision='accept'
)
resumed.add_decision(record)
coaching_persistence.save_session(resumed)
# Verify continuation
final = coaching_persistence.load_session(session.session_id)
assert len(final.decisions) == 6
assert final.current_step_index == 6
print("SUCCESS: Session persistence and recovery works correctly!")
def test_correction_integration_with_coaching(
self,
coaching_persistence,
correction_service
):
"""
Test that COACHING corrections integrate with Correction Packs.
"""
print("\n=== Testing Correction Integration ===")
from core.corrections import CorrectionPackIntegration
# Create integration
integration = CorrectionPackIntegration(
service=correction_service,
auto_create_pack=True
)
workflow_id = "wf_correction_test"
# Create COACHING session
session = coaching_persistence.create_session(
workflow_id=workflow_id,
execution_id="exec_correction",
total_steps=5
)
from core.coaching.session_persistence import CoachingDecisionRecord
# Simulate corrections
corrections_made = [
{
'action_type': 'click',
'element_type': 'button',
'failure_reason': 'element_not_found',
'correction_type': 'target_change',
'original_target': {'text': 'OK'},
'corrected_target': {'text': 'Valider'}
},
{
'action_type': 'type',
'element_type': 'input',
'failure_reason': 'wrong_field',
'correction_type': 'target_change',
'original_target': {'id': 'email'},
'corrected_target': {'name': 'user_email'}
}
]
# Add decisions with corrections
for i, correction_data in enumerate(corrections_made):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type=correction_data['action_type'],
decision='correct',
correction=correction_data
)
session.add_decision(record)
# Capture correction in Correction Pack
integration.capture_correction(
correction_data=correction_data,
session_id=session.session_id,
workflow_id=workflow_id
)
coaching_persistence.complete_session(session.session_id, success=True)
# Verify corrections captured in pack
pack = correction_service.get_pack(integration._default_pack_id)
corrections_list = pack.get('corrections') if isinstance(pack, dict) else pack.corrections
assert len(corrections_list) == 2
print(f"Captured {len(corrections_list)} corrections in Correction Pack")
print("SUCCESS: Corrections integrated correctly!")
def test_metrics_threshold_for_auto_mode(self, coaching_persistence, metrics_collector):
"""
Test that metrics correctly determine AUTO mode readiness.
"""
print("\n=== Testing AUTO Mode Threshold ===")
from core.coaching.session_persistence import CoachingDecisionRecord
workflow_id = "wf_threshold_test"
# Test case 1: Below threshold (too few sessions)
session = coaching_persistence.create_session(
workflow_id=workflow_id,
execution_id="exec_001",
total_steps=5
)
for i in range(5):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision='accept'
)
session.add_decision(record)
coaching_persistence.complete_session(session.session_id, success=True)
metrics = metrics_collector.get_workflow_metrics(workflow_id)
assert not metrics.ready_for_auto, "Should not be ready with only 1 session"
# Test case 2: Meet minimum sessions
for sess_num in range(2, 6):
session = coaching_persistence.create_session(
workflow_id=workflow_id,
execution_id=f"exec_{sess_num:03d}",
total_steps=5
)
for i in range(5):
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision='accept'
)
session.add_decision(record)
coaching_persistence.complete_session(session.session_id, success=True)
metrics = metrics_collector.get_workflow_metrics(workflow_id)
print(f"After 5 sessions - Acceptance: {metrics.acceptance_rate:.2%}, Ready: {metrics.ready_for_auto}")
assert metrics.ready_for_auto, "Should be ready after 5 sessions with high acceptance"
print("SUCCESS: Threshold calculation works correctly!")
def test_global_metrics_aggregation(self, coaching_persistence, metrics_collector):
"""
Test global metrics aggregation across multiple workflows.
"""
print("\n=== Testing Global Metrics ===")
from core.coaching.session_persistence import CoachingDecisionRecord
# Create sessions for multiple workflows
workflows = ["wf_global_1", "wf_global_2", "wf_global_3"]
for wf_id in workflows:
for sess_num in range(3):
session = coaching_persistence.create_session(
workflow_id=wf_id,
execution_id=f"exec_{wf_id}_{sess_num}",
total_steps=3
)
for i in range(3):
decision = 'accept' if i != 1 else 'correct'
record = CoachingDecisionRecord(
step_index=i,
node_id=f"node_{i+1}",
action_type='click',
decision=decision
)
session.add_decision(record)
coaching_persistence.complete_session(session.session_id, success=True)
# Get global metrics
global_metrics = metrics_collector.get_global_metrics()
print(f"Total workflows: {global_metrics.total_workflows}")
print(f"Total sessions: {global_metrics.total_sessions}")
print(f"Total decisions: {global_metrics.total_decisions}")
print(f"Acceptance rate: {global_metrics.overall_acceptance_rate:.2%}")
assert global_metrics.total_workflows == 3
assert global_metrics.total_sessions == 9 # 3 workflows x 3 sessions
assert global_metrics.total_decisions == 27 # 9 sessions x 3 decisions
print("SUCCESS: Global metrics aggregation works correctly!")
class TestCoachingAPIIntegration:
"""Tests for COACHING API integration."""
def test_api_session_lifecycle(self, coaching_persistence):
"""Test session lifecycle through persistence layer (API simulation)."""
print("\n=== Testing API Session Lifecycle ===")
from core.coaching.session_persistence import CoachingDecisionRecord
# Create session (simulating POST /api/coaching-sessions)
session = coaching_persistence.create_session(
workflow_id="wf_api_test",
execution_id="exec_api",
total_steps=3
)
assert session.session_id is not None
# Add decision (simulating POST /api/coaching-sessions/{id}/decisions)
record = CoachingDecisionRecord(
step_index=0,
node_id="node_1",
action_type="click",
decision="accept"
)
session.add_decision(record)
coaching_persistence.save_session(session)
# Get session (simulating GET /api/coaching-sessions/{id})
loaded = coaching_persistence.load_session(session.session_id)
assert loaded is not None
assert len(loaded.decisions) == 1
# Complete session (simulating POST /api/coaching-sessions/{id}/complete)
completed = coaching_persistence.complete_session(session.session_id, success=True)
assert completed.status.value == 'completed'
print("SUCCESS: API session lifecycle works correctly!")
if __name__ == '__main__':
pytest.main([__file__, '-v', '-s'])

View File

@@ -0,0 +1,531 @@
"""
COACHING Sessions API Blueprint
Provides REST endpoints for managing COACHING session persistence:
- List/create/load sessions
- Pause/resume sessions
- Session statistics
"""
import sys
from pathlib import Path
from flask import Blueprint, jsonify, request
# Add core to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from core.coaching import (
CoachingSessionPersistence,
CoachingSessionState,
get_coaching_persistence,
)
from core.coaching.session_persistence import SessionStatus
coaching_sessions_bp = Blueprint('coaching_sessions', __name__)
def get_persistence() -> CoachingSessionPersistence:
"""Get the coaching session persistence instance."""
return get_coaching_persistence()
@coaching_sessions_bp.route('/', methods=['GET'])
def list_sessions():
"""
List COACHING sessions.
Query params:
workflow_id: Filter by workflow ID
status: Filter by status (active, paused, completed, failed, abandoned)
limit: Maximum number of sessions (default: 100)
Returns:
sessions: List of session summaries
"""
workflow_id = request.args.get('workflow_id')
status_str = request.args.get('status')
limit = int(request.args.get('limit', 100))
status = None
if status_str:
try:
status = SessionStatus(status_str)
except ValueError:
return jsonify({
'error': f'Invalid status. Valid values: {[s.value for s in SessionStatus]}'
}), 400
persistence = get_persistence()
sessions = persistence.list_sessions(
workflow_id=workflow_id,
status=status,
limit=limit
)
return jsonify({'sessions': sessions})
@coaching_sessions_bp.route('/', methods=['POST'])
def create_session():
"""
Create a new COACHING session.
Body JSON:
workflow_id: str - ID of the workflow
execution_id: str - ID of the execution
total_steps: int (optional) - Total number of steps
variables: dict (optional) - Initial variables
metadata: dict (optional) - Additional metadata
Returns:
session: Created session state
"""
data = request.get_json() or {}
workflow_id = data.get('workflow_id')
execution_id = data.get('execution_id')
if not workflow_id or not execution_id:
return jsonify({'error': 'workflow_id and execution_id are required'}), 400
persistence = get_persistence()
session = persistence.create_session(
workflow_id=workflow_id,
execution_id=execution_id,
total_steps=data.get('total_steps', 0),
variables=data.get('variables', {}),
metadata=data.get('metadata', {})
)
return jsonify({
'message': 'Session created',
'session': session.to_dict()
}), 201
@coaching_sessions_bp.route('/<session_id>', methods=['GET'])
def get_session(session_id: str):
"""
Get a COACHING session by ID.
Returns:
session: Full session state
"""
persistence = get_persistence()
session = persistence.load_session(session_id)
if session is None:
return jsonify({'error': f'Session {session_id} not found'}), 404
return jsonify({'session': session.to_dict()})
@coaching_sessions_bp.route('/<session_id>', methods=['PUT'])
def update_session(session_id: str):
"""
Update a COACHING session.
Body JSON:
current_step_index: int (optional)
variables: dict (optional)
metadata: dict (optional)
Returns:
session: Updated session state
"""
data = request.get_json() or {}
persistence = get_persistence()
session = persistence.load_session(session_id)
if session is None:
return jsonify({'error': f'Session {session_id} not found'}), 404
# Update allowed fields
if 'current_step_index' in data:
session.current_step_index = data['current_step_index']
if 'variables' in data:
session.variables.update(data['variables'])
if 'metadata' in data:
session.metadata.update(data['metadata'])
persistence.save_session(session)
return jsonify({
'message': 'Session updated',
'session': session.to_dict()
})
@coaching_sessions_bp.route('/<session_id>', methods=['DELETE'])
def delete_session(session_id: str):
"""
Delete a COACHING session.
Returns:
success: bool
"""
persistence = get_persistence()
if persistence.delete_session(session_id):
return jsonify({
'message': 'Session deleted',
'session_id': session_id
})
else:
return jsonify({'error': f'Session {session_id} not found'}), 404
@coaching_sessions_bp.route('/<session_id>/decisions', methods=['POST'])
def add_decision(session_id: str):
"""
Add a decision to a COACHING session.
Body JSON:
step_index: int
node_id: str
action_type: str
decision: str (accept, reject, correct, manual, skip)
correction: dict (optional)
feedback: str (optional)
execution_success: bool (optional)
Returns:
session: Updated session state
"""
data = request.get_json() or {}
required = ['step_index', 'node_id', 'action_type', 'decision']
for field in required:
if field not in data:
return jsonify({'error': f'{field} is required'}), 400
valid_decisions = ['accept', 'reject', 'correct', 'manual', 'skip']
if data['decision'] not in valid_decisions:
return jsonify({
'error': f'Invalid decision. Valid values: {valid_decisions}'
}), 400
persistence = get_persistence()
session = persistence.load_session(session_id)
if session is None:
return jsonify({'error': f'Session {session_id} not found'}), 404
from core.coaching.session_persistence import CoachingDecisionRecord
decision = CoachingDecisionRecord(
step_index=data['step_index'],
node_id=data['node_id'],
action_type=data['action_type'],
decision=data['decision'],
correction=data.get('correction'),
feedback=data.get('feedback'),
execution_success=data.get('execution_success')
)
session.add_decision(decision)
persistence.save_session(session)
return jsonify({
'message': 'Decision added',
'session': session.to_dict()
})
@coaching_sessions_bp.route('/<session_id>/pause', methods=['POST'])
def pause_session(session_id: str):
"""
Pause an active COACHING session.
Returns:
success: bool
"""
persistence = get_persistence()
if persistence.pause_session(session_id):
session = persistence.load_session(session_id)
return jsonify({
'message': 'Session paused',
'session': session.to_dict() if session else None
})
else:
return jsonify({
'error': 'Cannot pause session (not active or not found)'
}), 400
@coaching_sessions_bp.route('/<session_id>/resume', methods=['POST'])
def resume_session(session_id: str):
"""
Resume a paused COACHING session.
Returns:
session: Resumed session state
"""
persistence = get_persistence()
session = persistence.resume_session(session_id)
if session:
return jsonify({
'message': 'Session resumed',
'session': session.to_dict()
})
else:
return jsonify({
'error': 'Cannot resume session (not paused or not found)'
}), 400
@coaching_sessions_bp.route('/<session_id>/complete', methods=['POST'])
def complete_session(session_id: str):
"""
Mark a COACHING session as completed.
Body JSON:
success: bool (default: True)
error_message: str (optional)
Returns:
session: Completed session state
"""
data = request.get_json() or {}
success = data.get('success', True)
error_message = data.get('error_message')
persistence = get_persistence()
session = persistence.complete_session(
session_id,
success=success,
error_message=error_message
)
if session:
return jsonify({
'message': 'Session completed',
'session': session.to_dict()
})
else:
return jsonify({'error': f'Session {session_id} not found'}), 404
@coaching_sessions_bp.route('/<session_id>/abandon', methods=['POST'])
def abandon_session(session_id: str):
"""
Mark a COACHING session as abandoned.
Returns:
success: bool
"""
persistence = get_persistence()
if persistence.abandon_session(session_id):
return jsonify({
'message': 'Session abandoned',
'session_id': session_id
})
else:
return jsonify({'error': f'Session {session_id} not found'}), 404
@coaching_sessions_bp.route('/resumable', methods=['GET'])
def get_resumable_sessions():
"""
Get all resumable sessions for a workflow.
Query params:
workflow_id: str (required) - Workflow ID
Returns:
sessions: List of resumable session states
"""
workflow_id = request.args.get('workflow_id')
if not workflow_id:
return jsonify({'error': 'workflow_id is required'}), 400
persistence = get_persistence()
sessions = persistence.get_resumable_sessions(workflow_id)
return jsonify({
'sessions': [s.to_dict() for s in sessions]
})
@coaching_sessions_bp.route('/statistics', methods=['GET'])
def get_statistics():
"""
Get overall COACHING session statistics.
Returns:
statistics: Overall statistics
"""
persistence = get_persistence()
stats = persistence.get_statistics()
return jsonify({'statistics': stats})
@coaching_sessions_bp.route('/cleanup', methods=['POST'])
def cleanup_sessions():
"""
Clean up old COACHING sessions.
Body JSON:
max_age_days: int (default: 30)
Returns:
removed_count: int - Number of sessions removed
"""
data = request.get_json() or {}
max_age_days = data.get('max_age_days', 30)
persistence = get_persistence()
removed = persistence.cleanup_old_sessions(max_age_days)
return jsonify({
'message': f'Removed {removed} old sessions',
'removed_count': removed
})
# =============================================================================
# Metrics & Monitoring Endpoints
# =============================================================================
@coaching_sessions_bp.route('/metrics/global', methods=['GET'])
def get_global_metrics():
"""
Get global COACHING metrics across all workflows.
Returns:
metrics: GlobalCoachingMetrics with aggregated data
"""
try:
from core.coaching import get_metrics_collector
collector = get_metrics_collector()
metrics = collector.get_global_metrics()
return jsonify({'metrics': metrics.to_dict()})
except Exception as e:
return jsonify({'error': str(e)}), 500
@coaching_sessions_bp.route('/metrics/workflow/<workflow_id>', methods=['GET'])
def get_workflow_metrics(workflow_id: str):
"""
Get detailed learning metrics for a specific workflow.
Returns:
metrics: WorkflowLearningMetrics with learning progress and recommendations
"""
try:
from core.coaching import get_metrics_collector
collector = get_metrics_collector()
metrics = collector.get_workflow_metrics(workflow_id)
return jsonify({'metrics': metrics.to_dict()})
except Exception as e:
return jsonify({'error': str(e)}), 500
@coaching_sessions_bp.route('/metrics/ready-for-auto', methods=['GET'])
def get_workflows_ready_for_auto():
"""
Get list of workflows ready for autonomous mode.
Returns:
workflows: List of workflow IDs ready for AUTO mode
"""
try:
from core.coaching import get_metrics_collector
collector = get_metrics_collector()
# Get global metrics to find workflows
global_metrics = collector.get_global_metrics()
# Check each workflow
ready_workflows = []
persistence = get_persistence()
all_sessions = persistence.list_sessions(limit=10000)
workflow_ids = set(s.get('workflow_id') for s in all_sessions if s.get('workflow_id'))
for workflow_id in workflow_ids:
metrics = collector.get_workflow_metrics(workflow_id)
if metrics.ready_for_auto:
ready_workflows.append({
'workflow_id': workflow_id,
'confidence_score': metrics.confidence_score,
'acceptance_rate': metrics.acceptance_rate,
'total_sessions': metrics.total_sessions
})
return jsonify({
'workflows_ready': ready_workflows,
'total_ready': len(ready_workflows)
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@coaching_sessions_bp.route('/metrics/dashboard', methods=['GET'])
def get_metrics_dashboard():
"""
Get comprehensive dashboard data for monitoring.
Returns all metrics needed for a monitoring dashboard:
- Global statistics
- Recent activity
- Top workflows
- Recommendations
"""
try:
from core.coaching import get_metrics_collector
collector = get_metrics_collector()
# Get global metrics
global_metrics = collector.get_global_metrics()
# Get recent activity (last 7 days sessions)
persistence = get_persistence()
recent_sessions = persistence.list_sessions(limit=50)
# Build dashboard response
dashboard = {
'overview': {
'total_workflows': global_metrics.total_workflows,
'total_sessions': global_metrics.total_sessions,
'active_sessions': global_metrics.active_sessions,
'workflows_ready_for_auto': global_metrics.workflows_ready_for_auto,
'workflows_in_learning': global_metrics.workflows_in_learning,
},
'rates': {
'acceptance_rate': round(global_metrics.overall_acceptance_rate * 100, 1),
'correction_rate': round(global_metrics.overall_correction_rate * 100, 1),
},
'activity': {
'sessions_last_24h': global_metrics.sessions_last_24h,
'decisions_last_24h': global_metrics.decisions_last_24h,
},
'decisions': {
'total': global_metrics.total_decisions,
'accepted': global_metrics.total_accepted,
'rejected': global_metrics.total_rejected,
'corrected': global_metrics.total_corrected,
},
'top_workflows': {
'by_sessions': global_metrics.top_workflows_by_sessions,
'by_corrections': global_metrics.top_workflows_by_corrections,
},
'recent_sessions': [
{
'session_id': s.get('session_id'),
'workflow_id': s.get('workflow_id'),
'status': s.get('status'),
'updated_at': s.get('updated_at'),
}
for s in recent_sessions[:10]
]
}
return jsonify({'dashboard': dashboard})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -0,0 +1,267 @@
"""
Executions API Blueprint
Provides REST endpoints for workflow execution management.
Exigences: 6.1, 6.2, 6.3, 6.4
"""
from flask import Blueprint, jsonify, request
from services.execution_integration import get_executor
executions_bp = Blueprint('executions', __name__)
@executions_bp.route('/', methods=['POST'])
def start_execution():
"""
Lance l'exécution d'un workflow.
Body JSON:
workflow_id: str - ID du workflow à exécuter
variables: dict (optionnel) - Variables d'entrée
mode: str (optionnel) - 'normal' ou 'coaching' (défaut: 'normal')
Returns:
execution_id: str - ID de l'exécution lancée
"""
data = request.get_json() or {}
workflow_id = data.get('workflow_id')
variables = data.get('variables', {})
mode = data.get('mode', 'normal')
if not workflow_id:
return jsonify({'error': 'workflow_id requis'}), 400
try:
executor = get_executor()
if mode == 'coaching':
execution_id = executor.execute_workflow_coaching(
workflow_id=workflow_id,
variables=variables
)
else:
execution_id = executor.execute_workflow(
workflow_id=workflow_id,
variables=variables
)
return jsonify({
'execution_id': execution_id,
'workflow_id': workflow_id,
'mode': mode,
'status': 'started'
}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@executions_bp.route('/coaching', methods=['POST'])
def start_coaching_execution():
"""
Lance l'exécution d'un workflow en mode COACHING.
En mode COACHING, chaque étape est soumise à l'utilisateur pour
validation/correction avant exécution.
Body JSON:
workflow_id: str - ID du workflow à exécuter
variables: dict (optionnel) - Variables d'entrée
Returns:
execution_id: str - ID de l'exécution COACHING lancée
"""
data = request.get_json() or {}
workflow_id = data.get('workflow_id')
variables = data.get('variables', {})
if not workflow_id:
return jsonify({'error': 'workflow_id requis'}), 400
try:
executor = get_executor()
execution_id = executor.execute_workflow_coaching(
workflow_id=workflow_id,
variables=variables
)
return jsonify({
'execution_id': execution_id,
'workflow_id': workflow_id,
'mode': 'coaching',
'status': 'started',
'message': 'Connectez-vous via WebSocket pour recevoir les suggestions'
}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@executions_bp.route('/', methods=['GET'])
def list_executions():
"""
Liste les exécutions.
Query params:
workflow_id: str (optionnel) - Filtrer par workflow
mode: str (optionnel) - Filtrer par mode ('normal', 'coaching')
Returns:
executions: list - Liste des exécutions
"""
workflow_id = request.args.get('workflow_id')
mode = request.args.get('mode')
executor = get_executor()
executions = executor.list_executions(workflow_id)
# Filtrer par mode si demandé
if mode:
if mode == 'coaching':
executions = [
e for e in executions
if executor.is_coaching_execution(e['execution_id'])
]
elif mode == 'normal':
executions = [
e for e in executions
if not executor.is_coaching_execution(e['execution_id'])
]
return jsonify({'executions': executions})
@executions_bp.route('/<execution_id>', methods=['GET'])
def get_execution(execution_id):
"""
Récupère le statut et les détails d'une exécution.
Returns:
Détails de l'exécution incluant statut, progression, logs
"""
executor = get_executor()
result = executor.get_execution_status(execution_id)
if result is None:
return jsonify({'error': f'Exécution {execution_id} introuvable'}), 404
response = result.to_dict()
response['is_coaching'] = executor.is_coaching_execution(execution_id)
return jsonify(response)
@executions_bp.route('/<execution_id>/cancel', methods=['POST'])
def cancel_execution(execution_id):
"""
Annule une exécution en cours.
Returns:
success: bool - Si l'annulation a réussi
"""
executor = get_executor()
if executor.cancel_execution(execution_id):
return jsonify({
'execution_id': execution_id,
'status': 'cancelled',
'message': 'Exécution annulée avec succès'
})
else:
return jsonify({
'error': 'Impossible d\'annuler l\'exécution (déjà terminée ou inexistante)'
}), 400
@executions_bp.route('/<execution_id>/coaching/decision', methods=['POST'])
def submit_coaching_decision(execution_id):
"""
Soumet une décision COACHING pour une exécution.
Alternative REST à WebSocket pour soumettre une décision.
Body JSON:
decision: str - 'accept' | 'reject' | 'correct' | 'manual' | 'skip'
correction: dict (optionnel) - Correction si decision == 'correct'
feedback: str (optionnel) - Commentaire utilisateur
Returns:
success: bool
"""
data = request.get_json() or {}
decision = data.get('decision')
if not decision:
return jsonify({'error': 'decision requis'}), 400
valid_decisions = ['accept', 'reject', 'correct', 'manual', 'skip']
if decision not in valid_decisions:
return jsonify({
'error': f'decision invalide. Valeurs acceptées: {valid_decisions}'
}), 400
executor = get_executor()
if not executor.is_coaching_execution(execution_id):
return jsonify({
'error': f'{execution_id} n\'est pas une exécution COACHING'
}), 400
decision_response = {
'decision': decision,
'correction': data.get('correction'),
'feedback': data.get('feedback'),
'executed_manually': decision == 'manual'
}
success = executor.submit_coaching_decision(execution_id, decision_response)
if success:
return jsonify({
'execution_id': execution_id,
'decision': decision,
'status': 'accepted'
})
else:
return jsonify({
'error': 'Impossible de soumettre la décision'
}), 400
@executions_bp.route('/<execution_id>/coaching/stats', methods=['GET'])
def get_coaching_stats(execution_id):
"""
Récupère les statistiques COACHING d'une exécution.
Returns:
stats: dict - Statistiques (suggestions, accepted, rejected, etc.)
"""
executor = get_executor()
if not executor.is_coaching_execution(execution_id):
return jsonify({
'error': f'{execution_id} n\'est pas une exécution COACHING'
}), 400
stats = executor.get_coaching_stats(execution_id)
if stats is None:
stats = {
'suggestions_made': 0,
'accepted': 0,
'rejected': 0,
'corrected': 0,
'manual_executions': 0
}
return jsonify({
'execution_id': execution_id,
'stats': stats
})

View File

@@ -119,6 +119,13 @@ try:
except ImportError as e: except ImportError as e:
print(f"⚠️ Blueprint correction_packs désactivé: {e}") print(f"⚠️ Blueprint correction_packs désactivé: {e}")
try:
from api.coaching_sessions import coaching_sessions_bp
app.register_blueprint(coaching_sessions_bp, url_prefix='/api/coaching-sessions')
print("✅ Blueprint coaching_sessions enregistré")
except ImportError as e:
print(f"⚠️ Blueprint coaching_sessions désactivé: {e}")
# Import WebSocket handlers (optional) # Import WebSocket handlers (optional)
try: try:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
/**
* CoachingDecisionButtons Component
*
* Decision buttons for COACHING mode.
* Provides accept, reject, correct, manual, and skip options.
*/
import React from 'react';
import { CoachingDecision } from '../../hooks/useCoachingWebSocket';
interface CoachingDecisionButtonsProps {
onDecision: (decision: CoachingDecision) => void;
onShowCorrection: () => void;
disabled?: boolean;
}
interface DecisionButtonConfig {
decision: CoachingDecision | 'correction';
label: string;
shortcut: string;
icon: string;
className: string;
title: string;
}
const DECISION_BUTTONS: DecisionButtonConfig[] = [
{
decision: 'accept',
label: 'Accepter',
shortcut: 'A',
icon: '\u2713',
className: 'btn-accept',
title: 'Accepter et executer cette action (A)',
},
{
decision: 'reject',
label: 'Rejeter',
shortcut: 'R',
icon: '\u2717',
className: 'btn-reject',
title: 'Rejeter cette action et passer (R)',
},
{
decision: 'correction',
label: 'Corriger',
shortcut: 'C',
icon: '\u270E',
className: 'btn-correct',
title: 'Modifier cette action avant execution (C)',
},
{
decision: 'manual',
label: 'Manuel',
shortcut: 'M',
icon: '\u{1F590}',
className: 'btn-manual',
title: 'Executer manuellement puis continuer (M)',
},
{
decision: 'skip',
label: 'Passer',
shortcut: 'S',
icon: '\u23E9',
className: 'btn-skip',
title: 'Passer cette etape (S)',
},
];
const CoachingDecisionButtons: React.FC<CoachingDecisionButtonsProps> = ({
onDecision,
onShowCorrection,
disabled = false,
}) => {
const handleClick = (button: DecisionButtonConfig) => {
if (button.decision === 'correction') {
onShowCorrection();
} else {
onDecision(button.decision as CoachingDecision);
}
};
return (
<div className="coaching-decision-buttons">
{DECISION_BUTTONS.map((button) => (
<button
key={button.decision}
className={`coaching-decision-btn ${button.className}`}
onClick={() => handleClick(button)}
disabled={disabled}
title={button.title}
aria-label={button.title}
>
<span className="btn-icon">{button.icon}</span>
<span className="btn-label">{button.label}</span>
<kbd className="btn-shortcut">{button.shortcut}</kbd>
</button>
))}
</div>
);
};
export default CoachingDecisionButtons;

View File

@@ -0,0 +1,695 @@
/**
* CoachingPanel Styles
*
* Styles for the COACHING mode UI components.
*/
/* Main Panel */
.coaching-panel {
display: flex;
flex-direction: column;
background: #1e1e2e;
border-radius: 8px;
border: 1px solid #313244;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.coaching-panel.active {
border-color: #89b4fa;
box-shadow: 0 0 10px rgba(137, 180, 250, 0.2);
}
/* Header */
.coaching-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #313244;
border-bottom: 1px solid #45475a;
}
.coaching-panel-header h3 {
margin: 0;
font-size: 16px;
color: #cdd6f4;
display: flex;
align-items: center;
gap: 8px;
}
.coaching-icon {
font-size: 20px;
}
.coaching-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #a6adc8;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f38ba8;
}
.status-indicator.connected {
background: #a6e3a1;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Error display */
.coaching-error {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(243, 139, 168, 0.1);
color: #f38ba8;
font-size: 13px;
}
.error-icon {
font-size: 16px;
}
/* Content area */
.coaching-panel-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
/* Start prompt */
.coaching-start-prompt {
text-align: center;
padding: 24px;
}
.coaching-start-prompt p {
color: #a6adc8;
margin-bottom: 16px;
}
.btn-start-coaching {
padding: 12px 24px;
background: #89b4fa;
color: #1e1e2e;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-start-coaching:hover {
background: #b4befe;
}
.btn-start-coaching:disabled {
background: #45475a;
color: #6c7086;
cursor: not-allowed;
}
/* Waiting state */
.coaching-waiting {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px;
color: #a6adc8;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #45475a;
border-top-color: #89b4fa;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Suggestion Card */
.coaching-suggestion-card {
background: #313244;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.suggestion-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.action-icon {
font-size: 24px;
}
.action-name {
font-size: 18px;
font-weight: 600;
color: #cdd6f4;
}
.confidence-badge {
margin-left: auto;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
color: white;
}
.suggestion-target,
.suggestion-params {
margin-bottom: 12px;
}
.suggestion-target label,
.suggestion-params label {
display: block;
font-size: 11px;
color: #6c7086;
text-transform: uppercase;
margin-bottom: 4px;
}
.target-value {
color: #89b4fa;
font-family: monospace;
font-size: 13px;
}
.suggestion-params ul {
margin: 0;
padding-left: 16px;
color: #cdd6f4;
font-size: 13px;
}
.suggestion-params li {
margin-bottom: 4px;
}
.suggestion-screenshot {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.suggestion-screenshot img {
max-width: 100%;
height: auto;
}
.suggestion-alternatives {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #45475a;
}
.suggestion-alternatives label {
display: block;
font-size: 11px;
color: #6c7086;
margin-bottom: 8px;
}
.suggestion-alternatives ul {
margin: 0;
padding-left: 16px;
font-size: 12px;
color: #a6adc8;
}
.suggestion-context {
margin-top: 12px;
font-size: 12px;
}
.suggestion-context summary {
cursor: pointer;
color: #6c7086;
}
.suggestion-context pre {
background: #1e1e2e;
padding: 8px;
border-radius: 4px;
overflow-x: auto;
color: #a6adc8;
font-size: 11px;
}
/* Decision Buttons */
.coaching-decision-buttons {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.coaching-decision-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1px solid #45475a;
border-radius: 8px;
background: #313244;
color: #cdd6f4;
cursor: pointer;
transition: all 0.2s;
}
.coaching-decision-btn:hover {
transform: translateY(-2px);
}
.coaching-decision-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.coaching-decision-btn .btn-icon {
font-size: 20px;
}
.coaching-decision-btn .btn-label {
font-size: 11px;
font-weight: 500;
}
.coaching-decision-btn .btn-shortcut {
font-size: 10px;
padding: 2px 4px;
background: #45475a;
border-radius: 3px;
font-family: monospace;
}
.btn-accept:hover {
background: rgba(166, 227, 161, 0.2);
border-color: #a6e3a1;
}
.btn-reject:hover {
background: rgba(243, 139, 168, 0.2);
border-color: #f38ba8;
}
.btn-correct:hover {
background: rgba(249, 226, 175, 0.2);
border-color: #f9e2af;
}
.btn-manual:hover {
background: rgba(137, 180, 250, 0.2);
border-color: #89b4fa;
}
.btn-skip:hover {
background: rgba(166, 173, 200, 0.2);
border-color: #a6adc8;
}
/* Feedback input */
.coaching-feedback {
margin-bottom: 16px;
}
.coaching-feedback label {
display: block;
font-size: 12px;
color: #a6adc8;
margin-bottom: 4px;
}
.coaching-feedback input {
width: 100%;
padding: 8px 12px;
background: #313244;
border: 1px solid #45475a;
border-radius: 6px;
color: #cdd6f4;
font-size: 13px;
}
.coaching-feedback input:focus {
outline: none;
border-color: #89b4fa;
}
/* Result display */
.coaching-result {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 13px;
}
.coaching-result.success {
background: rgba(166, 227, 161, 0.1);
color: #a6e3a1;
}
.coaching-result.error {
background: rgba(243, 139, 168, 0.1);
color: #f38ba8;
}
.result-icon {
font-size: 18px;
font-weight: bold;
}
/* Stats Display */
.coaching-stats {
padding: 16px;
background: #313244;
border-top: 1px solid #45475a;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.stats-header h4 {
margin: 0;
font-size: 13px;
color: #cdd6f4;
}
.btn-refresh-stats {
padding: 4px 8px;
background: transparent;
border: 1px solid #45475a;
border-radius: 4px;
color: #a6adc8;
cursor: pointer;
font-size: 14px;
}
.btn-refresh-stats:hover {
background: #45475a;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.stat-item {
text-align: center;
padding: 8px;
background: #1e1e2e;
border-radius: 6px;
}
.stat-value {
display: block;
font-size: 20px;
font-weight: 600;
color: #cdd6f4;
}
.stat-label {
display: block;
font-size: 10px;
color: #6c7086;
text-transform: uppercase;
}
.stat-item.accepted .stat-value { color: #a6e3a1; }
.stat-item.rejected .stat-value { color: #f38ba8; }
.stat-item.corrected .stat-value { color: #f9e2af; }
.stat-item.manual .stat-value { color: #89b4fa; }
.stats-rates {
display: flex;
flex-direction: column;
gap: 8px;
}
.rate-item {
display: flex;
align-items: center;
gap: 12px;
}
.rate-label {
font-size: 11px;
color: #a6adc8;
width: 120px;
}
.rate-bar-container {
flex: 1;
height: 8px;
background: #1e1e2e;
border-radius: 4px;
overflow: hidden;
}
.rate-bar {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.rate-value {
font-size: 12px;
font-weight: 600;
width: 50px;
text-align: right;
}
.learning-progress,
.learning-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 6px;
margin-top: 12px;
font-size: 12px;
}
.learning-progress {
background: rgba(166, 227, 161, 0.1);
color: #a6e3a1;
}
.learning-warning {
background: rgba(249, 226, 175, 0.1);
color: #f9e2af;
}
/* Correction Editor */
.correction-editor {
background: #313244;
border-radius: 8px;
padding: 16px;
}
.correction-editor h4 {
margin: 0 0 16px 0;
font-size: 14px;
color: #cdd6f4;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 12px;
color: #a6adc8;
margin-bottom: 6px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px 12px;
background: #1e1e2e;
border: 1px solid #45475a;
border-radius: 6px;
color: #cdd6f4;
font-size: 13px;
}
.form-group textarea {
resize: vertical;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #89b4fa;
}
.params-editor {
background: #1e1e2e;
border-radius: 6px;
padding: 12px;
}
.param-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.param-key {
font-size: 12px;
color: #89b4fa;
font-family: monospace;
min-width: 80px;
}
.param-row input {
flex: 1;
padding: 4px 8px;
font-size: 12px;
}
.btn-add-param {
padding: 6px 12px;
background: transparent;
border: 1px dashed #45475a;
border-radius: 4px;
color: #6c7086;
cursor: pointer;
font-size: 12px;
width: 100%;
margin-top: 8px;
}
.btn-add-param:hover {
border-color: #89b4fa;
color: #89b4fa;
}
.correction-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn-cancel,
.btn-apply-correction {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.btn-cancel {
background: #45475a;
color: #cdd6f4;
}
.btn-cancel:hover {
background: #585b70;
}
.btn-apply-correction {
background: #89b4fa;
color: #1e1e2e;
}
.btn-apply-correction:hover {
background: #b4befe;
}
/* Footer */
.coaching-panel-footer {
padding: 12px 16px;
border-top: 1px solid #45475a;
text-align: center;
}
.btn-end-session {
padding: 8px 16px;
background: transparent;
border: 1px solid #f38ba8;
border-radius: 6px;
color: #f38ba8;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.btn-end-session:hover {
background: rgba(243, 139, 168, 0.1);
}
/* Shortcuts help */
.coaching-shortcuts-help {
padding: 8px 16px;
background: #1e1e2e;
text-align: center;
border-top: 1px solid #313244;
}
.coaching-shortcuts-help small {
color: #6c7086;
font-size: 11px;
}
.coaching-shortcuts-help kbd {
padding: 2px 6px;
background: #313244;
border-radius: 3px;
font-family: monospace;
margin: 0 2px;
}
/* Responsive */
@media (max-width: 768px) {
.coaching-decision-buttons {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}

View File

@@ -0,0 +1,153 @@
/**
* CoachingStatsDisplay Component
*
* Displays COACHING session statistics including:
* - Total suggestions made
* - Acceptance/rejection/correction counts
* - Acceptance and correction rates
*/
import React from 'react';
import { CoachingStats } from '../../hooks/useCoachingWebSocket';
interface CoachingStatsDisplayProps {
stats: CoachingStats;
onRefresh?: () => void;
}
const CoachingStatsDisplay: React.FC<CoachingStatsDisplayProps> = ({
stats,
onRefresh,
}) => {
// Calculate rates
const total = stats.accepted + stats.rejected + stats.corrected + stats.manualExecutions;
const acceptanceRate = total > 0 ? (stats.accepted / total) * 100 : 0;
const correctionRate = total > 0 ? (stats.corrected / total) * 100 : 0;
// Get color for rate
const getRateColor = (rate: number, isAcceptance: boolean): string => {
if (isAcceptance) {
if (rate >= 80) return '#4caf50';
if (rate >= 50) return '#ff9800';
return '#f44336';
}
// For correction rate, lower is better
if (rate <= 10) return '#4caf50';
if (rate <= 30) return '#ff9800';
return '#f44336';
};
return (
<div className="coaching-stats">
<div className="stats-header">
<h4>Statistiques COACHING</h4>
{onRefresh && (
<button
className="btn-refresh-stats"
onClick={onRefresh}
title="Actualiser les statistiques"
aria-label="Actualiser"
>
&#x21BB;
</button>
)}
</div>
<div className="stats-grid">
{/* Suggestions count */}
<div className="stat-item">
<span className="stat-value">{stats.suggestionsMade}</span>
<span className="stat-label">Suggestions</span>
</div>
{/* Accepted count */}
<div className="stat-item accepted">
<span className="stat-value">{stats.accepted}</span>
<span className="stat-label">Acceptees</span>
</div>
{/* Rejected count */}
<div className="stat-item rejected">
<span className="stat-value">{stats.rejected}</span>
<span className="stat-label">Rejetees</span>
</div>
{/* Corrected count */}
<div className="stat-item corrected">
<span className="stat-value">{stats.corrected}</span>
<span className="stat-label">Corrigees</span>
</div>
{/* Manual executions */}
<div className="stat-item manual">
<span className="stat-value">{stats.manualExecutions}</span>
<span className="stat-label">Manuelles</span>
</div>
</div>
{/* Rate indicators */}
<div className="stats-rates">
{/* Acceptance rate */}
<div className="rate-item">
<div className="rate-label">Taux d'acceptation</div>
<div className="rate-bar-container">
<div
className="rate-bar"
style={{
width: `${acceptanceRate}%`,
backgroundColor: getRateColor(acceptanceRate, true),
}}
/>
</div>
<div
className="rate-value"
style={{ color: getRateColor(acceptanceRate, true) }}
>
{acceptanceRate.toFixed(1)}%
</div>
</div>
{/* Correction rate */}
<div className="rate-item">
<div className="rate-label">Taux de correction</div>
<div className="rate-bar-container">
<div
className="rate-bar"
style={{
width: `${correctionRate}%`,
backgroundColor: getRateColor(correctionRate, false),
}}
/>
</div>
<div
className="rate-value"
style={{ color: getRateColor(correctionRate, false) }}
>
{correctionRate.toFixed(1)}%
</div>
</div>
</div>
{/* Learning progress indicator */}
{total >= 10 && acceptanceRate >= 80 && (
<div className="learning-progress">
<span className="progress-icon">&#x1F4C8;</span>
<span className="progress-text">
Excellent ! Le workflow peut passer en mode AUTO.
</span>
</div>
)}
{total >= 10 && acceptanceRate < 50 && (
<div className="learning-warning">
<span className="warning-icon">&#x26A0;</span>
<span className="warning-text">
Taux d'acceptation faible. Le workflow necessite plus de corrections.
</span>
</div>
)}
</div>
);
};
export default CoachingStatsDisplay;

View File

@@ -0,0 +1,150 @@
/**
* CoachingSuggestionCard Component
*
* Displays the current action suggestion in COACHING mode.
* Shows action type, target, parameters, and confidence level.
*/
import React from 'react';
import { CoachingSuggestion } from '../../hooks/useCoachingWebSocket';
interface CoachingSuggestionCardProps {
suggestion: CoachingSuggestion;
onViewDetails?: () => void;
}
const CoachingSuggestionCard: React.FC<CoachingSuggestionCardProps> = ({
suggestion,
onViewDetails,
}) => {
// Get confidence color based on level
const getConfidenceColor = (confidence: number): string => {
if (confidence >= 0.8) return '#4caf50'; // Green
if (confidence >= 0.5) return '#ff9800'; // Orange
return '#f44336'; // Red
};
// Get action icon
const getActionIcon = (action: string): string => {
const icons: Record<string, string> = {
click: '\u{1F5B1}',
double_click: '\u{1F5B1}',
right_click: '\u{1F5B1}',
type: '\u2328',
fill: '\u2328',
scroll: '\u2195',
hover: '\u{1F4CD}',
wait: '\u23F1',
screenshot: '\u{1F4F8}',
navigate: '\u{1F517}',
default: '\u2699',
};
return icons[action.toLowerCase()] || icons.default;
};
// Format target for display
const formatTarget = (target: Record<string, any>): string => {
if (target.text) return `Text: "${target.text}"`;
if (target.id) return `ID: ${target.id}`;
if (target.xpath) return `XPath: ${target.xpath.substring(0, 50)}...`;
if (target.css) return `CSS: ${target.css}`;
if (target.image) return 'Image match';
if (target.x !== undefined && target.y !== undefined) {
return `Coordinates: (${target.x}, ${target.y})`;
}
return JSON.stringify(target).substring(0, 50);
};
// Format params for display
const formatParams = (params: Record<string, any>): string[] => {
return Object.entries(params)
.filter(([key]) => !['target', 'action'].includes(key))
.map(([key, value]) => {
if (typeof value === 'string' && value.length > 30) {
return `${key}: "${value.substring(0, 30)}..."`;
}
return `${key}: ${JSON.stringify(value)}`;
});
};
const confidencePercent = Math.round(suggestion.confidence * 100);
return (
<div className="coaching-suggestion-card">
{/* Action header */}
<div className="suggestion-header">
<span className="action-icon">{getActionIcon(suggestion.action)}</span>
<span className="action-name">{suggestion.action.toUpperCase()}</span>
<div
className="confidence-badge"
style={{ backgroundColor: getConfidenceColor(suggestion.confidence) }}
title={`Confiance: ${confidencePercent}%`}
>
{confidencePercent}%
</div>
</div>
{/* Target */}
<div className="suggestion-target">
<label>Cible:</label>
<span className="target-value">{formatTarget(suggestion.target)}</span>
</div>
{/* Parameters */}
{Object.keys(suggestion.params).length > 0 && (
<div className="suggestion-params">
<label>Parametres:</label>
<ul>
{formatParams(suggestion.params).map((param, index) => (
<li key={index}>{param}</li>
))}
</ul>
</div>
)}
{/* Screenshot preview */}
{suggestion.screenshotPath && (
<div className="suggestion-screenshot">
<img
src={`http://localhost:5000${suggestion.screenshotPath}`}
alt="Target element"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
{/* Alternatives */}
{suggestion.alternatives && suggestion.alternatives.length > 0 && (
<div className="suggestion-alternatives">
<label>Alternatives ({suggestion.alternatives.length}):</label>
<ul>
{suggestion.alternatives.slice(0, 3).map((alt, index) => (
<li key={index}>
{alt.action}: {formatTarget(alt.target)} ({Math.round(alt.confidence * 100)}%)
</li>
))}
</ul>
</div>
)}
{/* Context info */}
{suggestion.context && Object.keys(suggestion.context).length > 0 && (
<details className="suggestion-context">
<summary>Contexte</summary>
<pre>{JSON.stringify(suggestion.context, null, 2)}</pre>
</details>
)}
{/* View details button */}
{onViewDetails && (
<button className="btn-view-details" onClick={onViewDetails}>
Voir details
</button>
)}
</div>
);
};
export default CoachingSuggestionCard;

View File

@@ -0,0 +1,235 @@
/**
* CorrectionEditor Component
*
* Allows users to modify a suggested action before execution.
* Supports editing:
* - Target (element selector)
* - Parameters (timeout, value, etc.)
* - Action type (in some cases)
*/
import React, { useState, useEffect } from 'react';
import { CoachingSuggestion } from '../../hooks/useCoachingWebSocket';
interface CorrectionEditorProps {
suggestion: CoachingSuggestion;
onSubmit: (correction: Record<string, any>) => void;
onCancel: () => void;
}
const CorrectionEditor: React.FC<CorrectionEditorProps> = ({
suggestion,
onSubmit,
onCancel,
}) => {
// State for editable fields
const [targetType, setTargetType] = useState<string>('text');
const [targetValue, setTargetValue] = useState<string>('');
const [params, setParams] = useState<Record<string, any>>({});
const [feedback, setFeedback] = useState('');
// Initialize from suggestion
useEffect(() => {
// Detect target type
if (suggestion.target.text) {
setTargetType('text');
setTargetValue(suggestion.target.text);
} else if (suggestion.target.id) {
setTargetType('id');
setTargetValue(suggestion.target.id);
} else if (suggestion.target.xpath) {
setTargetType('xpath');
setTargetValue(suggestion.target.xpath);
} else if (suggestion.target.css) {
setTargetType('css');
setTargetValue(suggestion.target.css);
} else if (suggestion.target.x !== undefined && suggestion.target.y !== undefined) {
setTargetType('coordinates');
setTargetValue(`${suggestion.target.x},${suggestion.target.y}`);
}
// Copy params
setParams({ ...suggestion.params });
}, [suggestion]);
// Build correction object
const buildCorrection = (): Record<string, any> => {
const correction: Record<string, any> = {};
// Build corrected target
const correctedTarget: Record<string, any> = {};
switch (targetType) {
case 'text':
correctedTarget.text = targetValue;
break;
case 'id':
correctedTarget.id = targetValue;
break;
case 'xpath':
correctedTarget.xpath = targetValue;
break;
case 'css':
correctedTarget.css = targetValue;
break;
case 'coordinates':
const [x, y] = targetValue.split(',').map((v) => parseInt(v.trim(), 10));
correctedTarget.x = x;
correctedTarget.y = y;
break;
}
// Only include target if changed
if (JSON.stringify(correctedTarget) !== JSON.stringify(suggestion.target)) {
correction.target = correctedTarget;
}
// Only include params if changed
if (JSON.stringify(params) !== JSON.stringify(suggestion.params)) {
correction.params = params;
}
// Include feedback
if (feedback.trim()) {
correction.feedback = feedback;
}
return correction;
};
// Handle param change
const handleParamChange = (key: string, value: any) => {
setParams((prev) => ({ ...prev, [key]: value }));
};
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const correction = buildCorrection();
onSubmit(correction);
};
return (
<div className="correction-editor">
<h4>Corriger l'action</h4>
<form onSubmit={handleSubmit}>
{/* Target type selector */}
<div className="form-group">
<label htmlFor="target-type">Type de cible:</label>
<select
id="target-type"
value={targetType}
onChange={(e) => setTargetType(e.target.value)}
>
<option value="text">Texte visible</option>
<option value="id">ID element</option>
<option value="xpath">XPath</option>
<option value="css">Selecteur CSS</option>
<option value="coordinates">Coordonnees (x,y)</option>
</select>
</div>
{/* Target value */}
<div className="form-group">
<label htmlFor="target-value">
{targetType === 'coordinates' ? 'Coordonnees (x,y):' : 'Valeur cible:'}
</label>
{targetType === 'xpath' ? (
<textarea
id="target-value"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
rows={3}
placeholder="//button[@id='submit']"
/>
) : (
<input
id="target-value"
type="text"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
placeholder={
targetType === 'coordinates'
? '100, 200'
: targetType === 'text'
? 'Texte du bouton'
: targetType === 'id'
? 'element-id'
: '.css-selector'
}
/>
)}
</div>
{/* Parameters */}
<div className="form-group">
<label>Parametres:</label>
<div className="params-editor">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="param-row">
<span className="param-key">{key}:</span>
{typeof value === 'boolean' ? (
<input
type="checkbox"
checked={value}
onChange={(e) => handleParamChange(key, e.target.checked)}
/>
) : typeof value === 'number' ? (
<input
type="number"
value={value}
onChange={(e) => handleParamChange(key, parseFloat(e.target.value))}
/>
) : (
<input
type="text"
value={String(value)}
onChange={(e) => handleParamChange(key, e.target.value)}
/>
)}
</div>
))}
{/* Add new param */}
<button
type="button"
className="btn-add-param"
onClick={() => {
const newKey = prompt('Nom du parametre:');
if (newKey && !params[newKey]) {
handleParamChange(newKey, '');
}
}}
>
+ Ajouter parametre
</button>
</div>
</div>
{/* Feedback */}
<div className="form-group">
<label htmlFor="correction-feedback">Raison de la correction:</label>
<textarea
id="correction-feedback"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
placeholder="Expliquez pourquoi cette correction est necessaire..."
/>
</div>
{/* Action buttons */}
<div className="correction-actions">
<button type="button" className="btn-cancel" onClick={onCancel}>
Annuler
</button>
<button type="submit" className="btn-apply-correction">
Appliquer la correction
</button>
</div>
</form>
</div>
);
};
export default CorrectionEditor;

View File

@@ -0,0 +1,297 @@
/**
* CoachingPanel Component
*
* Main UI component for COACHING mode execution.
* Displays action suggestions and allows user decisions (accept, reject, correct, manual, skip).
*
* Features:
* - Real-time suggestion display via WebSocket
* - Decision buttons with keyboard shortcuts
* - Correction editor for adjusting suggested actions
* - Statistics dashboard showing acceptance/rejection rates
* - Screenshot preview of the target element
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
useCoachingWebSocket,
CoachingDecision,
CoachingSuggestion,
CoachingStats,
} from '../../hooks/useCoachingWebSocket';
import CoachingSuggestionCard from './CoachingSuggestionCard';
import CoachingDecisionButtons from './CoachingDecisionButtons';
import CoachingStatsDisplay from './CoachingStatsDisplay';
import CorrectionEditor from './CorrectionEditor';
import './CoachingPanel.css';
interface CoachingPanelProps {
executionId?: string;
workflowId?: string;
onSessionStart?: (executionId: string) => void;
onSessionEnd?: (stats: CoachingStats) => void;
onDecisionMade?: (decision: CoachingDecision, suggestion: CoachingSuggestion) => void;
serverUrl?: string;
className?: string;
}
export const CoachingPanel: React.FC<CoachingPanelProps> = ({
executionId: initialExecutionId,
workflowId,
onSessionStart,
onSessionEnd,
onDecisionMade,
serverUrl,
className = '',
}) => {
const [isActive, setIsActive] = useState(false);
const [showCorrectionEditor, setShowCorrectionEditor] = useState(false);
const [pendingCorrection, setPendingCorrection] = useState<Record<string, any> | null>(null);
const [feedback, setFeedback] = useState('');
const {
isConnected,
isSubscribed,
currentSuggestion,
stats,
lastActionResult,
error,
subscribe,
unsubscribe,
submitDecision,
refreshStats,
} = useCoachingWebSocket({ serverUrl });
// Subscribe when executionId is provided
useEffect(() => {
if (initialExecutionId && isConnected && !isSubscribed) {
subscribe(initialExecutionId);
setIsActive(true);
onSessionStart?.(initialExecutionId);
}
}, [initialExecutionId, isConnected, isSubscribed, subscribe, onSessionStart]);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!currentSuggestion || showCorrectionEditor) return;
// Don't capture if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
switch (e.key.toLowerCase()) {
case 'a':
handleDecision('accept');
break;
case 'r':
handleDecision('reject');
break;
case 'c':
setShowCorrectionEditor(true);
break;
case 'm':
handleDecision('manual');
break;
case 's':
handleDecision('skip');
break;
case 'escape':
if (showCorrectionEditor) {
setShowCorrectionEditor(false);
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentSuggestion, showCorrectionEditor]);
// Handle decision submission
const handleDecision = useCallback(
(decision: CoachingDecision) => {
if (!currentSuggestion) return;
submitDecision(
decision,
decision === 'correct' ? pendingCorrection || undefined : undefined,
feedback || undefined
);
onDecisionMade?.(decision, currentSuggestion);
// Reset state
setShowCorrectionEditor(false);
setPendingCorrection(null);
setFeedback('');
},
[currentSuggestion, submitDecision, pendingCorrection, feedback, onDecisionMade]
);
// Handle correction submission
const handleCorrectionSubmit = useCallback(
(correction: Record<string, any>) => {
setPendingCorrection(correction);
handleDecision('correct');
},
[handleDecision]
);
// Start a new COACHING session
const startSession = useCallback(
async (wfId: string) => {
try {
const response = await fetch(`${serverUrl || 'http://localhost:5000'}/api/executions/coaching`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: wfId }),
});
if (!response.ok) {
throw new Error('Failed to start COACHING session');
}
const data = await response.json();
subscribe(data.execution_id);
setIsActive(true);
onSessionStart?.(data.execution_id);
} catch (err) {
console.error('Error starting COACHING session:', err);
}
},
[serverUrl, subscribe, onSessionStart]
);
// End the current session
const endSession = useCallback(() => {
unsubscribe();
setIsActive(false);
onSessionEnd?.(stats);
}, [unsubscribe, onSessionEnd, stats]);
return (
<div className={`coaching-panel ${className} ${isActive ? 'active' : ''}`}>
{/* Header */}
<div className="coaching-panel-header">
<h3>
<span className="coaching-icon">&#x1F393;</span>
Mode COACHING
</h3>
<div className="coaching-status">
<span className={`status-indicator ${isConnected ? 'connected' : 'disconnected'}`} />
{isConnected ? (isSubscribed ? 'En session' : 'Connecte') : 'Deconnecte'}
</div>
</div>
{/* Error display */}
{error && (
<div className="coaching-error">
<span className="error-icon">&#x26A0;</span>
{error}
</div>
)}
{/* Main content */}
<div className="coaching-panel-content">
{/* Not started state */}
{!isActive && workflowId && (
<div className="coaching-start-prompt">
<p>Demarrer une session COACHING pour valider chaque etape avant execution.</p>
<button
className="btn-start-coaching"
onClick={() => startSession(workflowId)}
disabled={!isConnected}
>
Demarrer COACHING
</button>
</div>
)}
{/* Waiting for suggestion */}
{isActive && isSubscribed && !currentSuggestion && (
<div className="coaching-waiting">
<div className="spinner" />
<p>En attente de la prochaine action...</p>
</div>
)}
{/* Current suggestion */}
{currentSuggestion && !showCorrectionEditor && (
<>
<CoachingSuggestionCard
suggestion={currentSuggestion}
onViewDetails={() => {}}
/>
<CoachingDecisionButtons
onDecision={handleDecision}
onShowCorrection={() => setShowCorrectionEditor(true)}
disabled={false}
/>
{/* Feedback input */}
<div className="coaching-feedback">
<label htmlFor="coaching-feedback-input">Commentaire (optionnel):</label>
<input
id="coaching-feedback-input"
type="text"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Raison de la decision..."
/>
</div>
</>
)}
{/* Correction editor */}
{showCorrectionEditor && currentSuggestion && (
<CorrectionEditor
suggestion={currentSuggestion}
onSubmit={handleCorrectionSubmit}
onCancel={() => setShowCorrectionEditor(false)}
/>
)}
{/* Last action result */}
{lastActionResult && (
<div className={`coaching-result ${lastActionResult.success ? 'success' : 'error'}`}>
<span className="result-icon">
{lastActionResult.success ? '\u2713' : '\u2717'}
</span>
<span className="result-text">
{lastActionResult.success
? `Action "${lastActionResult.action}" executee avec succes`
: `Echec: ${lastActionResult.error}`}
</span>
</div>
)}
</div>
{/* Statistics */}
{isSubscribed && (
<CoachingStatsDisplay stats={stats} onRefresh={refreshStats} />
)}
{/* Session controls */}
{isActive && (
<div className="coaching-panel-footer">
<button className="btn-end-session" onClick={endSession}>
Terminer la session
</button>
</div>
)}
{/* Keyboard shortcuts help */}
<div className="coaching-shortcuts-help">
<small>
Raccourcis: <kbd>A</kbd> Accepter | <kbd>R</kbd> Rejeter |{' '}
<kbd>C</kbd> Corriger | <kbd>M</kbd> Manuel | <kbd>S</kbd> Passer
</small>
</div>
</div>
);
};
export default CoachingPanel;

View File

@@ -0,0 +1,838 @@
/**
* CorrectionPacksDashboard Styles
*/
/* Dashboard Container */
.correction-packs-dashboard {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e2e;
color: #cdd6f4;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Header */
.dashboard-header {
padding: 20px 24px;
border-bottom: 1px solid #313244;
}
.dashboard-header h2 {
margin: 0 0 4px 0;
font-size: 24px;
color: #cdd6f4;
}
.dashboard-header .subtitle {
margin: 0 0 16px 0;
font-size: 14px;
color: #6c7086;
}
.header-actions {
display: flex;
gap: 12px;
}
.header-actions button,
.header-actions label {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.btn-create {
background: #89b4fa;
color: #1e1e2e;
border: none;
font-weight: 600;
}
.btn-create:hover {
background: #b4befe;
}
.btn-import {
background: #313244;
color: #cdd6f4;
border: 1px solid #45475a;
}
.btn-import:hover {
background: #45475a;
}
.btn-refresh {
background: transparent;
color: #a6adc8;
border: 1px solid #45475a;
}
.btn-refresh:hover {
background: #313244;
}
/* Error display */
.dashboard-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: rgba(243, 139, 168, 0.1);
color: #f38ba8;
font-size: 14px;
}
/* Loading */
.dashboard-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #a6adc8;
}
.dashboard-loading .spinner {
width: 32px;
height: 32px;
border: 3px solid #45475a;
border-top-color: #89b4fa;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Content layout */
.dashboard-content {
display: flex;
flex: 1;
overflow: hidden;
}
.dashboard-sidebar {
width: 320px;
border-right: 1px solid #313244;
display: flex;
flex-direction: column;
}
.dashboard-main {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* Pack List */
.pack-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.pack-list-empty {
padding: 32px;
text-align: center;
color: #6c7086;
}
.pack-list-empty small {
display: block;
margin-top: 8px;
}
.pack-item {
padding: 12px;
margin-bottom: 8px;
background: #313244;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
position: relative;
}
.pack-item:hover {
background: #45475a;
}
.pack-item.selected {
border-color: #89b4fa;
background: rgba(137, 180, 250, 0.1);
}
.pack-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.pack-icon {
font-size: 20px;
}
.pack-title {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.pack-name {
font-weight: 600;
font-size: 14px;
}
.pack-version {
font-size: 11px;
color: #6c7086;
background: #1e1e2e;
padding: 2px 6px;
border-radius: 4px;
}
.pack-description {
margin: 0 0 8px 0;
font-size: 12px;
color: #a6adc8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pack-stats {
display: flex;
gap: 12px;
font-size: 11px;
color: #6c7086;
}
.pack-stats .stat {
display: flex;
align-items: center;
gap: 4px;
}
.pack-stats .stat-icon {
font-size: 12px;
}
.pack-tags {
display: flex;
gap: 4px;
margin-top: 8px;
flex-wrap: wrap;
}
.tag {
padding: 2px 8px;
background: #45475a;
border-radius: 4px;
font-size: 10px;
color: #a6adc8;
}
.tag.more {
background: #585b70;
}
.pack-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.pack-item:hover .pack-actions {
opacity: 1;
}
.btn-action {
width: 24px;
height: 24px;
padding: 0;
border: none;
background: #45475a;
color: #cdd6f4;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.btn-action:hover {
background: #585b70;
}
.btn-action.delete:hover {
background: #f38ba8;
color: #1e1e2e;
}
/* Packs Summary */
.packs-summary {
padding: 16px;
border-top: 1px solid #313244;
background: #181825;
}
.packs-summary h4 {
margin: 0 0 12px 0;
font-size: 12px;
color: #6c7086;
text-transform: uppercase;
}
.summary-stats {
display: flex;
gap: 16px;
}
.summary-stats .stat {
text-align: center;
}
.summary-stats .value {
display: block;
font-size: 20px;
font-weight: 600;
color: #89b4fa;
}
.summary-stats .label {
display: block;
font-size: 11px;
color: #6c7086;
}
/* No Selection */
.no-selection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: #6c7086;
}
.no-selection .icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.no-selection h3 {
margin: 0 0 8px 0;
color: #a6adc8;
}
.no-selection p {
margin: 0;
max-width: 300px;
}
/* Pack Details */
.pack-details {
max-width: 800px;
}
.details-header {
margin-bottom: 24px;
}
.details-header .header-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.details-header h3 {
margin: 0;
font-size: 20px;
}
.details-header .version {
padding: 4px 8px;
background: #313244;
border-radius: 4px;
font-size: 12px;
color: #a6adc8;
}
.details-header .description {
margin: 0 0 12px 0;
color: #a6adc8;
font-size: 14px;
}
.details-header .tags {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 8px;
}
.btn-edit,
.btn-export {
padding: 6px 12px;
border: 1px solid #45475a;
background: transparent;
color: #a6adc8;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.btn-edit:hover,
.btn-export:hover {
background: #313244;
color: #cdd6f4;
}
/* Edit form */
.edit-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-form input,
.edit-form textarea {
padding: 8px 12px;
background: #313244;
border: 1px solid #45475a;
border-radius: 6px;
color: #cdd6f4;
font-size: 14px;
}
.edit-form input:focus,
.edit-form textarea:focus {
outline: none;
border-color: #89b4fa;
}
.edit-form .edit-name {
font-size: 18px;
font-weight: 600;
}
.edit-actions {
display: flex;
gap: 8px;
}
.btn-cancel,
.btn-save {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-cancel {
background: #45475a;
color: #cdd6f4;
}
.btn-save {
background: #89b4fa;
color: #1e1e2e;
}
/* Statistics */
.details-stats {
background: #313244;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.details-stats h4 {
margin: 0 0 16px 0;
font-size: 14px;
color: #a6adc8;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.stat-card {
text-align: center;
padding: 12px;
background: #1e1e2e;
border-radius: 6px;
}
.stat-card .value {
display: block;
font-size: 24px;
font-weight: 600;
color: #89b4fa;
}
.stat-card .label {
display: block;
font-size: 11px;
color: #6c7086;
margin-top: 4px;
}
.distribution h5 {
margin: 0 0 12px 0;
font-size: 12px;
color: #6c7086;
}
.distribution-bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.bar-item {
display: flex;
align-items: center;
gap: 8px;
}
.bar-label {
width: 100px;
font-size: 11px;
color: #a6adc8;
}
.bar-container {
flex: 1;
height: 8px;
background: #1e1e2e;
border-radius: 4px;
overflow: hidden;
}
.bar {
height: 100%;
background: #89b4fa;
border-radius: 4px;
}
.bar-value {
width: 30px;
text-align: right;
font-size: 11px;
color: #6c7086;
}
/* Corrections list */
.details-corrections {
background: #313244;
border-radius: 8px;
padding: 16px;
}
.corrections-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.corrections-header h4 {
margin: 0;
font-size: 14px;
}
.corrections-controls {
display: flex;
gap: 8px;
}
.filter-input,
.sort-select {
padding: 6px 10px;
background: #1e1e2e;
border: 1px solid #45475a;
border-radius: 4px;
color: #cdd6f4;
font-size: 12px;
}
.filter-input {
width: 150px;
}
.no-corrections {
padding: 24px;
text-align: center;
color: #6c7086;
}
.corrections-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.correction-item {
background: #1e1e2e;
border-radius: 6px;
overflow: hidden;
}
.correction-summary {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
cursor: pointer;
}
.correction-summary:hover {
background: #252536;
}
.type-icon {
font-size: 18px;
}
.correction-info {
flex: 1;
display: flex;
gap: 8px;
font-size: 12px;
}
.correction-info .action {
font-weight: 600;
color: #cdd6f4;
}
.correction-info .element {
color: #89b4fa;
}
.correction-info .correction-type {
color: #6c7086;
padding: 2px 6px;
background: #313244;
border-radius: 4px;
}
.correction-metrics {
display: flex;
gap: 12px;
font-size: 12px;
}
.correction-metrics .confidence {
font-weight: 600;
}
.correction-metrics .success-rate {
color: #6c7086;
}
.expand-icon {
font-size: 10px;
color: #6c7086;
}
.correction-details {
padding: 12px;
border-top: 1px solid #313244;
background: #181825;
}
.detail-row {
margin-bottom: 12px;
}
.detail-row:last-child {
margin-bottom: 0;
}
.detail-row label {
display: block;
font-size: 10px;
color: #6c7086;
text-transform: uppercase;
margin-bottom: 4px;
}
.detail-row span {
font-size: 12px;
color: #a6adc8;
}
.detail-row pre {
margin: 0;
padding: 8px;
background: #313244;
border-radius: 4px;
font-size: 11px;
color: #cdd6f4;
overflow-x: auto;
}
.detail-row.source {
font-size: 11px;
color: #6c7086;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-width: 480px;
background: #313244;
border-radius: 12px;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #45475a;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
}
.btn-close {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: #a6adc8;
font-size: 24px;
cursor: pointer;
}
.btn-close:hover {
color: #cdd6f4;
}
.modal-body {
padding: 20px;
}
.modal-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: rgba(243, 139, 168, 0.1);
color: #f38ba8;
border-radius: 6px;
margin-bottom: 16px;
font-size: 13px;
}
.modal-body .form-group {
margin-bottom: 16px;
}
.modal-body label {
display: block;
font-size: 12px;
color: #a6adc8;
margin-bottom: 6px;
}
.modal-body input,
.modal-body select,
.modal-body textarea {
width: 100%;
padding: 10px 12px;
background: #1e1e2e;
border: 1px solid #45475a;
border-radius: 6px;
color: #cdd6f4;
font-size: 14px;
}
.modal-body input:focus,
.modal-body select:focus,
.modal-body textarea:focus {
outline: none;
border-color: #89b4fa;
}
.modal-body small {
display: block;
margin-top: 4px;
font-size: 11px;
color: #6c7086;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #45475a;
}
.modal-footer button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.modal-footer .btn-cancel {
background: #45475a;
color: #cdd6f4;
}
.modal-footer .btn-create {
background: #89b4fa;
color: #1e1e2e;
font-weight: 600;
}
.modal-footer .btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,156 @@
/**
* CreatePackModal Component
*
* Modal for creating a new Correction Pack.
*/
import React, { useState } from 'react';
interface CreatePackModalProps {
onClose: () => void;
onCreate: (
name: string,
description: string,
tags: string[],
category: string
) => Promise<void>;
}
const CATEGORIES = [
{ value: 'default', label: 'General' },
{ value: 'ui', label: 'Interface utilisateur' },
{ value: 'form', label: 'Formulaires' },
{ value: 'navigation', label: 'Navigation' },
{ value: 'data', label: 'Donnees' },
{ value: 'error', label: 'Gestion d\'erreurs' },
];
const CreatePackModal: React.FC<CreatePackModalProps> = ({
onClose,
onCreate,
}) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [category, setCategory] = useState('default');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Le nom est requis');
return;
}
setLoading(true);
setError('');
try {
await onCreate(
name.trim(),
description.trim(),
tags.split(',').map((t) => t.trim()).filter(Boolean),
category
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors de la creation');
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Nouveau Correction Pack</h3>
<button className="btn-close" onClick={onClose}>
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div className="modal-error">
<span>&#x26A0;</span> {error}
</div>
)}
<div className="form-group">
<label htmlFor="pack-name">Nom *</label>
<input
id="pack-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Mon pack de corrections"
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="pack-description">Description</label>
<textarea
id="pack-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description du pack..."
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="pack-category">Categorie</label>
<select
id="pack-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{CATEGORIES.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="pack-tags">Tags</label>
<input
id="pack-tags"
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
<small>Separez les tags par des virgules</small>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn-cancel"
onClick={onClose}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="btn-create"
disabled={loading || !name.trim()}
>
{loading ? 'Creation...' : 'Creer le pack'}
</button>
</div>
</form>
</div>
</div>
);
};
export default CreatePackModal;

View File

@@ -0,0 +1,321 @@
/**
* PackDetails Component
*
* Shows detailed view of a Correction Pack with all its corrections.
*/
import React, { useState, useEffect } from 'react';
import { CorrectionPack, Correction, useCorrectionPacks } from '../../hooks/useCorrectionPacks';
interface PackDetailsProps {
pack: CorrectionPack;
onUpdate: (packId: string, updates: Partial<CorrectionPack>) => Promise<boolean>;
onExport: (pack: CorrectionPack, format: 'json' | 'yaml') => void;
}
const PackDetails: React.FC<PackDetailsProps> = ({
pack,
onUpdate,
onExport,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(pack.name);
const [editDescription, setEditDescription] = useState(pack.description);
const [editTags, setEditTags] = useState(pack.tags.join(', '));
const [filter, setFilter] = useState('');
const [sortBy, setSortBy] = useState<'confidence' | 'type' | 'date'>('confidence');
const { getStatistics } = useCorrectionPacks();
const [stats, setStats] = useState<any>(null);
// Load statistics
useEffect(() => {
getStatistics(pack.id).then(setStats);
}, [pack.id, getStatistics]);
// Reset form when pack changes
useEffect(() => {
setEditName(pack.name);
setEditDescription(pack.description);
setEditTags(pack.tags.join(', '));
setIsEditing(false);
}, [pack]);
// Handle save
const handleSave = async () => {
const success = await onUpdate(pack.id, {
name: editName,
description: editDescription,
tags: editTags.split(',').map((t) => t.trim()).filter(Boolean),
});
if (success) {
setIsEditing(false);
}
};
// Filter and sort corrections
const filteredCorrections = pack.corrections
?.filter((c) => {
if (!filter) return true;
const searchLower = filter.toLowerCase();
return (
c.actionType?.toLowerCase().includes(searchLower) ||
c.elementType?.toLowerCase().includes(searchLower) ||
c.correctionType?.toLowerCase().includes(searchLower)
);
})
.sort((a, b) => {
switch (sortBy) {
case 'confidence':
return (b.confidenceScore || 0) - (a.confidenceScore || 0);
case 'type':
return (a.correctionType || '').localeCompare(b.correctionType || '');
case 'date':
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
default:
return 0;
}
}) || [];
// Get correction type icon
const getCorrectionTypeIcon = (type: string): string => {
const icons: Record<string, string> = {
target_change: '\u{1F3AF}',
parameter_change: '\u{2699}',
timing_adjust: '\u{23F1}',
coordinates_adjust: '\u{1F4CD}',
other: '\u{2753}',
};
return icons[type?.toLowerCase()] || icons.other;
};
return (
<div className="pack-details">
{/* Header */}
<div className="details-header">
{isEditing ? (
<div className="edit-form">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Nom du pack"
className="edit-name"
/>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description"
className="edit-description"
rows={2}
/>
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="Tags (separes par des virgules)"
className="edit-tags"
/>
<div className="edit-actions">
<button className="btn-cancel" onClick={() => setIsEditing(false)}>
Annuler
</button>
<button className="btn-save" onClick={handleSave}>
Sauvegarder
</button>
</div>
</div>
) : (
<>
<div className="header-info">
<h3>{pack.name}</h3>
<span className="version">v{pack.version}</span>
</div>
{pack.description && <p className="description">{pack.description}</p>}
{pack.tags && pack.tags.length > 0 && (
<div className="tags">
{pack.tags.map((tag, i) => (
<span key={i} className="tag">{tag}</span>
))}
</div>
)}
<div className="header-actions">
<button className="btn-edit" onClick={() => setIsEditing(true)}>
&#x270E; Modifier
</button>
<button className="btn-export" onClick={() => onExport(pack, 'json')}>
&#x2B07; Export JSON
</button>
<button className="btn-export" onClick={() => onExport(pack, 'yaml')}>
&#x2B07; Export YAML
</button>
</div>
</>
)}
</div>
{/* Statistics */}
{stats && (
<div className="details-stats">
<h4>Statistiques</h4>
<div className="stats-grid">
<div className="stat-card">
<span className="value">{stats.totalCorrections || 0}</span>
<span className="label">Corrections</span>
</div>
<div className="stat-card">
<span className="value">
{stats.avgSuccessRate ? `${Math.round(stats.avgSuccessRate * 100)}%` : '-'}
</span>
<span className="label">Taux succes</span>
</div>
<div className="stat-card">
<span className="value">
{stats.avgConfidence ? `${Math.round(stats.avgConfidence * 100)}%` : '-'}
</span>
<span className="label">Confiance moy.</span>
</div>
</div>
{/* Distribution by type */}
{stats.byType && Object.keys(stats.byType).length > 0 && (
<div className="distribution">
<h5>Par type de correction</h5>
<div className="distribution-bars">
{Object.entries(stats.byType).map(([type, count]: [string, any]) => (
<div key={type} className="bar-item">
<span className="bar-label">{type}</span>
<div className="bar-container">
<div
className="bar"
style={{
width: `${(count / stats.totalCorrections) * 100}%`,
}}
/>
</div>
<span className="bar-value">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Corrections list */}
<div className="details-corrections">
<div className="corrections-header">
<h4>Corrections ({filteredCorrections.length})</h4>
<div className="corrections-controls">
<input
type="text"
placeholder="Filtrer..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="filter-input"
/>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="sort-select"
>
<option value="confidence">Confiance</option>
<option value="type">Type</option>
<option value="date">Date</option>
</select>
</div>
</div>
{filteredCorrections.length === 0 ? (
<div className="no-corrections">
<p>Aucune correction dans ce pack</p>
</div>
) : (
<div className="corrections-list">
{filteredCorrections.map((correction, index) => (
<CorrectionItem key={correction.id || index} correction={correction} />
))}
</div>
)}
</div>
</div>
);
};
// Sub-component for individual correction
interface CorrectionItemProps {
correction: Correction;
}
const CorrectionItem: React.FC<CorrectionItemProps> = ({ correction }) => {
const [expanded, setExpanded] = useState(false);
const getCorrectionTypeIcon = (type: string): string => {
const icons: Record<string, string> = {
target_change: '\u{1F3AF}',
parameter_change: '\u{2699}',
timing_adjust: '\u{23F1}',
coordinates_adjust: '\u{1F4CD}',
other: '\u{2753}',
};
return icons[type?.toLowerCase()] || icons.other;
};
return (
<div className={`correction-item ${expanded ? 'expanded' : ''}`}>
<div className="correction-summary" onClick={() => setExpanded(!expanded)}>
<span className="type-icon">{getCorrectionTypeIcon(correction.correctionType)}</span>
<div className="correction-info">
<span className="action">{correction.actionType}</span>
<span className="element">{correction.elementType}</span>
<span className="correction-type">{correction.correctionType}</span>
</div>
<div className="correction-metrics">
<span
className="confidence"
style={{
color: correction.confidenceScore >= 0.8 ? '#a6e3a1' :
correction.confidenceScore >= 0.5 ? '#f9e2af' : '#f38ba8'
}}
>
{Math.round((correction.confidenceScore || 0) * 100)}%
</span>
<span className="success-rate">
{correction.successCount}/{correction.successCount + correction.failureCount}
</span>
</div>
<span className="expand-icon">{expanded ? '\u25BC' : '\u25B6'}</span>
</div>
{expanded && (
<div className="correction-details">
{correction.failureReason && (
<div className="detail-row">
<label>Raison de l'echec:</label>
<span>{correction.failureReason}</span>
</div>
)}
<div className="detail-row">
<label>Cible originale:</label>
<pre>{JSON.stringify(correction.originalTarget, null, 2)}</pre>
</div>
<div className="detail-row">
<label>Cible corrigee:</label>
<pre>{JSON.stringify(correction.correctedTarget, null, 2)}</pre>
</div>
{correction.source && (
<div className="detail-row source">
<label>Source:</label>
<span>
{correction.source.workflowId && `Workflow: ${correction.source.workflowId}`}
{correction.source.sessionId && ` | Session: ${correction.source.sessionId}`}
</span>
</div>
)}
</div>
)}
</div>
);
};
export default PackDetails;

View File

@@ -0,0 +1,135 @@
/**
* PackList Component
*
* Displays list of Correction Packs with summary information.
*/
import React from 'react';
import { CorrectionPack } from '../../hooks/useCorrectionPacks';
interface PackListProps {
packs: CorrectionPack[];
selectedPack: CorrectionPack | null;
onSelect: (pack: CorrectionPack) => void;
onDelete: (pack: CorrectionPack) => void;
onExport: (pack: CorrectionPack, format: 'json' | 'yaml') => void;
}
const PackList: React.FC<PackListProps> = ({
packs,
selectedPack,
onSelect,
onDelete,
onExport,
}) => {
// Format date
const formatDate = (dateStr: string): string => {
try {
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return dateStr;
}
};
// Get category icon
const getCategoryIcon = (category: string): string => {
const icons: Record<string, string> = {
'default': '\u{1F4E6}',
'ui': '\u{1F5A5}',
'form': '\u{1F4DD}',
'navigation': '\u{1F517}',
'data': '\u{1F4CA}',
'error': '\u{26A0}',
};
return icons[category?.toLowerCase()] || icons.default;
};
if (packs.length === 0) {
return (
<div className="pack-list-empty">
<p>Aucun pack de corrections</p>
<small>Creez un nouveau pack pour commencer</small>
</div>
);
}
return (
<div className="pack-list">
{packs.map((pack) => (
<div
key={pack.id}
className={`pack-item ${selectedPack?.id === pack.id ? 'selected' : ''}`}
onClick={() => onSelect(pack)}
>
{/* Pack icon and name */}
<div className="pack-header">
<span className="pack-icon">{getCategoryIcon(pack.category)}</span>
<div className="pack-title">
<span className="pack-name">{pack.name}</span>
<span className="pack-version">v{pack.version}</span>
</div>
</div>
{/* Pack description */}
{pack.description && (
<p className="pack-description">{pack.description}</p>
)}
{/* Pack stats */}
<div className="pack-stats">
<span className="stat" title="Nombre de corrections">
<span className="stat-icon">&#x1F4DD;</span>
{pack.corrections?.length || 0}
</span>
<span className="stat" title="Confiance moyenne">
<span className="stat-icon">&#x2713;</span>
{pack.statistics?.avgConfidence
? `${Math.round(pack.statistics.avgConfidence * 100)}%`
: '-'}
</span>
<span className="stat date" title="Derniere modification">
{formatDate(pack.updatedAt)}
</span>
</div>
{/* Tags */}
{pack.tags && pack.tags.length > 0 && (
<div className="pack-tags">
{pack.tags.slice(0, 3).map((tag, index) => (
<span key={index} className="tag">{tag}</span>
))}
{pack.tags.length > 3 && (
<span className="tag more">+{pack.tags.length - 3}</span>
)}
</div>
)}
{/* Actions */}
<div className="pack-actions" onClick={(e) => e.stopPropagation()}>
<button
className="btn-action"
title="Exporter JSON"
onClick={() => onExport(pack, 'json')}
>
&#x2B07;
</button>
<button
className="btn-action delete"
title="Supprimer"
onClick={() => onDelete(pack)}
>
&#x1F5D1;
</button>
</div>
</div>
))}
</div>
);
};
export default PackList;

View File

@@ -0,0 +1,202 @@
/**
* CorrectionPacksDashboard Component
*
* Dashboard for managing Correction Packs:
* - List all packs with statistics
* - Create/edit/delete packs
* - Import/export functionality
* - View corrections in a pack
*/
import React, { useState, useCallback } from 'react';
import { useCorrectionPacks, CorrectionPack, Correction } from '../../hooks/useCorrectionPacks';
import PackList from './PackList';
import PackDetails from './PackDetails';
import CreatePackModal from './CreatePackModal';
import './CorrectionPacksDashboard.css';
interface CorrectionPacksDashboardProps {
className?: string;
}
export const CorrectionPacksDashboard: React.FC<CorrectionPacksDashboardProps> = ({
className = '',
}) => {
const {
packs,
selectedPack,
loading,
error,
fetchPacks,
createPack,
updatePack,
deletePack,
exportPack,
importPack,
selectPack,
} = useCorrectionPacks();
const [showCreateModal, setShowCreateModal] = useState(false);
const [importLoading, setImportLoading] = useState(false);
// Handle pack creation
const handleCreatePack = useCallback(async (
name: string,
description: string,
tags: string[],
category: string
) => {
const newPack = await createPack(name, description, tags, category);
if (newPack) {
setShowCreateModal(false);
selectPack(newPack);
}
}, [createPack, selectPack]);
// Handle pack deletion
const handleDeletePack = useCallback(async (pack: CorrectionPack) => {
if (window.confirm(`Supprimer le pack "${pack.name}" ?`)) {
await deletePack(pack.id);
}
}, [deletePack]);
// Handle import
const handleImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImportLoading(true);
try {
const imported = await importPack(file);
if (imported) {
selectPack(imported);
}
} finally {
setImportLoading(false);
e.target.value = ''; // Reset input
}
}, [importPack, selectPack]);
// Handle export
const handleExport = useCallback(async (pack: CorrectionPack, format: 'json' | 'yaml') => {
await exportPack(pack.id, format);
}, [exportPack]);
return (
<div className={`correction-packs-dashboard ${className}`}>
{/* Header */}
<div className="dashboard-header">
<h2>Correction Packs</h2>
<p className="subtitle">
Gerez les corrections apprises pour ameliorer l'execution automatique
</p>
<div className="header-actions">
<button
className="btn-create"
onClick={() => setShowCreateModal(true)}
>
<span>+</span> Nouveau Pack
</button>
<label className="btn-import">
<span>&#x2191;</span> Importer
<input
type="file"
accept=".json,.yaml,.yml"
onChange={handleImport}
disabled={importLoading}
hidden
/>
</label>
<button
className="btn-refresh"
onClick={fetchPacks}
disabled={loading}
>
&#x21BB; Actualiser
</button>
</div>
</div>
{/* Error display */}
{error && (
<div className="dashboard-error">
<span>&#x26A0;</span> {error}
</div>
)}
{/* Loading state */}
{loading && !packs.length && (
<div className="dashboard-loading">
<div className="spinner" />
<p>Chargement des packs...</p>
</div>
)}
{/* Main content */}
<div className="dashboard-content">
{/* Pack list */}
<div className="dashboard-sidebar">
<PackList
packs={packs}
selectedPack={selectedPack}
onSelect={selectPack}
onDelete={handleDeletePack}
onExport={handleExport}
/>
{/* Summary stats */}
{packs.length > 0 && (
<div className="packs-summary">
<h4>Resume</h4>
<div className="summary-stats">
<div className="stat">
<span className="value">{packs.length}</span>
<span className="label">Packs</span>
</div>
<div className="stat">
<span className="value">
{packs.reduce((sum, p) => sum + (p.corrections?.length || 0), 0)}
</span>
<span className="label">Corrections</span>
</div>
</div>
</div>
)}
</div>
{/* Pack details */}
<div className="dashboard-main">
{selectedPack ? (
<PackDetails
pack={selectedPack}
onUpdate={updatePack}
onExport={handleExport}
/>
) : (
<div className="no-selection">
<div className="icon">&#x1F4E6;</div>
<h3>Selectionnez un pack</h3>
<p>
Choisissez un pack dans la liste pour voir ses corrections
ou creez-en un nouveau.
</p>
</div>
)}
</div>
</div>
{/* Create pack modal */}
{showCreateModal && (
<CreatePackModal
onClose={() => setShowCreateModal(false)}
onCreate={handleCreatePack}
/>
)}
</div>
);
};
export default CorrectionPacksDashboard;

View File

@@ -0,0 +1,279 @@
/**
* Hook for COACHING mode WebSocket communication.
*
* Manages real-time communication for COACHING sessions including:
* - Subscribing to COACHING events
* - Receiving action suggestions
* - Submitting user decisions
* - Tracking COACHING statistics
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
// Types for COACHING mode
export type CoachingDecision = 'accept' | 'reject' | 'correct' | 'manual' | 'skip';
export interface CoachingSuggestion {
executionId: string;
action: string;
target: Record<string, any>;
params: Record<string, any>;
confidence: number;
alternatives?: Array<{
action: string;
target: Record<string, any>;
confidence: number;
}>;
screenshotPath?: string;
context?: Record<string, any>;
timestamp: string;
}
export interface CoachingStats {
suggestionsMade: number;
accepted: number;
rejected: number;
corrected: number;
manualExecutions: number;
acceptanceRate: number;
correctionRate: number;
}
export interface CoachingActionResult {
executionId: string;
action: string;
success: boolean;
result?: Record<string, any>;
error?: string;
timestamp: string;
}
interface UseCoachingWebSocketOptions {
serverUrl?: string;
autoConnect?: boolean;
}
interface UseCoachingWebSocketReturn {
isConnected: boolean;
isSubscribed: boolean;
currentSuggestion: CoachingSuggestion | null;
stats: CoachingStats;
lastActionResult: CoachingActionResult | null;
error: string | null;
subscribe: (executionId: string) => void;
unsubscribe: () => void;
submitDecision: (decision: CoachingDecision, correction?: Record<string, any>, feedback?: string) => void;
refreshStats: () => void;
}
const initialStats: CoachingStats = {
suggestionsMade: 0,
accepted: 0,
rejected: 0,
corrected: 0,
manualExecutions: 0,
acceptanceRate: 0,
correctionRate: 0,
};
export function useCoachingWebSocket(
options: UseCoachingWebSocketOptions = {}
): UseCoachingWebSocketReturn {
const { serverUrl = 'http://localhost:5000', autoConnect = true } = options;
const [isConnected, setIsConnected] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const [currentSuggestion, setCurrentSuggestion] = useState<CoachingSuggestion | null>(null);
const [stats, setStats] = useState<CoachingStats>(initialStats);
const [lastActionResult, setLastActionResult] = useState<CoachingActionResult | null>(null);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
const executionIdRef = useRef<string | null>(null);
// Initialize socket connection
useEffect(() => {
if (!autoConnect) return;
const socket = io(serverUrl, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socket.on('connect', () => {
console.log('[COACHING WS] Connected');
setIsConnected(true);
setError(null);
});
socket.on('disconnect', () => {
console.log('[COACHING WS] Disconnected');
setIsConnected(false);
setIsSubscribed(false);
});
socket.on('connect_error', (err) => {
console.error('[COACHING WS] Connection error:', err);
setError(`Connection error: ${err.message}`);
});
// COACHING specific events
socket.on('coaching_subscribed', (data) => {
console.log('[COACHING WS] Subscribed:', data);
setIsSubscribed(true);
if (data.stats) {
setStats(convertStats(data.stats));
}
});
socket.on('coaching_unsubscribed', () => {
console.log('[COACHING WS] Unsubscribed');
setIsSubscribed(false);
setCurrentSuggestion(null);
});
socket.on('coaching_suggestion', (data: any) => {
console.log('[COACHING WS] Suggestion received:', data);
setCurrentSuggestion({
executionId: data.execution_id,
action: data.action,
target: data.target || {},
params: data.params || {},
confidence: data.confidence || 0,
alternatives: data.alternatives,
screenshotPath: data.screenshot_path,
context: data.context,
timestamp: data.timestamp,
});
});
socket.on('coaching_action_result', (data: any) => {
console.log('[COACHING WS] Action result:', data);
setLastActionResult({
executionId: data.execution_id,
action: data.action,
success: data.success,
result: data.result,
error: data.error,
timestamp: data.timestamp,
});
// Clear current suggestion after result
setCurrentSuggestion(null);
});
socket.on('coaching_stats_update', (data: any) => {
console.log('[COACHING WS] Stats update:', data);
if (data.stats) {
setStats(convertStats(data.stats));
}
});
socket.on('coaching_decision_accepted', (data: any) => {
console.log('[COACHING WS] Decision accepted:', data);
});
socket.on('coaching_decision_broadcast', (data: any) => {
console.log('[COACHING WS] Decision broadcast:', data);
});
socket.on('coaching_session_end', (data: any) => {
console.log('[COACHING WS] Session ended:', data);
setIsSubscribed(false);
setCurrentSuggestion(null);
if (data.stats) {
setStats(convertStats(data.stats));
}
});
socket.on('error', (data) => {
console.error('[COACHING WS] Error:', data);
setError(data.message || 'Unknown error');
});
socketRef.current = socket;
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [serverUrl, autoConnect]);
// Convert backend stats format to frontend format
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
return {
suggestionsMade: backendStats.suggestions_made || 0,
accepted: backendStats.accepted || 0,
rejected: backendStats.rejected || 0,
corrected: backendStats.corrected || 0,
manualExecutions: backendStats.manual_executions || 0,
acceptanceRate: backendStats.acceptance_rate || 0,
correctionRate: backendStats.correction_rate || 0,
};
};
// Subscribe to COACHING events for an execution
const subscribe = useCallback((executionId: string) => {
if (!socketRef.current || !isConnected) {
setError('Not connected to server');
return;
}
executionIdRef.current = executionId;
socketRef.current.emit('subscribe_coaching', { execution_id: executionId });
}, [isConnected]);
// Unsubscribe from COACHING events
const unsubscribe = useCallback(() => {
if (!socketRef.current || !executionIdRef.current) return;
socketRef.current.emit('unsubscribe_coaching', {
execution_id: executionIdRef.current,
});
executionIdRef.current = null;
}, []);
// Submit a COACHING decision
const submitDecision = useCallback(
(decision: CoachingDecision, correction?: Record<string, any>, feedback?: string) => {
if (!socketRef.current || !executionIdRef.current) {
setError('Not subscribed to any execution');
return;
}
socketRef.current.emit('coaching_decision', {
execution_id: executionIdRef.current,
decision,
correction,
feedback,
});
},
[]
);
// Refresh stats
const refreshStats = useCallback(() => {
if (!socketRef.current || !executionIdRef.current) return;
socketRef.current.emit('get_coaching_stats', {
execution_id: executionIdRef.current,
});
}, []);
return {
isConnected,
isSubscribed,
currentSuggestion,
stats,
lastActionResult,
error,
subscribe,
unsubscribe,
submitDecision,
refreshStats,
};
}
export default useCoachingWebSocket;

View File

@@ -0,0 +1,287 @@
/**
* Hook for Correction Packs API
*
* Provides methods to interact with the Correction Packs backend API.
*/
import { useState, useCallback, useEffect } from 'react';
// Types
export interface Correction {
id: string;
actionType: string;
elementType: string;
failureReason?: string;
correctionType: string;
originalTarget: Record<string, any>;
correctedTarget: Record<string, any>;
successCount: number;
failureCount: number;
confidenceScore: number;
createdAt: string;
source: {
sessionId?: string;
workflowId?: string;
nodeId?: string;
};
}
export interface CorrectionPack {
id: string;
name: string;
description: string;
corrections: Correction[];
tags: string[];
category: string;
version: number;
createdAt: string;
updatedAt: string;
statistics: {
totalCorrections: number;
avgConfidence: number;
mostCommonType: string;
};
}
export interface PackStatistics {
totalCorrections: number;
avgSuccessRate: number;
avgConfidence: number;
byType: Record<string, number>;
byElement: Record<string, number>;
}
interface UseCorrectionPacksReturn {
packs: CorrectionPack[];
selectedPack: CorrectionPack | null;
loading: boolean;
error: string | null;
fetchPacks: () => Promise<void>;
fetchPack: (packId: string) => Promise<CorrectionPack | null>;
createPack: (name: string, description?: string, tags?: string[], category?: string) => Promise<CorrectionPack | null>;
updatePack: (packId: string, updates: Partial<CorrectionPack>) => Promise<boolean>;
deletePack: (packId: string) => Promise<boolean>;
exportPack: (packId: string, format?: 'json' | 'yaml') => Promise<void>;
importPack: (file: File) => Promise<CorrectionPack | null>;
getStatistics: (packId: string) => Promise<PackStatistics | null>;
findApplicable: (context: Record<string, any>) => Promise<Correction[]>;
selectPack: (pack: CorrectionPack | null) => void;
}
const API_BASE = 'http://localhost:5000/api';
export function useCorrectionPacks(): UseCorrectionPacksReturn {
const [packs, setPacks] = useState<CorrectionPack[]>([]);
const [selectedPack, setSelectedPack] = useState<CorrectionPack | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch all packs
const fetchPacks = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/correction-packs`);
if (!response.ok) throw new Error('Failed to fetch packs');
const data = await response.json();
setPacks(data.packs || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, []);
// Fetch single pack
const fetchPack = useCallback(async (packId: string): Promise<CorrectionPack | null> => {
try {
const response = await fetch(`${API_BASE}/correction-packs/${packId}`);
if (!response.ok) return null;
const data = await response.json();
return data.pack || data;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return null;
}
}, []);
// Create new pack
const createPack = useCallback(async (
name: string,
description?: string,
tags?: string[],
category?: string
): Promise<CorrectionPack | null> => {
try {
const response = await fetch(`${API_BASE}/correction-packs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, tags, category }),
});
if (!response.ok) throw new Error('Failed to create pack');
const data = await response.json();
await fetchPacks(); // Refresh list
return data.pack || data;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return null;
}
}, [fetchPacks]);
// Update pack
const updatePack = useCallback(async (
packId: string,
updates: Partial<CorrectionPack>
): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/correction-packs/${packId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) return false;
await fetchPacks(); // Refresh list
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return false;
}
}, [fetchPacks]);
// Delete pack
const deletePack = useCallback(async (packId: string): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/correction-packs/${packId}`, {
method: 'DELETE',
});
if (!response.ok) return false;
await fetchPacks(); // Refresh list
if (selectedPack?.id === packId) {
setSelectedPack(null);
}
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return false;
}
}, [fetchPacks, selectedPack]);
// Export pack
const exportPack = useCallback(async (
packId: string,
format: 'json' | 'yaml' = 'json'
): Promise<void> => {
try {
const response = await fetch(
`${API_BASE}/correction-packs/${packId}/export?format=${format}`
);
if (!response.ok) throw new Error('Failed to export pack');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `correction_pack_${packId}.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
}, []);
// Import pack
const importPack = useCallback(async (file: File): Promise<CorrectionPack | null> => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/correction-packs/import`, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Failed to import pack');
const data = await response.json();
await fetchPacks(); // Refresh list
return data.pack || data;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return null;
}
}, [fetchPacks]);
// Get statistics
const getStatistics = useCallback(async (packId: string): Promise<PackStatistics | null> => {
try {
const response = await fetch(`${API_BASE}/correction-packs/${packId}/statistics`);
if (!response.ok) return null;
const data = await response.json();
return data.statistics || data;
} catch (err) {
return null;
}
}, []);
// Find applicable corrections
const findApplicable = useCallback(async (
context: Record<string, any>
): Promise<Correction[]> => {
try {
const response = await fetch(`${API_BASE}/correction-packs/find`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(context),
});
if (!response.ok) return [];
const data = await response.json();
return data.corrections || [];
} catch (err) {
return [];
}
}, []);
// Select pack
const selectPack = useCallback((pack: CorrectionPack | null) => {
setSelectedPack(pack);
}, []);
// Initial fetch
useEffect(() => {
fetchPacks();
}, [fetchPacks]);
return {
packs,
selectedPack,
loading,
error,
fetchPacks,
fetchPack,
createPack,
updatePack,
deletePack,
exportPack,
importPack,
getStatistics,
findApplicable,
selectPack,
};
}
export default useCorrectionPacks;