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:
40
core/coaching/__init__.py
Normal file
40
core/coaching/__init__.py
Normal 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
462
core/coaching/metrics.py
Normal 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
|
||||
553
core/coaching/session_persistence.py
Normal file
553
core/coaching/session_persistence.py
Normal 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
486
tests/test_coaching_e2e.py
Normal 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'])
|
||||
531
visual_workflow_builder/backend/api/coaching_sessions.py
Normal file
531
visual_workflow_builder/backend/api/coaching_sessions.py
Normal 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
|
||||
267
visual_workflow_builder/backend/api/executions.py
Normal file
267
visual_workflow_builder/backend/api/executions.py
Normal 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
|
||||
})
|
||||
@@ -119,6 +119,13 @@ try:
|
||||
except ImportError as 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)
|
||||
try:
|
||||
|
||||
1073
visual_workflow_builder/backend/services/execution_integration.py
Normal file
1073
visual_workflow_builder/backend/services/execution_integration.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
↻
|
||||
</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">📈</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">⚠</span>
|
||||
<span className="warning-text">
|
||||
Taux d'acceptation faible. Le workflow necessite plus de corrections.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoachingStatsDisplay;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">🎓</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">⚠</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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{error && (
|
||||
<div className="modal-error">
|
||||
<span>⚠</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;
|
||||
@@ -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)}>
|
||||
✎ Modifier
|
||||
</button>
|
||||
<button className="btn-export" onClick={() => onExport(pack, 'json')}>
|
||||
⬇ Export JSON
|
||||
</button>
|
||||
<button className="btn-export" onClick={() => onExport(pack, 'yaml')}>
|
||||
⬇ 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;
|
||||
@@ -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">📝</span>
|
||||
{pack.corrections?.length || 0}
|
||||
</span>
|
||||
<span className="stat" title="Confiance moyenne">
|
||||
<span className="stat-icon">✓</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')}
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
<button
|
||||
className="btn-action delete"
|
||||
title="Supprimer"
|
||||
onClick={() => onDelete(pack)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackList;
|
||||
@@ -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>↑</span> Importer
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
onChange={handleImport}
|
||||
disabled={importLoading}
|
||||
hidden
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="btn-refresh"
|
||||
onClick={fetchPacks}
|
||||
disabled={loading}
|
||||
>
|
||||
↻ Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="dashboard-error">
|
||||
<span>⚠</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">📦</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;
|
||||
@@ -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;
|
||||
287
visual_workflow_builder/frontend/src/hooks/useCorrectionPacks.ts
Normal file
287
visual_workflow_builder/frontend/src/hooks/useCorrectionPacks.ts
Normal 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;
|
||||
Reference in New Issue
Block a user