feat(coaching): Implement complete COACHING mode infrastructure

Add comprehensive COACHING mode system with:

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

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

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

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

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

View File

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

View File

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

View File

@@ -119,6 +119,13 @@ try:
except ImportError as e:
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:

File diff suppressed because it is too large Load Diff