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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user