Files
rpa_vision_v3/tools/backfill_anchors_r1.py
Dom 0a70ac1334 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>
2026-07-03 10:10:45 +02:00

221 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""
Backfill des ancres visuelles pour les workflows importés par le pont R1.
Contexte (diagnostic + plan 2026-07-03) : 62 workflows `source='learned_import'`
sont déjà en DB VWB avec le crop `_anchor_image_base64` resté dans
`Step.parameters_json` et `anchor_id = NULL` → le VWB affiche « Ancre requise »
alors que l'image est là (verdict H1 : ~69 % des clics ont le crop en base).
Corriger le flux futur (helper R1 + route HTTP) ne répare PAS l'existant.
Ce script matérialise l'ancre pour chaque step réparable, via le MÊME helper
partagé `materialize_anchor_from_b64` (zéro logique dupliquée).
Sécurité / garde-fous :
* `--dry-run` par défaut : n'écrit RIEN. Affiche le chemin DB résolu, le
nombre de steps réparables (par workflow), l'estimation du nombre de PNG à
créer et l'espace disque.
* Filtre dur : `source='learned_import'` ET `action_type ∈ NEEDS_ANCHOR` ET
`anchor_id IS NULL` ET `_anchor_image_base64` présent dans parameters_json.
Les workflows `source='manual'` ne sont JAMAIS touchés.
* Idempotent : un step déjà pourvu d'`anchor_id` est ignoré ; une ré-exécution
d'`--apply` est un no-op (le helper retire aussi la clé b64 des params).
* Cible DB résolue depuis la config runtime VWB (env `DATABASE_URL` + logique
`instance_path` de Flask), PAS un chemin codé en dur (réserve QG R1).
⚠️ Ce script est manuel, hors flux runtime. Il NE fait PAS de backup — l'appelant
doit sauvegarder la DB avant `--apply` (copie horodatée), cf. plan §3.3.
Ne JAMAIS lancer `--apply` sans backup ni sans avoir validé le `--dry-run`.
Usage :
python tools/backfill_anchors_r1.py # dry-run (défaut)
python tools/backfill_anchors_r1.py --dry-run # explicite
python tools/backfill_anchors_r1.py --db /chemin/workflows.db --dry-run
python tools/backfill_anchors_r1.py --apply # écrit (backup requis avant)
Auteur : Dom, Claude — 2026-07-03
"""
import argparse
import base64
import json
import os
import sys
from pathlib import Path
# --- Chemins : racine projet + backend VWB (pour db.models, services.*) ---
_ROOT = Path(__file__).resolve().parent.parent
_BACKEND = _ROOT / "visual_workflow_builder" / "backend"
for p in (str(_ROOT), str(_BACKEND)):
if p not in sys.path:
sys.path.insert(0, p)
def resolve_vwb_db_path(explicit: str | None = None) -> Path:
"""Résout le chemin du fichier SQLite VWB depuis la config runtime.
Ordre de priorité :
1. `--db` explicite (échappatoire manuelle) ;
2. `DATABASE_URL` env (même variable que `app.py`) si sqlite:/// ;
3. défaut `app.py` = `sqlite:///workflows.db`, résolu relativement à
`Flask.instance_path` du backend VWB — EXACTEMENT comme l'app au runtime.
On instancie une app Flask *sonde* légère (root_path = backend) au lieu
d'importer `app.py` (lourd : socketio, blueprints). Flask calcule alors le
même `instance_path` que l'app réelle → même résolution de la DB.
"""
if explicit:
return Path(explicit).resolve()
uri = os.getenv("DATABASE_URL", "sqlite:///workflows.db")
if not uri.startswith("sqlite:"):
raise SystemExit(
f"DATABASE_URL non-SQLite ({uri!r}) : backfill SQLite uniquement. "
"Passez --db pour un chemin explicite."
)
# sqlite:////abs/path (4 slashes) → chemin absolu ; sqlite:///rel → relatif.
tail = uri[len("sqlite://"):]
if tail.startswith("//"): # //// → chemin absolu
return Path(tail[1:]).resolve()
rel = tail.lstrip("/") # ex. "workflows.db"
from flask import Flask
probe = Flask("vwb_backfill_probe", root_path=str(_BACKEND))
return (Path(probe.instance_path) / rel).resolve()
def _iter_repairable_steps(session, NEEDS_ANCHOR):
"""Génère les tuples (workflow, step, params) réparables.
Réparable = source learned_import, action_type ∈ NEEDS_ANCHOR,
anchor_id NULL, et `_anchor_image_base64` présent (non vide) dans params.
"""
from db.models import Workflow, Step
q = (
session.query(Workflow, Step)
.join(Step, Step.workflow_id == Workflow.id)
.filter(Workflow.source == "learned_import")
.filter(Step.anchor_id.is_(None))
.filter(Step.action_type.in_(tuple(NEEDS_ANCHOR)))
.filter(Step.parameters_json.like("%_anchor_image_base64%"))
)
for wf, step in q.all():
params = step.parameters # dict décodé
b64 = params.get("_anchor_image_base64")
if b64:
yield wf, step, params
def _estimate_png_bytes(b64: str) -> int:
"""Taille approximative du PNG décodé (le fichier écrit ≈ crop optimisé)."""
try:
clean = b64.split(",", 1)[1] if "," in b64 else b64
return len(base64.b64decode(clean))
except Exception:
return 0
def run(db_path: Path, apply: bool) -> int:
from flask import Flask
from db.models import db
from services.anchor_action_types import NEEDS_ANCHOR
from services.anchor_image_service import materialize_anchor_from_b64
if not db_path.exists():
raise SystemExit(f"DB introuvable : {db_path}")
app = Flask("vwb_backfill")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
mode = "APPLY (écriture)" if apply else "DRY-RUN (lecture seule)"
print(f"=== Backfill ancres R1 — {mode} ===")
print(f"DB résolue : {db_path}")
print(f"action_type ∈ NEEDS_ANCHOR ({len(NEEDS_ANCHOR)}) : "
f"{', '.join(sorted(NEEDS_ANCHOR))}")
print()
with app.app_context():
session = db.session
per_workflow: dict = {}
total_repairable = 0
total_est_bytes = 0
for wf, step, params in _iter_repairable_steps(session, NEEDS_ANCHOR):
total_repairable += 1
total_est_bytes += _estimate_png_bytes(params["_anchor_image_base64"])
bucket = per_workflow.setdefault(
wf.id, {"name": wf.name, "count": 0}
)
bucket["count"] += 1
if apply:
# Le helper retire la clé b64 de params, crée VisualAnchor +
# fichier, pose step.anchor_id. On réécrit params (b64 retiré).
anchor_id = materialize_anchor_from_b64(
step, params, db_session=session, source="learned_import"
)
if anchor_id:
step.parameters = params # persiste params sans le b64
session.add(step)
if apply:
session.commit()
# --- Rapport ---
print(f"Workflows learned_import concernés : {len(per_workflow)}")
for wf_id, info in sorted(per_workflow.items(),
key=lambda kv: -kv[1]["count"]):
print(f" - {wf_id} ({info['name'][:48]}) : "
f"{info['count']} step(s) réparable(s)")
print()
print(f"Total steps réparables : {total_repairable}")
if apply:
print(f"{total_repairable} ancres matérialisées (VisualAnchor + PNG).")
print("Ré-exécuter ce script = no-op (idempotent).")
else:
est_png_mb = total_est_bytes / (1024 * 1024)
# Thumbnails JPEG q80 ≈ 30 % du PNG en plus (majoration prudente).
est_total_mb = est_png_mb * 1.3
print(f"→ PNG à créer : {total_repairable}")
print(f"→ Espace disque estimé : ~{est_png_mb:.2f} Mo (PNG) "
f"+ thumbnails ≈ ~{est_total_mb:.2f} Mo total")
print()
print("DRY-RUN : aucune écriture. Vérifiez le chemin DB ci-dessus,")
print("SAUVEGARDEZ la DB, puis relancez avec --apply.")
return 0
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="Backfill idempotent des ancres visuelles des workflows R1."
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--dry-run", action="store_true", default=True,
help="Lecture seule (défaut) : rapport sans écriture.",
)
group.add_argument(
"--apply", action="store_true", default=False,
help="Écrit les ancres (backup DB requis AVANT).",
)
parser.add_argument(
"--db", default=None,
help="Chemin explicite du fichier SQLite VWB (sinon résolu depuis config).",
)
args = parser.parse_args(argv)
apply = bool(args.apply)
db_path = resolve_vwb_db_path(args.db)
return run(db_path, apply=apply)
if __name__ == "__main__":
raise SystemExit(main())