feat(vwb-v3): Architecture Thin Client fonctionnelle
API = Source de vérité unique (SQLite + Flask) - Backend: API v3 avec session, workflow, capture, execute - Frontend: Vanilla TypeScript, pas de state local - Contrats stricts pour les actions RPA - Drag & drop pour réorganiser les étapes - Insertion d'étapes entre deux existantes - Bibliothèque de captures (sessionStorage) - Exécution avec coordonnées statiques (pyautogui) Fonctionne mais fragile (coordonnées fixes, pas de détection visuelle) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
306
visual_workflow_builder/backend/db/models.py
Normal file
306
visual_workflow_builder/backend/db/models.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Modèles SQLAlchemy - RPA Vision v3
|
||||
Source de vérité unique pour les workflows VWB
|
||||
|
||||
Auteur: Dom, Alice, Kiro - 23 janvier 2026
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
import json
|
||||
|
||||
# Instance SQLAlchemy partagée
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class Workflow(db.Model):
|
||||
"""Workflow VWB - Conteneur d'étapes ordonnées"""
|
||||
__tablename__ = 'workflows'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Relations
|
||||
steps = db.relationship('Step', backref='workflow', lazy='dynamic',
|
||||
order_by='Step.order', cascade='all, delete-orphan')
|
||||
executions = db.relationship('Execution', backref='workflow', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise le workflow complet"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'steps': [step.to_dict() for step in self.steps.order_by(Step.order).all()],
|
||||
'step_count': self.steps.count()
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Workflow {self.id}: {self.name}>'
|
||||
|
||||
|
||||
class Step(db.Model):
|
||||
"""Étape d'un workflow - Action VWB avec type et paramètres"""
|
||||
__tablename__ = 'steps'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
workflow_id = db.Column(db.String(64), db.ForeignKey('workflows.id'), nullable=False)
|
||||
|
||||
# Type d'action - SOURCE DE VÉRITÉ UNIQUE
|
||||
action_type = db.Column(db.String(64), nullable=False)
|
||||
|
||||
# Ordre dans le workflow (0-indexed)
|
||||
order = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
# Position sur le canvas (pour l'affichage)
|
||||
position_x = db.Column(db.Float, default=0)
|
||||
position_y = db.Column(db.Float, default=0)
|
||||
|
||||
# Paramètres de l'action (JSON)
|
||||
parameters_json = db.Column(db.Text, default='{}')
|
||||
|
||||
# Référence vers l'ancre visuelle (si applicable)
|
||||
anchor_id = db.Column(db.String(64), db.ForeignKey('visual_anchors.id'), nullable=True)
|
||||
|
||||
# Métadonnées
|
||||
label = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
anchor = db.relationship('VisualAnchor', backref='steps')
|
||||
|
||||
@property
|
||||
def parameters(self) -> Dict[str, Any]:
|
||||
"""Retourne les paramètres comme dict"""
|
||||
try:
|
||||
return json.loads(self.parameters_json) if self.parameters_json else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
@parameters.setter
|
||||
def parameters(self, value: Dict[str, Any]):
|
||||
"""Stocke les paramètres comme JSON"""
|
||||
self.parameters_json = json.dumps(value) if value else '{}'
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise l'étape"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'workflow_id': self.workflow_id,
|
||||
'action_type': self.action_type, # SOURCE DE VÉRITÉ
|
||||
'order': self.order,
|
||||
'position': {'x': self.position_x, 'y': self.position_y},
|
||||
'parameters': self.parameters,
|
||||
'anchor_id': self.anchor_id,
|
||||
'anchor': self.anchor.to_dict() if self.anchor else None,
|
||||
'label': self.label or self.action_type,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Step {self.id}: {self.action_type} (order={self.order})>'
|
||||
|
||||
|
||||
class VisualAnchor(db.Model):
|
||||
"""Ancre visuelle - Image de référence pour localiser un élément UI"""
|
||||
__tablename__ = 'visual_anchors'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
|
||||
# Image de référence (chemin fichier, pas base64 en DB)
|
||||
image_path = db.Column(db.String(512), nullable=True)
|
||||
thumbnail_path = db.Column(db.String(512), nullable=True)
|
||||
|
||||
# Bounding box de la sélection
|
||||
bbox_x = db.Column(db.Float, nullable=True)
|
||||
bbox_y = db.Column(db.Float, nullable=True)
|
||||
bbox_width = db.Column(db.Float, nullable=True)
|
||||
bbox_height = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Résolution de l'écran lors de la capture
|
||||
screen_width = db.Column(db.Integer, nullable=True)
|
||||
screen_height = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Description pour l'utilisateur
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Seuil de confiance pour la détection
|
||||
confidence_threshold = db.Column(db.Float, default=0.8)
|
||||
|
||||
# Métadonnées
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
capture_method = db.Column(db.String(64), default='screen_capture')
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise l'ancre visuelle"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'image_url': f'/api/v3/anchor/{self.id}/image' if self.image_path else None,
|
||||
'thumbnail_url': f'/api/v3/anchor/{self.id}/thumbnail' if self.thumbnail_path else None,
|
||||
'bounding_box': {
|
||||
'x': self.bbox_x,
|
||||
'y': self.bbox_y,
|
||||
'width': self.bbox_width,
|
||||
'height': self.bbox_height
|
||||
} if self.bbox_x is not None else None,
|
||||
'screen_resolution': {
|
||||
'width': self.screen_width,
|
||||
'height': self.screen_height
|
||||
} if self.screen_width else None,
|
||||
'description': self.description,
|
||||
'confidence_threshold': self.confidence_threshold,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<VisualAnchor {self.id}>'
|
||||
|
||||
|
||||
class Execution(db.Model):
|
||||
"""Exécution d'un workflow - Historique et état"""
|
||||
__tablename__ = 'executions'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
workflow_id = db.Column(db.String(64), db.ForeignKey('workflows.id'), nullable=False)
|
||||
|
||||
# État de l'exécution
|
||||
status = db.Column(db.String(32), default='pending') # pending, running, paused, completed, error, cancelled
|
||||
|
||||
# Timestamps
|
||||
started_at = db.Column(db.DateTime, nullable=True)
|
||||
ended_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Progression
|
||||
current_step_index = db.Column(db.Integer, default=0)
|
||||
total_steps = db.Column(db.Integer, default=0)
|
||||
|
||||
# Résumé
|
||||
completed_steps = db.Column(db.Integer, default=0)
|
||||
failed_steps = db.Column(db.Integer, default=0)
|
||||
|
||||
# Erreur globale (si applicable)
|
||||
error_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relations
|
||||
step_results = db.relationship('ExecutionStep', backref='execution', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise l'exécution"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'workflow_id': self.workflow_id,
|
||||
'status': self.status,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'ended_at': self.ended_at.isoformat() if self.ended_at else None,
|
||||
'current_step_index': self.current_step_index,
|
||||
'total_steps': self.total_steps,
|
||||
'completed_steps': self.completed_steps,
|
||||
'failed_steps': self.failed_steps,
|
||||
'error_message': self.error_message,
|
||||
'progress': (self.current_step_index / self.total_steps * 100) if self.total_steps > 0 else 0,
|
||||
'step_results': [sr.to_dict() for sr in self.step_results.all()]
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Execution {self.id}: {self.status}>'
|
||||
|
||||
|
||||
class ExecutionStep(db.Model):
|
||||
"""Résultat d'exécution d'une étape"""
|
||||
__tablename__ = 'execution_steps'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
execution_id = db.Column(db.String(64), db.ForeignKey('executions.id'), nullable=False)
|
||||
step_id = db.Column(db.String(64), nullable=False)
|
||||
|
||||
# Résultat
|
||||
status = db.Column(db.String(32), default='pending') # pending, running, success, error, skipped
|
||||
|
||||
# Timestamps
|
||||
started_at = db.Column(db.DateTime, nullable=True)
|
||||
ended_at = db.Column(db.DateTime, nullable=True)
|
||||
duration_ms = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Erreur (si applicable)
|
||||
error_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Evidence (chemin vers screenshot)
|
||||
evidence_path = db.Column(db.String(512), nullable=True)
|
||||
|
||||
# Output data (JSON)
|
||||
output_json = db.Column(db.Text, default='{}')
|
||||
|
||||
@property
|
||||
def output(self) -> Dict[str, Any]:
|
||||
try:
|
||||
return json.loads(self.output_json) if self.output_json else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
@output.setter
|
||||
def output(self, value: Dict[str, Any]):
|
||||
self.output_json = json.dumps(value) if value else '{}'
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'step_id': self.step_id,
|
||||
'status': self.status,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'ended_at': self.ended_at.isoformat() if self.ended_at else None,
|
||||
'duration_ms': self.duration_ms,
|
||||
'error_message': self.error_message,
|
||||
'evidence_url': f'/api/v3/evidence/{self.id}' if self.evidence_path else None,
|
||||
'output': self.output
|
||||
}
|
||||
|
||||
|
||||
# Session active (en mémoire, pas en DB)
|
||||
class SessionState:
|
||||
"""État de la session utilisateur (en mémoire)"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_workflow_id: Optional[str] = None
|
||||
self.selected_step_id: Optional[str] = None
|
||||
self.active_execution_id: Optional[str] = None
|
||||
self.last_capture: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'active_workflow_id': self.active_workflow_id,
|
||||
'selected_step_id': self.selected_step_id,
|
||||
'active_execution_id': self.active_execution_id,
|
||||
'has_capture': self.last_capture is not None
|
||||
}
|
||||
|
||||
|
||||
# Instance globale de session
|
||||
_session_state = SessionState()
|
||||
|
||||
|
||||
def get_session_state() -> SessionState:
|
||||
"""Retourne l'état de session global"""
|
||||
return _session_state
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""Initialise la base de données avec l'application Flask"""
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print("✅ [DB] Base de données SQLite initialisée")
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Retourne la session DB actuelle"""
|
||||
return db.session
|
||||
Reference in New Issue
Block a user