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:
Dom
2026-01-18 18:48:35 +01:00
parent fa57ecdbfd
commit d8756883c5
9 changed files with 4411 additions and 0 deletions

View 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