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