feat(anchors): matérialisation ancre partagée à l'import R1 (fix « Ancre requise ») — 2026-07-03

Les steps needsAnchor des workflows importés par le pont R1
(import_core_workflow_to_db) affichaient « Ancre requise » car R1 laissait
_anchor_image_base64 dans params sans créer le VisualAnchor ni poser anchor_id.
La route HTTP import_learned_workflow faisait ce travail mais en dupliquait ~50
lignes inline.

Fix GÉNÉRAL et réutilisable (mandat Dom), pas une rustine click_anchor :
- helper unique services/anchor_image_service.materialize_anchor_from_b64 :
  crée VisualAnchor + fichier PNG + pose step.anchor_id + retire le b64 de params ;
  ne commit pas (transaction gérée par l'appelant) ; tolérant (crop invalide →
  anchor_id NULL, pas de crash) ; générique pour TOUS les action_type needsAnchor.
- branchement dans import_core_workflow_to_db (flux R1) ;
- route HTTP refactorée pour réutiliser le même helper (dé-duplication réelle,
  -48 lignes) → source unique de vérité.
- source canonique needsAnchor côté backend : services/anchor_action_types.py
  (miroir de frontend_v4/src/types.ts, garde-fou anti-divergence testé).
- script tools/backfill_anchors_r1.py (--dry-run par défaut / --apply) : filtre
  source=learned_import ET action_type ∈ NEEDS_ANCHOR ET anchor_id NULL ET crop
  présent ; DB résolue depuis la config runtime VWB (pas en dur) ; dry-run
  affiche le chemin DB résolu + estime PNG/disque ; idempotent. NON exécuté en
  --apply (backup + arbitrage Dom requis avant, jamais la DB clinique DGX).

TDD : tests rouges d'abord puis verts. 20 tests neufs (matérialisation 6 types
d'ancre, b64 retiré, sans-crop→NULL, R1 clic, ré-import idempotent, dé-dup route,
backfill apply/idempotent/dry-run/filtre manual+hors-needsAnchor, canonique). 24
tests unit VERTS, 0 régression (test_import_core_workflow_to_db intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-07-03 10:10:45 +02:00
parent c371c9775f
commit 0a70ac1334
8 changed files with 959 additions and 54 deletions

View File

@@ -27,7 +27,7 @@ from flask import jsonify, request
from . import api_v3_bp
from .workflow import generate_id
from db.models import db, Workflow, Step, VisualAnchor
from db.models import db, Workflow, Step
logger = logging.getLogger(__name__)
@@ -328,56 +328,14 @@ def import_learned_workflow(workflow_id: str):
)
params = dict(step_data.get("parameters", {}))
# Extraire et sauvegarder le screenshot d'ancre si présent
anchor_b64 = params.pop("_anchor_image_base64", None)
# NE PAS supprimer _anchor_bbox : on le conserve dans params pour que le frontend puisse lire x_pct/y_pct
# et afficher la zone ciblée, au lieu de le jeter et de créer une bbox factice.
if anchor_b64:
try:
from services.anchor_image_service import (
save_anchor_image, generate_anchor_id
)
from PIL import Image
from io import BytesIO
import base64 as b64mod
if ',' in anchor_b64:
anchor_b64 = anchor_b64.split(',', 1)[1]
img_data = b64mod.b64decode(anchor_b64)
img = Image.open(BytesIO(img_data))
# Fallback sécurisé pour le service de crop si _anchor_bbox n'a pas le format attendu,
# mais les données x_pct/y_pct restent intactes dans params pour le frontend.
bbox = {
"x": 0, "y": 0,
"width": img.width, "height": img.height
}
anchor_id = generate_anchor_id()
result = save_anchor_image(
anchor_id=anchor_id,
image_base64=anchor_b64,
bounding_box=bbox,
metadata={"source": "learned_import", "workflow_id": wf_id}
)
if result.get("success"):
from services.anchor_image_service import (
get_original_path, get_thumbnail_path
)
va = VisualAnchor(
id=anchor_id,
image_path=str(get_original_path(anchor_id) or ""),
thumbnail_path=str(get_thumbnail_path(anchor_id) or ""),
bbox_x=0, bbox_y=0,
bbox_width=img.width, bbox_height=img.height,
description=step_data.get("label", ""),
capture_method="learned_import",
)
db.session.add(va)
step.anchor_id = anchor_id
logger.info("Ancre sauvegardée: %s pour step %s",
anchor_id, step.id)
except Exception as e:
logger.warning("Échec sauvegarde ancre pour step %s: %s",
step_data.get("order"), e)
# Matérialiser l'ancre visuelle (VisualAnchor + fichier PNG + anchor_id)
# via le helper partagé — MÊME source de vérité que l'import R1
# (`import_core_workflow_to_db`). Le helper retire `_anchor_image_base64`
# de params (pas de double stockage) et ne commit pas.
# NE PAS supprimer _anchor_bbox : conservé dans params pour que le
# frontend lise x_pct/y_pct et affiche la zone ciblée.
from services.anchor_image_service import materialize_anchor_from_b64
materialize_anchor_from_b64(step, params, db_session=db.session)
step.parameters = params
db.session.add(step)