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

@@ -0,0 +1,43 @@
"""
Source canonique (backend) de l'ensemble des `action_type` qui requièrent une
ancre visuelle — miroir Python du catalogue frontend `needsAnchor: true`.
⚠️ SOURCE DE VÉRITÉ PARTAGÉE — synchroniser avec le frontend :
`visual_workflow_builder/frontend_v4/src/types.ts` (champ `needsAnchor` de
`ACTION_DEFINITIONS`). Toute action ajoutée là avec `needsAnchor: true` doit être
ajoutée ici, et réciproquement. Le test
`tests/unit/test_needs_anchor_canonical.py` verrouille la cohérence (garde-fou
anti-divergence exigé par Dom : pas de liste en dur qui divergera).
Ce set est utilisé par :
- `anchor_image_service.materialize_anchor_from_b64` (indirectement via l'appelant) ;
- `tools/backfill_anchors_r1.py` (filtre `action_type ∈ NEEDS_ANCHOR`).
Auteur : Dom, Claude — 2026-07-03
"""
# Miroir de `types.ts` : toutes les entrées `needsAnchor: true` de ACTION_DEFINITIONS.
NEEDS_ANCHOR: frozenset = frozenset(
{
"click_anchor",
"double_click_anchor",
"right_click_anchor",
"hover_anchor",
"drag_drop_anchor",
"scroll_to_anchor",
"focus_anchor",
"wait_for_anchor",
"extract_table",
"visual_condition",
"loop_visual",
"ai_ocr",
"ai_extract",
"verify_element_exists",
"verify_text_content",
}
)
def needs_anchor(action_type: str) -> bool:
"""True si `action_type` requiert une ancre visuelle (miroir `needsAnchor`)."""
return action_type in NEEDS_ANCHOR

View File

@@ -17,6 +17,7 @@ Structure sur disque :
import os
import json
import base64
import logging
import uuid
import shutil
from datetime import datetime
@@ -25,6 +26,8 @@ from typing import Optional, Dict, Any, Tuple
from PIL import Image
from io import BytesIO
logger = logging.getLogger(__name__)
# Configuration
DATA_DIR = Path(__file__).parent.parent / 'data' / 'anchor_images'
@@ -188,6 +191,103 @@ def save_anchor_image(
raise ValueError(f"Erreur lors de la sauvegarde de l'image: {str(e)}")
def materialize_anchor_from_b64(
step,
params: Dict[str, Any],
*,
db_session,
source: str = "learned_import",
) -> Optional[str]:
"""Matérialise une ancre visuelle à partir de `_anchor_image_base64`.
Helper GÉNÉRIQUE (mandat Dom) : appelable pour TOUT `action_type` qui
requiert une ancre (`needsAnchor` : clic, clic droit, double-clic, survol,
scroll, wait…). Ne présuppose PAS `click_anchor`.
Comportement, si `params` contient une clé `_anchor_image_base64` non vide :
1. retire la clé de `params` (`pop`) → le crop volumineux n'est PAS
re-stocké en double dans `parameters_json` ;
2. décode le b64 → PIL.Image (pour connaître les dimensions du crop) ;
3. `save_anchor_image(...)` écrit `original.png` + `thumbnail.jpg` sur disque ;
4. crée une ligne `VisualAnchor` (bbox = plein crop) + `db_session.add(...)` ;
5. pose `step.anchor_id = anchor_id`.
Ne commit PAS : l'appelant (import R1, route HTTP, backfill) détient la
transaction et committe.
Tolérant : sur crop invalide (b64 corrompu, décodage impossible), log un
avertissement et retourne None sans lever — l'import du reste du workflow
ne doit jamais casser à cause d'une ancre. Dans ce cas `step.anchor_id`
reste NULL (« Ancre requise » légitime, pas un contournement du 100% vision).
Args:
step: instance `Step` (SQLAlchemy) sur laquelle poser `anchor_id`.
params: dict des paramètres du step (mutable — la clé b64 est retirée).
db_session: session SQLAlchemy (transaction gérée par l'appelant).
source: `capture_method` / marqueur de provenance (défaut learned_import).
Returns:
L'`anchor_id` créé, ou None si aucun crop exploitable.
"""
anchor_b64 = params.pop("_anchor_image_base64", None)
if not anchor_b64:
return None
# Import paresseux : évite un import circulaire db.models ↔ services au load.
from db.models import VisualAnchor
try:
clean_b64 = anchor_b64.split(",", 1)[1] if "," in anchor_b64 else anchor_b64
img = Image.open(BytesIO(base64.b64decode(clean_b64)))
width, height = img.width, img.height
anchor_id = generate_anchor_id()
# bbox plein crop : le b64 est DÉJÀ le crop de la zone cible (cf.
# stream_processor crop 80×80), pas un screenshot plein écran.
bbox = {"x": 0, "y": 0, "width": width, "height": height}
result = save_anchor_image(
anchor_id=anchor_id,
image_base64=clean_b64,
bounding_box=bbox,
metadata={"source": source},
)
if not result.get("success"):
logger.warning(
"Matérialisation ancre : save_anchor_image a échoué (step %s)",
getattr(step, "id", "?"),
)
return None
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=width,
bbox_height=height,
description=getattr(step, "label", "") or "",
capture_method=source,
)
db_session.add(va)
step.anchor_id = anchor_id
logger.info(
"Ancre matérialisée : %s pour step %s (%s)",
anchor_id,
getattr(step, "id", "?"),
getattr(step, "action_type", "?"),
)
return anchor_id
except Exception as e: # tolérant : ne jamais casser l'import à cause d'une ancre
logger.warning(
"Échec matérialisation ancre pour step %s : %s",
getattr(step, "id", "?"),
e,
)
return None
def get_thumbnail_path(anchor_id: str) -> Optional[Path]:
"""
Obtenir le chemin du fichier miniature.

View File

@@ -446,9 +446,14 @@ def import_core_workflow_to_db(
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.
# Matérialiser l'ancre visuelle si un crop `_anchor_image_base64` est
# présent : crée le VisualAnchor + fichier PNG et pose step.anchor_id
# (sinon anchor_id reste NULL → « Ancre requise » légitime). Helper
# partagé avec la route HTTP `import_learned_workflow` (source unique).
# `materialize_anchor_from_b64` retire la clé b64 de params (pas de
# double stockage) et ne commit pas (transaction gérée ci-dessous).
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)