feat(vwb): pont R1 import idempotent core→DB par signature trajectoire
Add import_core_workflow_to_db() — create-or-update par signature de trajectoire (décision produit Dom 23/06). Les workflows source='manual' sont exclus du filtre de fusion. Inclut test TDD idempotent (ré-import 2× → toujours 1 seul workflow).
This commit is contained in:
@@ -295,6 +295,175 @@ def convert_learned_to_vwb_steps(
|
||||
return workflow_meta, steps, warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pont R1 — import IDEMPOTENT d'un workflow core en DB VWB (create-or-update)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Marqueur stable de signature de trajectoire embarqué dans `Workflow.description`.
|
||||
# Le modèle `Workflow` n'a PAS (encore) de colonne dédiée ; on réutilise donc le
|
||||
# même mécanisme que la route GET /learned-workflows existante, qui détecte les
|
||||
# imports via `description.contains(...)`. La clé d'idempotence est la SIGNATURE
|
||||
# DE TRAJECTOIRE (cf. core.execution.trajectory_signature), pas le workflow_id de
|
||||
# session (qui change à chaque ré-apprentissage du même parcours).
|
||||
_TRAJ_SIG_MARKER = "[traj_sig:"
|
||||
|
||||
|
||||
def _trajectory_signature_marker(signature: str) -> str:
|
||||
"""Marqueur texte stable à embarquer dans la description."""
|
||||
return f"{_TRAJ_SIG_MARKER}{signature}]"
|
||||
|
||||
|
||||
def _find_existing_learned_workflow(db_session, signature: str):
|
||||
"""Cherche un Workflow `source='learned_import'` de MÊME signature de trajectoire.
|
||||
|
||||
Ne considère QUE les imports appris : les workflows `source='manual'`
|
||||
(démo Urgence_aiva, etc.) sont volontairement exclus du filtre et donc
|
||||
jamais candidats à la mise à jour.
|
||||
"""
|
||||
from db.models import Workflow # import paresseux (modèles liés au runtime VWB)
|
||||
|
||||
marker = _trajectory_signature_marker(signature)
|
||||
return (
|
||||
db_session.query(Workflow)
|
||||
.filter(
|
||||
Workflow.source == "learned_import",
|
||||
Workflow.description.contains(marker),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def import_core_workflow_to_db(
|
||||
core_dict: Dict[str, Any],
|
||||
*,
|
||||
machine_id: str,
|
||||
source_session_id: str,
|
||||
db_session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Importe un workflow core (JSON appris par Léa) en DB VWB, de façon IDEMPOTENTE.
|
||||
|
||||
Fusion par **signature de trajectoire** (décision produit Dom 23/06) :
|
||||
1. calcule `sig = workflow_trajectory_signature(core_dict)` ;
|
||||
2. cherche un `Workflow` `source='learned_import'` de même signature ;
|
||||
3. si trouvé → **skip** (pas de doublon, le workflow existant fait foi) ;
|
||||
sinon → crée `Workflow` + `Step`(s) via `convert_learned_to_vwb_steps`.
|
||||
|
||||
Le nouveau workflow est marqué `source='learned_import'`,
|
||||
`review_status='pending_review'`. Les workflows `source='manual'` ne sont
|
||||
JAMAIS touchés (cf. `_find_existing_learned_workflow`).
|
||||
|
||||
Args:
|
||||
core_dict: workflow core (dict JSON) tel qu'appris/sauvegardé.
|
||||
machine_id: poste d'origine (traçabilité, stocké en tag/description).
|
||||
source_session_id: session ayant produit ce workflow (traçabilité).
|
||||
db_session: session SQLAlchemy (l'app appelante détient le contexte).
|
||||
|
||||
Returns:
|
||||
dict {created: bool, workflow_id: str, signature: str, warnings: list}.
|
||||
`created=False` quand un workflow de même trajectoire existait déjà.
|
||||
|
||||
Note (non-wiring) : cette unité n'est PAS branchée au worker live ni à la
|
||||
route HTTP existante ; voir le rapport de câblage R1.
|
||||
"""
|
||||
# Imports paresseux : garde le module léger et évite un import core/DB au load.
|
||||
from core.execution.trajectory_signature import workflow_trajectory_signature
|
||||
from db.models import Workflow, Step
|
||||
|
||||
signature = workflow_trajectory_signature(core_dict)
|
||||
|
||||
# --- Idempotence : même trajectoire déjà importée ? → skip (pas de doublon) ---
|
||||
existing = _find_existing_learned_workflow(db_session, signature)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"Workflow appris déjà présent (signature %s…) → import ignoré, "
|
||||
"réutilisation de %s",
|
||||
signature[:12],
|
||||
existing.id,
|
||||
)
|
||||
return {
|
||||
"created": False,
|
||||
"workflow_id": existing.id,
|
||||
"signature": signature,
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
# --- Création : conversion core → steps VWB, puis écriture DB ---
|
||||
wf_meta, steps_list, warnings = convert_learned_to_vwb_steps(core_dict)
|
||||
|
||||
current_name = (wf_meta.get("name") or "").strip()
|
||||
if current_name.lower() in {"", "unnamed workflow", "workflow importé"}:
|
||||
# Réutilise la dérivation de nom de la route HTTP si disponible.
|
||||
try:
|
||||
from api_v3.learned_workflows import _derive_default_name
|
||||
wf_meta["name"] = _derive_default_name(core_dict)
|
||||
except Exception: # pragma: no cover - fallback minimal
|
||||
wf_meta["name"] = f"Léa import — {datetime.now():%Y-%m-%d %H:%M}"
|
||||
|
||||
wf_id = f"wf_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# La signature est embarquée dans la description (clé d'idempotence) + une
|
||||
# ligne de traçabilité (workflow core d'origine).
|
||||
base_desc = (wf_meta.get("description") or "").strip()
|
||||
description = "\n\n".join(
|
||||
part
|
||||
for part in (
|
||||
base_desc,
|
||||
f"[Importé depuis workflow appris: {core_dict.get('workflow_id', '')}]",
|
||||
_trajectory_signature_marker(signature),
|
||||
)
|
||||
if part
|
||||
)
|
||||
|
||||
workflow = Workflow(
|
||||
id=wf_id,
|
||||
name=wf_meta["name"],
|
||||
description=description,
|
||||
source="learned_import",
|
||||
review_status="pending_review",
|
||||
)
|
||||
|
||||
# Tags : conserver ceux du workflow + traçabilité machine/session.
|
||||
tags = list(wf_meta.get("tags") or [])
|
||||
tags.extend([f"machine:{machine_id}", f"session:{source_session_id}"])
|
||||
workflow.tags = tags
|
||||
|
||||
db_session.add(workflow)
|
||||
|
||||
for step_data in steps_list:
|
||||
step = Step(
|
||||
id=f"step_{uuid.uuid4().hex[:12]}",
|
||||
workflow_id=wf_id,
|
||||
action_type=step_data["action_type"],
|
||||
order=step_data["order"],
|
||||
position_x=step_data.get("position_x", 0),
|
||||
position_y=step_data.get("position_y", 0),
|
||||
label=step_data.get("label", step_data["action_type"]),
|
||||
)
|
||||
params = dict(step_data.get("parameters", {}))
|
||||
# L'image d'ancre (_anchor_image_base64) est laissée dans params : la
|
||||
# persistance d'ancre (VisualAnchor + fichier) reste pilotée par la route
|
||||
# HTTP existante. Cette unité se concentre sur l'idempotence Workflow/Step.
|
||||
step.parameters = params
|
||||
db_session.add(step)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
logger.info(
|
||||
"Workflow appris importé (R1) : %s (signature %s…, %d étapes, machine %s)",
|
||||
wf_id,
|
||||
signature[:12],
|
||||
len(steps_list),
|
||||
machine_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"created": True,
|
||||
"workflow_id": wf_id,
|
||||
"signature": signature,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _convert_compound_substep(
|
||||
sub_type: str, sub: Dict[str, Any], parent_target: Dict[str, Any]
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
|
||||
Reference in New Issue
Block a user