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:
Dom
2026-03-15 10:02:09 +01:00
parent 74a1cb4e03
commit cf495dd82f
93 changed files with 12463 additions and 1080 deletions

View File

@@ -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']

View 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')

View File

@@ -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({