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:
Dom
2026-07-02 13:01:33 +02:00
parent 9a8242add5
commit ebed4d7546
3 changed files with 395 additions and 0 deletions

View File

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