feat(corrections): Add Correction Packs system for cross-workflow learning
Implement a complete system for capitalizing user corrections across multiple workflows and sessions. This enables automatic application of learned fixes when similar failures occur in different contexts. New components: - core/corrections/models.py: CorrectionKey, Correction, CorrectionPack models - core/corrections/correction_repository.py: JSON storage with atomic writes - core/corrections/aggregator.py: Aggregation by hash and quality filtering - core/corrections/correction_pack_service.py: CRUD, export/import, versioning - backend/api/correction_packs.py: REST API with 15 endpoints Features: - MD5-based key hashing for correction deduplication - Export/import in JSON and YAML formats - Version history with rollback support - Cross-workflow pattern detection - Integration with SelfHealingEngine for automatic application - 29 unit tests (all passing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
640
visual_workflow_builder/backend/api/correction_packs.py
Normal file
640
visual_workflow_builder/backend/api/correction_packs.py
Normal file
@@ -0,0 +1,640 @@
|
||||
"""
|
||||
API endpoints pour les Correction Packs.
|
||||
|
||||
Provides REST API for managing correction packs, corrections,
|
||||
aggregation, export/import, and versioning.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
from typing import Optional
|
||||
|
||||
# Add parent paths for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))))
|
||||
|
||||
from core.corrections import CorrectionPackService
|
||||
|
||||
correction_packs_bp = Blueprint('correction_packs', __name__)
|
||||
|
||||
# Initialize service (singleton pattern)
|
||||
_service: Optional[CorrectionPackService] = None
|
||||
|
||||
|
||||
def get_service() -> CorrectionPackService:
|
||||
"""Get or create the correction pack service."""
|
||||
global _service
|
||||
if _service is None:
|
||||
# Use paths relative to the project root
|
||||
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
storage_path = os.path.join(base_path, "data", "correction_packs")
|
||||
training_path = os.path.join(base_path, "training_data")
|
||||
_service = CorrectionPackService(storage_path, training_path)
|
||||
return _service
|
||||
|
||||
|
||||
# ========== Pack CRUD Endpoints ==========
|
||||
|
||||
@correction_packs_bp.route('/correction-packs', methods=['GET'])
|
||||
def list_packs():
|
||||
"""
|
||||
List all correction packs.
|
||||
|
||||
Query params:
|
||||
category: Filter by category
|
||||
tags: Filter by tags (comma-separated)
|
||||
|
||||
Returns:
|
||||
JSON list of pack summaries
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
|
||||
category = request.args.get('category')
|
||||
tags_param = request.args.get('tags')
|
||||
tags = tags_param.split(',') if tags_param else None
|
||||
|
||||
packs = service.list_packs(category=category, tags=tags)
|
||||
return jsonify({'success': True, 'packs': packs})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs', methods=['POST'])
|
||||
def create_pack():
|
||||
"""
|
||||
Create a new correction pack.
|
||||
|
||||
Request body:
|
||||
name: Pack name (required)
|
||||
description: Pack description
|
||||
category: Pack category
|
||||
tags: List of tags
|
||||
author: Pack author
|
||||
|
||||
Returns:
|
||||
Created pack
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
if not data.get('name'):
|
||||
return jsonify({'success': False, 'error': 'name is required'}), 400
|
||||
|
||||
pack = service.create_pack(
|
||||
name=data['name'],
|
||||
description=data.get('description', ''),
|
||||
category=data.get('category', 'general'),
|
||||
tags=data.get('tags', []),
|
||||
author=data.get('author', '')
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'pack': pack}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>', methods=['GET'])
|
||||
def get_pack(pack_id: str):
|
||||
"""
|
||||
Get a pack by ID.
|
||||
|
||||
Returns:
|
||||
Pack details including all corrections
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
pack = service.get_pack(pack_id)
|
||||
|
||||
if not pack:
|
||||
return jsonify({'success': False, 'error': 'Pack not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'pack': pack})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>', methods=['PUT'])
|
||||
def update_pack(pack_id: str):
|
||||
"""
|
||||
Update a pack.
|
||||
|
||||
Request body:
|
||||
name: New name
|
||||
description: New description
|
||||
metadata: New metadata dict
|
||||
|
||||
Returns:
|
||||
Updated pack
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
pack = service.update_pack(
|
||||
pack_id=pack_id,
|
||||
name=data.get('name'),
|
||||
description=data.get('description'),
|
||||
metadata=data.get('metadata')
|
||||
)
|
||||
|
||||
if not pack:
|
||||
return jsonify({'success': False, 'error': 'Pack not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'pack': pack})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>', methods=['DELETE'])
|
||||
def delete_pack(pack_id: str):
|
||||
"""
|
||||
Delete a pack.
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
|
||||
if not service.delete_pack(pack_id):
|
||||
return jsonify({'success': False, 'error': 'Pack not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'message': 'Pack deleted'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ========== Correction Endpoints ==========
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/corrections', methods=['POST'])
|
||||
def add_correction(pack_id: str):
|
||||
"""
|
||||
Add a correction to a pack.
|
||||
|
||||
Request body:
|
||||
action_type: Type of action (required)
|
||||
element_type: Type of element
|
||||
failure_context: Description of failure
|
||||
correction_type: Type of correction
|
||||
original_target: Original target dict
|
||||
original_params: Original parameters dict
|
||||
corrected_target: Corrected target dict
|
||||
corrected_params: Corrected parameters dict
|
||||
description: Correction description
|
||||
tags: List of tags
|
||||
source: Source info dict
|
||||
|
||||
Returns:
|
||||
Created correction
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
if not data.get('action_type'):
|
||||
return jsonify({'success': False, 'error': 'action_type is required'}), 400
|
||||
|
||||
correction = service.add_correction(pack_id, data)
|
||||
|
||||
if not correction:
|
||||
return jsonify({'success': False, 'error': 'Failed to add correction'}), 400
|
||||
|
||||
return jsonify({'success': True, 'correction': correction}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/corrections/<correction_id>', methods=['PUT'])
|
||||
def update_correction(pack_id: str, correction_id: str):
|
||||
"""
|
||||
Update a correction.
|
||||
|
||||
Request body:
|
||||
corrected_target: New corrected target
|
||||
corrected_params: New corrected params
|
||||
correction_description: New description
|
||||
status: New status
|
||||
tags: New tags
|
||||
|
||||
Returns:
|
||||
Updated correction
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
correction = service.update_correction(pack_id, correction_id, data)
|
||||
|
||||
if not correction:
|
||||
return jsonify({'success': False, 'error': 'Correction not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'correction': correction})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/corrections/<correction_id>', methods=['DELETE'])
|
||||
def remove_correction(pack_id: str, correction_id: str):
|
||||
"""
|
||||
Remove a correction from a pack.
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
|
||||
if not service.remove_correction(pack_id, correction_id):
|
||||
return jsonify({'success': False, 'error': 'Correction not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'message': 'Correction removed'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ========== Aggregation Endpoints ==========
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/aggregate', methods=['POST'])
|
||||
def aggregate_from_sessions(pack_id: str):
|
||||
"""
|
||||
Aggregate corrections from training sessions into a pack.
|
||||
|
||||
Request body:
|
||||
workflow_id: Filter by workflow ID
|
||||
min_occurrences: Minimum occurrences to include
|
||||
min_success_rate: Minimum success rate to include
|
||||
session_files: Specific session files to process
|
||||
|
||||
Returns:
|
||||
Aggregation summary
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
result = service.aggregate_from_sessions(
|
||||
pack_id=pack_id,
|
||||
session_files=data.get('session_files'),
|
||||
workflow_id=data.get('workflow_id'),
|
||||
min_occurrences=data.get('min_occurrences', 1),
|
||||
min_success_rate=data.get('min_success_rate', 0.0)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'result': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/aggregate-cross-workflow', methods=['POST'])
|
||||
def aggregate_cross_workflow(pack_id: str):
|
||||
"""
|
||||
Find and aggregate corrections common across multiple workflows.
|
||||
|
||||
Request body:
|
||||
min_workflows: Minimum number of workflows (default: 2)
|
||||
|
||||
Returns:
|
||||
Aggregation summary with patterns
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
result = service.aggregate_cross_workflow(
|
||||
pack_id=pack_id,
|
||||
min_workflows=data.get('min_workflows', 2)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'result': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ========== Search and Apply Endpoints ==========
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/find', methods=['POST'])
|
||||
def find_corrections():
|
||||
"""
|
||||
Find corrections applicable to a failure context.
|
||||
|
||||
Request body:
|
||||
action_type: Type of action that failed (required)
|
||||
element_type: Type of element involved
|
||||
failure_context: Description of failure
|
||||
pack_ids: Specific packs to search
|
||||
min_confidence: Minimum confidence score
|
||||
|
||||
Returns:
|
||||
List of applicable corrections
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
if not data.get('action_type'):
|
||||
return jsonify({'success': False, 'error': 'action_type is required'}), 400
|
||||
|
||||
corrections = service.find_applicable_corrections(
|
||||
action_type=data['action_type'],
|
||||
element_type=data.get('element_type', 'unknown'),
|
||||
failure_context=data.get('failure_context', ''),
|
||||
pack_ids=data.get('pack_ids'),
|
||||
min_confidence=data.get('min_confidence', 0.3)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'corrections': corrections})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/apply', methods=['POST'])
|
||||
def apply_correction():
|
||||
"""
|
||||
Record an application of a correction.
|
||||
|
||||
Request body:
|
||||
pack_id: Pack ID (required)
|
||||
correction_id: Correction ID (required)
|
||||
success: Whether the application was successful (required)
|
||||
context: Optional application context
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
if not data.get('pack_id') or not data.get('correction_id'):
|
||||
return jsonify({'success': False, 'error': 'pack_id and correction_id are required'}), 400
|
||||
|
||||
if 'success' not in data:
|
||||
return jsonify({'success': False, 'error': 'success is required'}), 400
|
||||
|
||||
service.apply_correction(
|
||||
pack_id=data['pack_id'],
|
||||
correction_id=data['correction_id'],
|
||||
success=data['success'],
|
||||
application_context=data.get('context')
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': 'Application recorded'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/search', methods=['GET'])
|
||||
def search_corrections():
|
||||
"""
|
||||
Search corrections by text query.
|
||||
|
||||
Query params:
|
||||
q: Search query (required)
|
||||
pack_id: Optional pack ID to search in
|
||||
|
||||
Returns:
|
||||
List of matching corrections
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
|
||||
query = request.args.get('q')
|
||||
if not query:
|
||||
return jsonify({'success': False, 'error': 'q parameter is required'}), 400
|
||||
|
||||
pack_id = request.args.get('pack_id')
|
||||
|
||||
corrections = service.search_corrections(query, pack_id)
|
||||
return jsonify({'success': True, 'corrections': corrections})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ========== Export/Import Endpoints ==========
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/export', methods=['GET'])
|
||||
def export_pack(pack_id: str):
|
||||
"""
|
||||
Export a pack to JSON or YAML.
|
||||
|
||||
Query params:
|
||||
format: Export format ('json' or 'yaml', default: 'json')
|
||||
|
||||
Returns:
|
||||
Exported content
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
|
||||
format_type = request.args.get('format', 'json')
|
||||
if format_type not in ['json', 'yaml']:
|
||||
return jsonify({'success': False, 'error': 'Invalid format'}), 400
|
||||
|
||||
content = service.export_pack(pack_id, format=format_type)
|
||||
|
||||
if not content:
|
||||
return jsonify({'success': False, 'error': 'Pack not found'}), 404
|
||||
|
||||
# Determine content type
|
||||
if format_type == 'yaml':
|
||||
content_type = 'application/x-yaml'
|
||||
filename = f'correction_pack_{pack_id}.yaml'
|
||||
else:
|
||||
content_type = 'application/json'
|
||||
filename = f'correction_pack_{pack_id}.json'
|
||||
|
||||
return Response(
|
||||
content,
|
||||
mimetype=content_type,
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename={filename}'
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/import', methods=['POST'])
|
||||
def import_pack():
|
||||
"""
|
||||
Import a pack from JSON or YAML.
|
||||
|
||||
Request body or file upload:
|
||||
content: Content to import (if JSON body)
|
||||
format: Import format ('json' or 'yaml')
|
||||
merge_strategy: How to handle existing pack
|
||||
- 'create_new': Always create new pack
|
||||
- 'merge': Merge into existing pack
|
||||
- 'replace': Replace existing pack
|
||||
|
||||
Returns:
|
||||
Imported pack
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
|
||||
# Check for file upload
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
content = file.read().decode('utf-8')
|
||||
filename = file.filename
|
||||
|
||||
# Auto-detect format from filename
|
||||
if filename and (filename.endswith('.yaml') or filename.endswith('.yml')):
|
||||
format_type = 'yaml'
|
||||
else:
|
||||
format_type = 'json'
|
||||
|
||||
merge_strategy = request.form.get('merge_strategy', 'create_new')
|
||||
else:
|
||||
# JSON body
|
||||
data = request.get_json() or {}
|
||||
content = data.get('content')
|
||||
|
||||
if not content:
|
||||
return jsonify({'success': False, 'error': 'content or file is required'}), 400
|
||||
|
||||
format_type = data.get('format', 'json')
|
||||
merge_strategy = data.get('merge_strategy', 'create_new')
|
||||
|
||||
pack = service.import_pack(content, format=format_type, merge_strategy=merge_strategy)
|
||||
|
||||
if not pack:
|
||||
return jsonify({'success': False, 'error': 'Failed to import pack'}), 400
|
||||
|
||||
return jsonify({'success': True, 'pack': pack}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ========== Version Endpoints ==========
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/versions', methods=['GET'])
|
||||
def list_versions(pack_id: str):
|
||||
"""
|
||||
List all versions of a pack.
|
||||
|
||||
Returns:
|
||||
List of version info
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
versions = service.list_versions(pack_id)
|
||||
return jsonify({'success': True, 'versions': versions})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/versions', methods=['POST'])
|
||||
def create_version(pack_id: str):
|
||||
"""
|
||||
Create a version snapshot of a pack.
|
||||
|
||||
Request body:
|
||||
description: Version description
|
||||
|
||||
Returns:
|
||||
Created version number
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
version = service.create_version(
|
||||
pack_id=pack_id,
|
||||
description=data.get('description', '')
|
||||
)
|
||||
|
||||
if version < 0:
|
||||
return jsonify({'success': False, 'error': 'Pack not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'version': version}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/rollback', methods=['POST'])
|
||||
def rollback_pack(pack_id: str):
|
||||
"""
|
||||
Rollback a pack to a previous version.
|
||||
|
||||
Request body:
|
||||
version: Version number to rollback to (required)
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
data = request.get_json() or {}
|
||||
|
||||
version = data.get('version')
|
||||
if version is None:
|
||||
return jsonify({'success': False, 'error': 'version is required'}), 400
|
||||
|
||||
if not service.rollback_pack(pack_id, version):
|
||||
return jsonify({'success': False, 'error': 'Rollback failed'}), 400
|
||||
|
||||
return jsonify({'success': True, 'message': f'Rolled back to version {version}'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ========== Statistics Endpoints ==========
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/<pack_id>/statistics', methods=['GET'])
|
||||
def get_pack_statistics(pack_id: str):
|
||||
"""
|
||||
Get detailed statistics for a pack.
|
||||
|
||||
Returns:
|
||||
Statistics dict
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
stats = service.get_pack_statistics(pack_id)
|
||||
|
||||
if not stats:
|
||||
return jsonify({'success': False, 'error': 'Pack not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'statistics': stats})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@correction_packs_bp.route('/correction-packs/statistics', methods=['GET'])
|
||||
def get_global_statistics():
|
||||
"""
|
||||
Get global statistics across all packs.
|
||||
|
||||
Returns:
|
||||
Global statistics dict
|
||||
"""
|
||||
try:
|
||||
service = get_service()
|
||||
stats = service.get_global_statistics()
|
||||
return jsonify({'success': True, 'statistics': stats})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
Reference in New Issue
Block a user