feat: chat unifié, GestureCatalog, Copilot, Léa UI, extraction données, vérification replay
Refonte majeure du système Agent Chat et ajout de nombreux modules : - Chat unifié : suppression du dual Workflows/Agent Libre, tout passe par /api/chat avec résolution en 3 niveaux (workflow → geste → "montre-moi") - GestureCatalog : 38 raccourcis clavier universels Windows avec matching sémantique, substitution automatique dans les replays, et endpoint /api/gestures - Mode Copilot : exécution pas-à-pas des workflows avec validation humaine via WebSocket (approve/skip/abort) avant chaque action - Léa UI (agent_v0/lea_ui/) : interface PyQt5 pour Windows avec overlay transparent pour feedback visuel pendant le replay - Data Extraction (core/extraction/) : moteur d'extraction visuelle de données (OCR + VLM → SQLite), avec schémas YAML et export CSV/Excel - ReplayVerifier (agent_v0/server_v1/) : vérification post-action par comparaison de screenshots, avec logique de retry (max 3) - IntentParser durci : meilleur fallback regex, type GREETING, patterns améliorés - Dashboard : nouvelles pages gestures, streaming, extractions - Tests : 63 tests GestureCatalog, 47 tests extraction, corrections tests existants - Dépréciation : /api/agent/plan et /api/agent/execute retournent HTTP 410, suppression du code hardcodé _plan_to_replay_actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,5 +15,6 @@ from . import workflow
|
||||
from . import capture
|
||||
from . import execute
|
||||
from . import match # Matching sémantique des workflows
|
||||
from . import review # Review/Validation de workflows importés
|
||||
|
||||
__all__ = ['api_v3_bp']
|
||||
|
||||
384
visual_workflow_builder/backend/api_v3/review.py
Normal file
384
visual_workflow_builder/backend/api_v3/review.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
API v3 - Review/Validation de workflows importes depuis le streaming
|
||||
|
||||
Endpoints:
|
||||
GET /api/v3/workflows/pending-review -> liste les workflows en attente de review
|
||||
GET /api/v3/workflow/<id>/review -> donnees de review (workflow + screenshots)
|
||||
POST /api/v3/workflow/<id>/review -> soumettre une decision de review
|
||||
POST /api/v3/workflow/import-core -> importer un core Workflow avec review
|
||||
"""
|
||||
|
||||
from flask import jsonify, request
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from . import api_v3_bp
|
||||
from .workflow import generate_id
|
||||
from db.models import db, Workflow, Step
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflows/pending-review', methods=['GET'])
|
||||
def list_pending_review():
|
||||
"""
|
||||
Liste les workflows en attente de validation.
|
||||
|
||||
Filtre par source='graph_to_visual_converter' et review_status='pending_review'.
|
||||
Retourne aussi les workflows avec review_status='needs_edit'.
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflows": [
|
||||
{
|
||||
"id": "...",
|
||||
"name": "...",
|
||||
"description": "...",
|
||||
"step_count": 5,
|
||||
"source": "graph_to_visual_converter",
|
||||
"review_status": "pending_review",
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
],
|
||||
"total": 2
|
||||
}
|
||||
"""
|
||||
try:
|
||||
workflows = Workflow.query.filter(
|
||||
Workflow.is_active == True,
|
||||
Workflow.review_status.in_(['pending_review', 'needs_edit'])
|
||||
).order_by(Workflow.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for wf in workflows:
|
||||
result.append({
|
||||
'id': wf.id,
|
||||
'name': wf.name,
|
||||
'description': wf.description or '',
|
||||
'tags': wf.tags or [],
|
||||
'step_count': wf.steps.count(),
|
||||
'source': wf.source or 'manual',
|
||||
'review_status': wf.review_status,
|
||||
'review_feedback': wf.review_feedback,
|
||||
'created_at': wf.created_at.isoformat() if wf.created_at else None,
|
||||
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflows': result,
|
||||
'total': len(result)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur listing pending review: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/review', methods=['GET'])
|
||||
def get_review_data(workflow_id: str):
|
||||
"""
|
||||
Retourne les donnees de review pour un workflow.
|
||||
|
||||
Inclut le workflow complet avec ses etapes, les screenshots
|
||||
associes (si disponibles via les ancres visuelles), et les
|
||||
metadonnees de la source.
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... },
|
||||
"review_info": {
|
||||
"source": "graph_to_visual_converter",
|
||||
"review_status": "pending_review",
|
||||
"review_feedback": null,
|
||||
"reviewed_at": null,
|
||||
"step_count": 5,
|
||||
"steps_with_anchors": 3,
|
||||
"steps_without_anchors": 2
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouve"
|
||||
}), 404
|
||||
|
||||
# Compter les etapes avec/sans ancres visuelles
|
||||
steps = Step.query.filter_by(workflow_id=workflow_id).order_by(Step.order).all()
|
||||
steps_with_anchors = sum(1 for s in steps if s.anchor_id)
|
||||
steps_without_anchors = len(steps) - steps_with_anchors
|
||||
|
||||
review_info = {
|
||||
'source': workflow.source or 'manual',
|
||||
'review_status': workflow.review_status,
|
||||
'review_feedback': workflow.review_feedback,
|
||||
'reviewed_at': workflow.reviewed_at.isoformat() if workflow.reviewed_at else None,
|
||||
'step_count': len(steps),
|
||||
'steps_with_anchors': steps_with_anchors,
|
||||
'steps_without_anchors': steps_without_anchors,
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'review_info': review_info,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur get review data: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/review', methods=['POST'])
|
||||
def submit_review(workflow_id: str):
|
||||
"""
|
||||
Soumet une decision de review pour un workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"status": "approved" | "rejected" | "needs_edit",
|
||||
"feedback": "Commentaire optionnel..."
|
||||
}
|
||||
|
||||
Comportement selon le status:
|
||||
- "approved" : le workflow est valide, passe en learning_state COACHING
|
||||
- "rejected" : le workflow est marque inactif (is_active=False)
|
||||
- "needs_edit": le workflow reste actif, l'utilisateur peut le modifier dans le VWB
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow_id": "...",
|
||||
"review_status": "approved",
|
||||
"message": "..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouve"
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
status = data.get('status')
|
||||
if status not in ('approved', 'rejected', 'needs_edit'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Le champ 'status' doit etre 'approved', 'rejected' ou 'needs_edit'"
|
||||
}), 400
|
||||
|
||||
feedback = data.get('feedback', '')
|
||||
|
||||
# Mettre a jour le workflow
|
||||
workflow.review_status = status
|
||||
workflow.review_feedback = feedback
|
||||
workflow.reviewed_at = datetime.utcnow()
|
||||
workflow.updated_at = datetime.utcnow()
|
||||
|
||||
message = ''
|
||||
|
||||
if status == 'approved':
|
||||
# Passer le learning_state du workflow core vers COACHING
|
||||
_promote_to_coaching(workflow_id)
|
||||
message = f"Workflow '{workflow.name}' approuve. Le systeme peut maintenant suggerer ce workflow."
|
||||
|
||||
elif status == 'rejected':
|
||||
# Marquer comme inactif
|
||||
workflow.is_active = False
|
||||
message = f"Workflow '{workflow.name}' rejete et desactive."
|
||||
|
||||
elif status == 'needs_edit':
|
||||
# Laisser actif, l'utilisateur peut modifier
|
||||
message = f"Workflow '{workflow.name}' marque pour modification."
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[Review] Workflow {workflow_id} -> {status} (feedback: {feedback[:50]}...)")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow_id': workflow_id,
|
||||
'review_status': status,
|
||||
'message': message,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Erreur submit review: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
def _promote_to_coaching(workflow_id: str):
|
||||
"""
|
||||
Passe le learning_state du workflow core vers COACHING.
|
||||
|
||||
Tente de mettre a jour via le LearningManager si disponible.
|
||||
Fonctionnement gracieux : si le LearningManager n'est pas disponible,
|
||||
on log un warning et on continue.
|
||||
"""
|
||||
try:
|
||||
from services.learning_integration import _get_learning_manager
|
||||
manager = _get_learning_manager()
|
||||
if manager is None:
|
||||
logger.warning(
|
||||
f"[Review] LearningManager non disponible, impossible de promouvoir "
|
||||
f"le workflow {workflow_id} vers COACHING"
|
||||
)
|
||||
return
|
||||
|
||||
# Tenter de changer l'etat
|
||||
try:
|
||||
from core.models.workflow_graph import LearningState
|
||||
manager.set_workflow_state(workflow_id, LearningState.COACHING)
|
||||
logger.info(f"[Review] Workflow {workflow_id} promu vers COACHING")
|
||||
except AttributeError:
|
||||
# set_workflow_state n'existe pas, essayer promote
|
||||
try:
|
||||
manager.promote_workflow(workflow_id)
|
||||
logger.info(f"[Review] Workflow {workflow_id} promu via promote_workflow()")
|
||||
except Exception as e2:
|
||||
logger.warning(f"[Review] Impossible de promouvoir le workflow: {e2}")
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f"[Review] Import learning_integration impossible: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Review] Erreur promotion workflow {workflow_id}: {e}")
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/import-core', methods=['POST'])
|
||||
def import_core_workflow_v3():
|
||||
"""
|
||||
Importe un core Workflow (issu du streaming/GraphBuilder) dans la base v3.
|
||||
|
||||
Convertit via GraphToVisualConverter puis cree un Workflow SQLAlchemy
|
||||
avec source='graph_to_visual_converter' et review_status='pending_review'.
|
||||
|
||||
Body: core Workflow JSON dict (tel que produit par Workflow.to_dict())
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... },
|
||||
"warnings": [...],
|
||||
"message": "..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Request body (core Workflow JSON) est requis"
|
||||
}), 400
|
||||
|
||||
# Ajouter le chemin racine pour les imports core
|
||||
core_path = str(Path(__file__).parent.parent.parent.parent)
|
||||
if core_path not in sys.path:
|
||||
sys.path.insert(0, core_path)
|
||||
|
||||
# Charger le core Workflow
|
||||
from core.models.workflow_graph import Workflow as CoreWorkflow
|
||||
core_wf = CoreWorkflow.from_dict(data)
|
||||
|
||||
# Convertir vers VisualWorkflow (modele riche)
|
||||
from services.graph_to_visual_converter import GraphToVisualConverter
|
||||
converter = GraphToVisualConverter()
|
||||
visual_wf_rich = converter.convert(core_wf)
|
||||
|
||||
# Creer le workflow SQLAlchemy (v3)
|
||||
wf_id = generate_id('wf')
|
||||
workflow = Workflow(
|
||||
id=wf_id,
|
||||
name=visual_wf_rich.name,
|
||||
description=visual_wf_rich.description or 'Workflow importe depuis le streaming',
|
||||
source='graph_to_visual_converter',
|
||||
review_status='pending_review',
|
||||
)
|
||||
|
||||
if visual_wf_rich.tags:
|
||||
workflow.tags = visual_wf_rich.tags
|
||||
|
||||
db.session.add(workflow)
|
||||
|
||||
# Creer les etapes
|
||||
for idx, vnode in enumerate(visual_wf_rich.nodes):
|
||||
# Ignorer les nodes start/end purement structurels
|
||||
if vnode.type in ('start', 'end'):
|
||||
continue
|
||||
|
||||
step = Step(
|
||||
id=generate_id('step'),
|
||||
workflow_id=wf_id,
|
||||
action_type=_visual_type_to_action_type(vnode.type),
|
||||
order=idx,
|
||||
position_x=vnode.position.x,
|
||||
position_y=vnode.position.y,
|
||||
label=vnode.label or vnode.type,
|
||||
)
|
||||
step.parameters = vnode.parameters or {}
|
||||
db.session.add(step)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[Review] Core workflow importe -> {wf_id} "
|
||||
f"({workflow.name}, {len(visual_wf_rich.nodes)} nodes)"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'warnings': converter.warnings,
|
||||
'message': f"Workflow '{workflow.name}' importe et en attente de validation",
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
traceback.print_exc()
|
||||
logger.error(f"[Review] Erreur import core workflow: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
def _visual_type_to_action_type(visual_type: str) -> str:
|
||||
"""Convertit un type visuel VWB vers un action_type v3."""
|
||||
mapping = {
|
||||
'click': 'click_anchor',
|
||||
'type': 'type_text',
|
||||
'wait': 'wait_for_anchor',
|
||||
'navigate': 'click_anchor',
|
||||
'extract': 'extract_text',
|
||||
'variable': 'type_text',
|
||||
'condition': 'visual_condition',
|
||||
'loop': 'loop_visual',
|
||||
'validate': 'keyboard_shortcut',
|
||||
'scroll': 'scroll_to_anchor',
|
||||
'screenshot': 'screenshot_evidence',
|
||||
'transform': 'type_text',
|
||||
'api': 'click_anchor',
|
||||
'database': 'db_save_data',
|
||||
}
|
||||
return mapping.get(visual_type, 'click_anchor')
|
||||
@@ -72,7 +72,9 @@ def get_state():
|
||||
'tags': wf.tags or [],
|
||||
'trigger_examples': wf.trigger_examples or [],
|
||||
'step_count': wf.steps.count(),
|
||||
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None
|
||||
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None,
|
||||
'source': wf.source or 'manual',
|
||||
'review_status': wf.review_status,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
|
||||
Reference in New Issue
Block a user