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)

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)

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""
Test TDD — matérialisation des ancres visuelles à l'import R1.
Contexte (diagnostic 2026-07-03, verdict H1) : le pont R1
`import_core_workflow_to_db` importe les steps clic mais LAISSE le crop
`_anchor_image_base64` dans `Step.parameters` sans créer le `VisualAnchor`
ni poser `Step.anchor_id` → le VWB affiche « Ancre requise » alors que
l'image est là. La route HTTP `import_learned_workflow` fait déjà ce travail
mais en duplique la logique inline.
Mandat de généralisation (Dom) : le fix doit être GÉNÉRAL — un helper unique
`materialize_anchor_from_b64` couvrant TOUS les `action_type ∈ needsAnchor`
(clic, clic droit, double-clic, survol, scroll…), réutilisé par R1 ET par la
route HTTP. Jamais une rustine `click_anchor` seul.
Ces tests sont volontairement isolés du chemin live (app Flask minimale +
SQLite en mémoire, même pattern que `test_import_core_workflow_to_db.py`).
Les fichiers PNG sont écrits dans un `DATA_DIR` temporaire (monkeypatch).
"""
import base64
import sys
from io import BytesIO
from pathlib import Path
import pytest
from flask import Flask
from PIL import Image
# --- Chemins : racine projet (pour core.*) + backend (pour db.models, services.*) ---
_BACKEND = Path(__file__).resolve().parent.parent.parent # .../visual_workflow_builder/backend
_ROOT = _BACKEND.parent.parent # .../rpa_vision_v3
for p in (str(_ROOT), str(_BACKEND)):
if p not in sys.path:
sys.path.insert(0, p)
from db.models import db, Workflow, Step, VisualAnchor # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures : DB en mémoire + DATA_DIR temporaire pour les PNG d'ancre
# ---------------------------------------------------------------------------
@pytest.fixture
def db_app():
"""App Flask minimale liée à une SQLite en mémoire, schéma créé."""
app = Flask("test_anchor_materialization")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def anchor_data_dir(tmp_path, monkeypatch):
"""Redirige le stockage des images d'ancre vers un répertoire temporaire."""
import services.anchor_image_service as ais
tmp_anchor_dir = tmp_path / "anchor_images"
monkeypatch.setattr(ais, "DATA_DIR", tmp_anchor_dir)
return tmp_anchor_dir
def _png_b64(width: int = 12, height: int = 8, color=(200, 30, 30)) -> str:
"""Génère un petit PNG valide encodé base64 (crop d'ancre factice)."""
img = Image.new("RGB", (width, height), color)
buf = BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
# ---------------------------------------------------------------------------
# 1. Le helper matérialise l'ancre pour PLUSIEURS types (pas que clic)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"action_type",
[
"click_anchor",
"double_click_anchor",
"right_click_anchor",
"hover_anchor",
"scroll_to_anchor",
"wait_for_anchor",
],
)
def test_materialize_anchor_for_all_needs_anchor_types(
db_app, anchor_data_dir, action_type
):
"""Le helper crée un VisualAnchor + fichier + pose anchor_id pour TOUS
les action_type qui requièrent une ancre — pas seulement click_anchor."""
from services.anchor_image_service import materialize_anchor_from_b64
with db_app.app_context():
wf = Workflow(id="wf_x", name="wf", source="learned_import")
db.session.add(wf)
step = Step(
id="step_x",
workflow_id="wf_x",
action_type=action_type,
order=0,
label="cible",
)
db.session.add(step)
params = {"_anchor_image_base64": _png_b64(), "x_pct": 0.5, "y_pct": 0.4}
anchor_id = materialize_anchor_from_b64(
step, params, db_session=db.session
)
db.session.commit()
assert anchor_id, f"{action_type} : le helper doit retourner un anchor_id"
assert step.anchor_id == anchor_id, "anchor_id posé sur le step"
va = VisualAnchor.query.get(anchor_id)
assert va is not None, "ligne VisualAnchor créée"
assert va.image_path and Path(va.image_path).exists(), "fichier PNG écrit"
# ---------------------------------------------------------------------------
# 2. Le b64 est retiré des params après matérialisation
# ---------------------------------------------------------------------------
def test_b64_removed_from_params_after_materialization(db_app, anchor_data_dir):
"""Après matérialisation, `_anchor_image_base64` n'est plus dans params
(évite le double stockage du crop volumineux en DB)."""
from services.anchor_image_service import materialize_anchor_from_b64
with db_app.app_context():
wf = Workflow(id="wf_y", name="wf", source="learned_import")
db.session.add(wf)
step = Step(id="step_y", workflow_id="wf_y",
action_type="click_anchor", order=0)
db.session.add(step)
params = {"_anchor_image_base64": _png_b64(), "target_text": "OK"}
materialize_anchor_from_b64(step, params, db_session=db.session)
assert "_anchor_image_base64" not in params, "b64 retiré de params"
assert params.get("target_text") == "OK", "autres params préservés"
# ---------------------------------------------------------------------------
# 3. Pas de crop → anchor_id reste NULL (« Ancre requise » légitime)
# ---------------------------------------------------------------------------
def test_no_crop_leaves_anchor_id_null(db_app, anchor_data_dir):
"""Sans crop b64, le helper ne crée rien et retourne None : anchor_id
reste NULL. Comportement correct — pas un contournement du 100% vision."""
from services.anchor_image_service import materialize_anchor_from_b64
with db_app.app_context():
wf = Workflow(id="wf_z", name="wf", source="learned_import")
db.session.add(wf)
step = Step(id="step_z", workflow_id="wf_z",
action_type="click_anchor", order=0)
db.session.add(step)
params = {"target_text": "sans crop"}
anchor_id = materialize_anchor_from_b64(
step, params, db_session=db.session
)
assert anchor_id is None
assert step.anchor_id is None
assert VisualAnchor.query.count() == 0
# ---------------------------------------------------------------------------
# 4. Import R1 : matérialise l'ancre pour un step clic (le bug d'aujourd'hui)
# ---------------------------------------------------------------------------
def test_import_r1_materializes_anchor_for_click_step(db_app, anchor_data_dir):
"""Un core workflow avec un edge mouse_click portant anchor_image_base64
→ import_core_workflow_to_db pose anchor_id + crée le VisualAnchor + fichier.
C'EST le test qui échoue aujourd'hui (R1 laisse anchor_id NULL)."""
from services.learned_workflow_bridge import import_core_workflow_to_db
core = {
"workflow_id": "wf_sess_click_anchor_001",
"name": "Léa Clic Ancré",
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1", "name": "A"}, {"node_id": "n2", "name": "B"}],
"edges": [
{
"edge_id": "e1",
"from_node": "n1",
"to_node": "n2",
"action": {
"type": "mouse_click",
"target": {
"by_text": "Valider",
"by_role": "button",
"anchor_image_base64": _png_b64(),
},
"parameters": {"button": "left"},
},
},
],
}
with db_app.app_context():
result = import_core_workflow_to_db(
core,
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_click_anchor_001",
db_session=db.session,
)
assert result["created"] is True
wf_id = result["workflow_id"]
click_steps = [
s for s in Step.query.filter_by(workflow_id=wf_id).all()
if s.action_type in ("click_anchor", "double_click_anchor",
"right_click_anchor")
]
assert click_steps, "au moins un step clic importé"
click = click_steps[0]
assert click.anchor_id is not None, "R1 doit poser anchor_id (bug corrigé)"
va = VisualAnchor.query.get(click.anchor_id)
assert va is not None and Path(va.image_path).exists()
# b64 retiré des params persistés
assert "_anchor_image_base64" not in click.parameters
def test_import_r1_click_without_crop_stays_anchor_null(db_app, anchor_data_dir):
"""Un edge mouse_click SANS crop → anchor_id reste NULL (légitime)."""
from services.learned_workflow_bridge import import_core_workflow_to_db
core = {
"workflow_id": "wf_sess_noimg_002",
"name": "Léa Clic Sans Image",
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1", "name": "A"}, {"node_id": "n2", "name": "B"}],
"edges": [
{
"edge_id": "e1",
"from_node": "n1",
"to_node": "n2",
"action": {
"type": "mouse_click",
"target": {"by_text": "Fichier", "by_role": "menu"},
"parameters": {"button": "left"},
},
},
],
}
with db_app.app_context():
result = import_core_workflow_to_db(
core,
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_noimg_002",
db_session=db.session,
)
wf_id = result["workflow_id"]
steps = Step.query.filter_by(workflow_id=wf_id).all()
click = [s for s in steps if s.action_type == "click_anchor"][0]
assert click.anchor_id is None
assert VisualAnchor.query.count() == 0
# ---------------------------------------------------------------------------
# 5. Ré-import même signature = idempotent (une seule ancre)
# ---------------------------------------------------------------------------
def test_reimport_idempotent_preserves_single_anchor(db_app, anchor_data_dir):
"""Ré-importer 2× le même core (create-or-skip) → 1 seul workflow ET
pas d'ancre dupliquée."""
from services.learned_workflow_bridge import import_core_workflow_to_db
def _core():
return {
"workflow_id": "wf_sess_idem_003",
"name": "Léa Idempotent",
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1", "name": "A"},
{"node_id": "n2", "name": "B"}],
"edges": [
{
"edge_id": "e1",
"from_node": "n1",
"to_node": "n2",
"action": {
"type": "mouse_click",
"target": {
"by_text": "Ouvrir",
"anchor_image_base64": _png_b64(),
},
"parameters": {"button": "left"},
},
},
],
}
with db_app.app_context():
import_core_workflow_to_db(
_core(), machine_id="M", source_session_id="s1",
db_session=db.session,
)
anchors_after_first = VisualAnchor.query.count()
second = import_core_workflow_to_db(
_core(), machine_id="M", source_session_id="s1_rerun",
db_session=db.session,
)
assert Workflow.query.count() == 1, "pas de doublon workflow"
assert second["created"] is False
assert VisualAnchor.query.count() == anchors_after_first, \
"pas d'ancre dupliquée au ré-import (skip)"
# ---------------------------------------------------------------------------
# 6. Non-régression route HTTP : la route et R1 partagent le MÊME helper
# ---------------------------------------------------------------------------
def test_http_route_delegates_to_shared_helper(db_app, anchor_data_dir):
"""La route `import_learned_workflow` a été refactorée pour appeler le
helper partagé (dé-duplication réelle). On vérifie ici que le chemin
per-step de la route matérialise l'ancre à l'identique — sans booter l'app
Flask complète. On reproduit la boucle de la route (build Step + helper),
qui doit produire un anchor_id + VisualAnchor comme l'import R1."""
from services.anchor_image_service import materialize_anchor_from_b64
from api_v3 import learned_workflows as route_mod
# Garde-fou dé-duplication : la route n'a plus de logique d'ancre inline.
src = Path(route_mod.__file__).read_text(encoding="utf-8")
assert "materialize_anchor_from_b64" in src
assert "save_anchor_image(" not in src, "logique inline non supprimée"
assert "VisualAnchor(" not in src, "construction VisualAnchor inline restante"
# Chemin per-step de la route (identique à R1 via le helper partagé).
with db_app.app_context():
wf = Workflow(id="wf_http", name="via route", source="learned_import")
db.session.add(wf)
step = Step(id="step_http", workflow_id="wf_http",
action_type="right_click_anchor", order=0, label="Menu")
db.session.add(step)
params = {"_anchor_image_base64": _png_b64(), "target_role": "menu"}
materialize_anchor_from_b64(step, params, db_session=db.session)
step.parameters = params
db.session.commit()
assert step.anchor_id is not None
assert VisualAnchor.query.get(step.anchor_id) is not None
assert "_anchor_image_base64" not in step.parameters

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
Test TDD — script de backfill des ancres R1 (`tools/backfill_anchors_r1.py`).
Vérifie :
* matérialisation d'un step réparable (learned_import, click_anchor, anchor_id
NULL, crop présent) → VisualAnchor + anchor_id posé + b64 retiré des params ;
* idempotence : 2e `--apply` = no-op (aucune ancre supplémentaire) ;
* filtre dur : un workflow `source='manual'` avec crop n'est JAMAIS touché ;
* un step d'un action_type HORS NEEDS_ANCHOR (type_text) n'est pas matérialisé.
On utilise une DB SQLite sur FICHIER temporaire (pas :memory:) pour exercer la
vraie résolution de chemin et un ré-attachement propre entre exécutions.
"""
import base64
import importlib
import sys
from io import BytesIO
from pathlib import Path
import pytest
from flask import Flask
from PIL import Image
_BACKEND = Path(__file__).resolve().parent.parent.parent
_ROOT = _BACKEND.parent.parent
for p in (str(_ROOT), str(_BACKEND)):
if p not in sys.path:
sys.path.insert(0, p)
from db.models import db, Workflow, Step, VisualAnchor # noqa: E402
def _png_b64(w=10, h=6, color=(10, 120, 200)) -> str:
img = Image.new("RGB", (w, h), color)
buf = BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
@pytest.fixture
def backfill_module(tmp_path, monkeypatch):
"""Charge le module backfill et redirige le stockage PNG en temp."""
import importlib.util
script = _ROOT / "tools" / "backfill_anchors_r1.py"
spec = importlib.util.spec_from_file_location("backfill_anchors_r1", script)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
import services.anchor_image_service as ais
monkeypatch.setattr(ais, "DATA_DIR", tmp_path / "anchor_images")
return mod
@pytest.fixture
def seeded_db(tmp_path):
"""Crée une DB SQLite fichier avec 1 step réparable + 1 manual + 1 type_text."""
db_path = tmp_path / "workflows.db"
app = Flask("seed")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
with app.app_context():
db.create_all()
# Réparable : learned_import + click_anchor + anchor_id NULL + crop.
wf = Workflow(id="wf_learned", name="Léa parcours",
source="learned_import")
db.session.add(wf)
s_click = Step(id="s_click", workflow_id="wf_learned",
action_type="click_anchor", order=0, label="Bouton")
s_click.parameters = {"_anchor_image_base64": _png_b64(), "x_pct": 0.3}
db.session.add(s_click)
# HORS NEEDS_ANCHOR : type_text avec un crop (bruit) → non réparable.
s_text = Step(id="s_text", workflow_id="wf_learned",
action_type="type_text", order=1)
s_text.parameters = {"_anchor_image_base64": _png_b64(), "text": "x"}
db.session.add(s_text)
# Manual avec crop → intouchable.
wf_m = Workflow(id="wf_manual", name="Démo manuelle", source="manual")
db.session.add(wf_m)
s_m = Step(id="s_manual", workflow_id="wf_manual",
action_type="click_anchor", order=0)
s_m.parameters = {"_anchor_image_base64": _png_b64()}
db.session.add(s_m)
db.session.commit()
db.session.remove()
# Détacher le db de cette app pour permettre au backfill de le ré-attacher.
return db_path
def _count_anchors(db_path):
app = Flask("count")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
with app.app_context():
n = VisualAnchor.query.count()
click = Step.query.get("s_click")
manual = Step.query.get("s_manual")
text = Step.query.get("s_text")
info = {
"anchors": n,
"click_anchor_id": click.anchor_id,
"manual_anchor_id": manual.anchor_id,
"text_anchor_id": text.anchor_id,
"click_has_b64": "_anchor_image_base64" in click.parameters,
}
db.session.remove()
return info
def test_backfill_apply_materializes_repairable_only(backfill_module, seeded_db):
"""--apply matérialise le step réparable, laisse manual et type_text intacts."""
backfill_module.run(seeded_db, apply=True)
info = _count_anchors(seeded_db)
assert info["anchors"] == 1, "une seule ancre créée (le click réparable)"
assert info["click_anchor_id"] is not None, "anchor_id posé sur le click"
assert info["manual_anchor_id"] is None, "workflow manual jamais touché"
assert info["text_anchor_id"] is None, "type_text (hors NEEDS_ANCHOR) ignoré"
assert info["click_has_b64"] is False, "b64 retiré des params après backfill"
def test_backfill_is_idempotent(backfill_module, seeded_db):
"""2e --apply = no-op : aucune ancre supplémentaire, anchor_id inchangé."""
backfill_module.run(seeded_db, apply=True)
first = _count_anchors(seeded_db)
backfill_module.run(seeded_db, apply=True)
second = _count_anchors(seeded_db)
assert second["anchors"] == first["anchors"] == 1, "pas d'ancre en double"
assert second["click_anchor_id"] == first["click_anchor_id"], \
"anchor_id inchangé au 2e passage"
def test_backfill_dry_run_writes_nothing(backfill_module, seeded_db):
"""--dry-run (apply=False) ne crée aucune ancre ni ne modifie les params."""
backfill_module.run(seeded_db, apply=False)
info = _count_anchors(seeded_db)
assert info["anchors"] == 0, "dry-run n'écrit aucune ancre"
assert info["click_anchor_id"] is None
assert info["click_has_b64"] is True, "params intacts en dry-run"
def test_resolve_db_path_uses_config_not_hardcoded(backfill_module, monkeypatch):
"""Réserve R1 : la cible DB vient de la config runtime (env DATABASE_URL /
instance_path Flask), pas d'un chemin codé en dur."""
# Défaut : résolution via instance_path du backend VWB.
default_path = backfill_module.resolve_vwb_db_path(None)
assert default_path.name == "workflows.db"
assert "instance" in str(default_path), \
"défaut résolu via Flask instance_path du backend"
# Override explicite honoré.
explicit = backfill_module.resolve_vwb_db_path("/tmp/custom_vwb.db")
assert str(explicit) == "/tmp/custom_vwb.db"
# DATABASE_URL env sqlite absolu honoré.
monkeypatch.setenv("DATABASE_URL", "sqlite:////var/data/wf.db")
env_path = backfill_module.resolve_vwb_db_path(None)
assert str(env_path) == "/var/data/wf.db"

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Garde-fou anti-divergence : l'ensemble Python `NEEDS_ANCHOR`
(`services/anchor_action_types.py`) doit rester synchronisé avec la source
canonique frontend `frontend_v4/src/types.ts` (entrées `needsAnchor: true`).
Exigence Dom : le set `needsAnchor` vient d'une source canonique, pas d'une
liste en dur qui divergera. Ce test lit `types.ts` et compare.
"""
import re
import sys
from pathlib import Path
_BACKEND = Path(__file__).resolve().parent.parent.parent
_VWB = _BACKEND.parent # .../visual_workflow_builder
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
from services.anchor_action_types import NEEDS_ANCHOR # noqa: E402
_TYPES_TS = _VWB / "frontend_v4" / "src" / "types.ts"
# Chaque entrée de ACTION_DEFINITIONS commence par `{ type: '<action>'`.
# On segmente le fichier sur ce marqueur puis on lit le `needsAnchor` de CHAQUE
# segment AVANT tout tableau `params:` imbriqué (dont les `type: 'string'` /
# `type: 'number'` ne doivent PAS être confondus avec un action_type).
_ACTION_START_RE = re.compile(r"\{\s*type:\s*'(?P<type>[a-z_]+)'")
_NEEDS_ANCHOR_RE = re.compile(r"needsAnchor:\s*(true|false)")
def _frontend_needs_anchor() -> set:
text = _TYPES_TS.read_text(encoding="utf-8")
starts = list(_ACTION_START_RE.finditer(text))
result = set()
for idx, m in enumerate(starts):
action_type = m.group("type")
seg_start = m.end()
seg_end = starts[idx + 1].start() if idx + 1 < len(starts) else len(text)
segment = text[seg_start:seg_end]
# Le needsAnchor de l'entrée est le PREMIER de son segment (avant params).
flag = _NEEDS_ANCHOR_RE.search(segment)
if flag and flag.group(1) == "true":
result.add(action_type)
return result
def test_needs_anchor_matches_frontend_types_ts():
"""Le set Python doit être identique aux entrées needsAnchor:true du front."""
front = _frontend_needs_anchor()
assert front, "aucune entrée needsAnchor:true parsée depuis types.ts"
assert set(NEEDS_ANCHOR) == front, (
"Divergence NEEDS_ANCHOR (Python) vs types.ts (front) :\n"
f" manquantes côté Python : {sorted(front - set(NEEDS_ANCHOR))}\n"
f" en trop côté Python : {sorted(set(NEEDS_ANCHOR) - front)}"
)