Files
rpa_vision_v3/docs/PLAN_D1_NAVIGATE_COORDS_IMPLEMENTATION_2026-07-02.md
Dom 931cf13217 feat(navigate): jalon partiel D1 — compile navigate + coercion coords sûre
Ferme Gap C : _edge_to_normalized_actions produit désormais une action navigate
(handler serveur atteignable). Ajoute _coerce_action_coords : cast x_pct/y_pct en
float APRÈS résolution des templates, JAMAIS de fallback (0,0) — template non
résolu / valeur invalide → pause_for_human (safety_level=high). Non-régression
prouvée sur mouse_click classiques (idempotent sur floats).

⚠️ NE FERME PAS le write-only : Gap A (P1-B) non livré — aucun step click/type ne
déclare encore consommer navigate_login_coords. TestCompilerGapLiteralFloats
assert l'état ouvert. Boucle complète = chantier suivant (P1-B + test e2e edge→action).

21 tests verts, boot OK. Revue croisée Claude (GO jalon partiel).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:49:32 +02:00

19 KiB

D1 — NavigateCoords Implementation Plan

Auteur: Qwen
Date: 2026-07-02
Statut: EN ATTENTE GO Dom/Claude — Option 1 vs Option 2
Référence: docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md (3 gaps documentés)


Résumé des gaps à résoudre

Gap Description Fichier:Ligne Preuve
A Compiler bake floats littéraux — aucun template pour coords replay_engine.py:1821-1833 x_pct = px (literal float)
B Zéro consommateur de navigate_*_coords variables replay_engine.py + api_stream.py grep: 0 occurrences
C _edge_to_normalized_actions pas de branche navigate[] replay_engine.py:1951-1953 else: return []

Infrastructure existante (non-modifiée)

_ALLOWED_ACTION_TYPES (replay_engine.py:35-50)

"navigate" est déjà présent (ligne 44). La validation de sécurité l'accepte déjà.

_SERVER_SIDE_ACTION_TYPES (replay_engine.py:55-64)

"navigate" est déjà présent (ligne 59). Le dispatch loop le traite comme serveur-side.

_handle_navigate_action (core/navigation/init.py:24-113)

Handler déjà câblé dans api_stream.py (ligne 4459-4467). Résout screenshot, OCR/VLM, stocke coords dans replay_state["variables"].

_resolve_runtime_vars (replay_engine.py:2031-2045)

Resolver existant pour {{var.field}} — récursif sur dict/list/str. Retourne str(value) au niveau leaf → float→string conversion nécessaire pour coords.


OPTION 1 — Compiler Injection (~2h)

Principe

Ajouter une branche navigate dans _edge_to_normalized_actions + ajouter coords_var mechanism dans les branches mouse_click/text_input + runtime resolution + float conversion.

Patch P1-A : Branche navigate dans _edge_to_normalized_actions

Fichier: agent_v0/server_v1/replay_engine.py
Position: Après elif action_type == "llm_generate": (ligne 1949), avant else: (ligne 1951)

elif action_type == "navigate":
    normalized["type"] = "navigate"
    normalized["parameters"] = {
        "login_field": action_params.get("login_field", "login"),
        "password_field": action_params.get("password_field", "password"),
        "submit_button": action_params.get("submit_button", "submit"),
        "login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
        "password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
        "submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
    }
    return [normalized]

Justification: Action serveur-side — pas besoin de x_pct/y_pct ni target_spec. Le handler _handle_navigate_action lit parameters pour config, résout coords au runtime.

Impact: Gap C résolu. Navigate edge → 1 normalized action au lieu de [].

Patch P1-B : coords_var dans branches mouse_click / text_input

Fichier: agent_v0/server_v1/replay_engine.py
Position: Lignes 1844-1856 (branches click et type)

mouse_click (ligne 1844-1848) — AVANT :

if action_type == "mouse_click":
    normalized["type"] = "click"
    normalized["x_pct"] = x_pct
    normalized["y_pct"] = y_pct
    normalized["button"] = action_params.get("button", "left")

mouse_click — APRES :

if action_type == "mouse_click":
    normalized["type"] = "click"
    coords_var = action_params.get("coords_var")
    if coords_var:
        normalized["x_pct"] = f"{{{{{coords_var}.x_pct}}}}"
        normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
        normalized["coords_var"] = coords_var
    else:
        normalized["x_pct"] = x_pct
        normalized["y_pct"] = y_pct
    normalized["button"] = action_params.get("button", "left")

text_input (ligne 1850-1856) — AVANT :

elif action_type == "text_input":
    normalized["type"] = "type"
    text = action_params.get("text", "")
    text = _substitute_variables(text, params, action_params.get("defaults", {}))
    normalized["text"] = text
    normalized["x_pct"] = x_pct
    normalized["y_pct"] = y_pct

text_input — APRES :

elif action_type == "text_input":
    normalized["type"] = "type"
    text = action_params.get("text", "")
    text = _substitute_variables(text, params, action_params.get("defaults", {}))
    normalized["text"] = text
    coords_var = action_params.get("coords_var")
    if coords_var:
        normalized["x_pct"] = f"{{{{{coords_var}.y_pct}}}}"
        normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
        normalized["coords_var"] = coords_var
    else:
        normalized["x_pct"] = x_pct
        normalized["y_pct"] = y_pct

⚠️ BUG dans le draft ci-dessus: x_pct template pour text_input doit être {{coords_var.x_pct}} (pas .y_pct deux fois). Version corrigée :

elif action_type == "text_input":
    normalized["type"] = "type"
    text = action_params.get("text", "")
    text = _substitute_variables(text, params, action_params.get("defaults", {}))
    normalized["text"] = text
    coords_var = action_params.get("coords_var")
    if coords_var:
        normalized["x_pct"] = f"{{{{{coords_var}.x_pct}}}}"
        normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
        normalized["coords_var"] = coords_var
    else:
        normalized["x_pct"] = x_pct
        normalized["y_pct"] = y_pct

Justification: coords_var = mécanisme minimal pour déclarer "ces coords viennent de la variable navigate_login_coords". Template strings résolus au runtime par _resolve_runtime_vars.

Impact: Gap A résolu. Gap B partiellement — les actions click/type deviennent consommatrices via coords_var.

Patch P1-C : Coercion helper après resolver existant

⚠️ CORRECTION IMPORTANT (2026-07-02 14:45) : Le plan original sur-dimensionnait P1-C en proposant un second resolver runtime. Codex a correctement identifié que _resolve_runtime_vars est déjà appelé dans la boucle dispatch à api_stream.py:4331-4335 :

# L4331-4335 (EXISTANT, ne pas modifier)
if owning_replay is not None:
    runtime_vars = owning_replay.get("variables") or {}
    if runtime_vars:
        action = _resolve_runtime_vars(action, runtime_vars)

Besoin réel = coercion helper uniquement : _resolve_runtime_vars résout les templates {{var.field}} mais retourne str(value) au leaf → {{navigate_login_coords.x_pct}} devient "0.15" (string), pas 0.15 (float). Le client attend des floats pour x_pct/y_pct.

Fichier: agent_v0/server_v1/api_stream.py Position: Juste après la ligne 4335 (action = _resolve_runtime_vars(action, runtime_vars))

Politique coords_var non résolu : Skip + pause supervisée (AGREED Qwen/Codex). Jamais fallback 0.0/0.0 — un clic sur coords (0,0) = top-left = potentiellement dangereux.

def _coerce_action_coords(action: dict) -> dict:
    """Cast x_pct/y_pct en float après template resolution par _resolve_runtime_vars.

    Politique : si string non convertible ou template encore present → skip + pause_for_human.
    Idempotent sur les actions qui ont déjà des floats (mouse_click existant).

    Appelé APRÈS _resolve_runtime_vars dans la boucle dispatch (api_stream.py ~L4335).
    """
    for key in ("x_pct", "y_pct"):
        val = action.get(key)
        if val is None:
            continue
        if isinstance(val, float):
            continue  # déjà float, idempotent
        if isinstance(val, str):
            # Template encore présent = non résolu par _resolve_runtime_vars
            if val.startswith("{{") and val.endswith("}}"):
                action["_skip_reason"] = f"coords_var non résolu: {key}={val}"
                action["type"] = "pause_for_human"
                action["safety_level"] = "high"
                return action
            try:
                action[key] = float(val)
            except (ValueError, TypeError):
                action["_skip_reason"] = f"coords invalide: {key}={val}"
                action["type"] = "pause_for_human"
                action["safety_level"] = "high"
                return action
    return action

Appel dans la boucle dispatch (à insérer après L4335) :

# L4335 existant: action = _resolve_runtime_vars(action, runtime_vars)
# NOUVEAU — coercion coords après resolver existant
action = _coerce_action_coords(action)

Justification: _resolve_runtime_vars (existant à L4335) résout les templates → strings. _coerce_action_coords cast les strings en floats. Si template non résolu ou conversion impossible → pause_for_human (fail-safe), jamais fallback coords (0,0). Idempotent sur actions existantes (floats déjà présents).

Risques additionnels identifiés :

  1. Résolution partielle : si seul y_pct est résolu mais x_pct reste template → _coerce_action_coords convertit pause_for_human (safe stop, pas top-left click).
  2. Idempotence : si action existante a déjà x_pct=0.35 (float) → helper passe sans modification (isinstance(float) → continue).
  3. Race condition : variables dict partagé entre navigate handler et dispatch loop — mais BFS séquentiel garantit que navigate stocke AVANT click consomme.

Impact: Gap B résolu — les coords navigate sont consommées au runtime par click/type, avec coercion + fail-safe.

Patch P1-D : VWB YAML schema — coords_var field

Fichier: Schema VWB (workflow YAML format) — documentation
Nature: Ajout d'un champ coords_var dans action.parameters pour les steps mouse_click et text_input

Exemple de workflow YAML avec navigate + click consommateur :

steps:
  - id: s1
    action:
      type: navigate
      parameters:
        login_coords_var: navigate_login_coords
        password_coords_var: navigate_password_coords
    to_node: n2

  - id: s2
    action:
      type: mouse_click
      parameters:
        coords_var: navigate_login_coords
        button: left
    to_node: n3

  - id: s3
    action:
      type: text_input
      parameters:
        coords_var: navigate_password_coords
        text: "${password}"
    to_node: n4

Justification: Le VWB builder doit savoir qu'un click peut référencer une variable coords au lieu de fournir des pixels littéraux. C'est un changement de schema minimal (1 champ optionnel).


OPTION 2 — Declarative YAML Templates (~4h)

Principe

Introduire un coords_template field dans les step definitions + un resolver typed qui extrait directement les floats du dict variables sans passage string→float.

Patch P2-A : Même branche navigate (identique à P1-A)

Inchangé — Gap C résolu par la même branche.

Patch P2-B : coords_template field + typed resolver

Fichier: agent_v0/server_v1/replay_engine.py

Nouvelle fonction _resolve_coords_template :

def _resolve_coords_template(
    coords_template: str,
    variables: Dict[str, Any],
) -> Optional[Dict[str, float]]:
    """Résoudre un coords_template en dict {x_pct, y_pct, bbox_pct} depuis variables.
    
    Retourne None si la variable n'existe pas ou si les champs ne sont pas floats.
    Pas de conversion string→float : les valeurs doivent déjà être des floats.
    """
    coords_dict = variables.get(coords_template)
    if not coords_dict or not isinstance(coords_dict, dict):
        return None
    
    x_pct = coords_dict.get("x_pct")
    y_pct = coords_dict.get("y_pct")
    
    if not isinstance(x_pct, (int, float)) or not isinstance(y_pct, (int, float)):
        logger.warning(
            f"coords_template {coords_template}: x_pct/y_pct not numeric "
            f"(x_pct={x_pct}, y_pct={y_pct})"
        )
        return None
    
    result = {"x_pct": float(x_pct), "y_pct": float(y_pct)}
    
    bbox_pct = coords_dict.get("bbox_pct")
    if bbox_pct:
        result["bbox_pct"] = bbox_pct  # tuple, pas de conversion
    
    return result

Patch P2-C : Branches mouse_click / text_input avec coords_template

if action_type == "mouse_click":
    normalized["type"] = "click"
    coords_template = action_params.get("coords_template")
    if coords_template:
        normalized["coords_template"] = coords_template
        # x_pct/y_pct résolus au runtime par _resolve_coords_template
        normalized["x_pct"] = None  # placeholder → resolved at runtime
        normalized["y_pct"] = None
    else:
        normalized["x_pct"] = x_pct
        normalized["y_pct"] = y_pct
    normalized["button"] = action_params.get("button", "left")

Patch P2-D : Runtime resolution typed dans dispatch loop

# --- Résolution coords_template (typed, no string→float) ---
if action.get("coords_template"):
    variables = owning_replay.replay_state.get("variables", {})
    from agent_v0.server_v1.replay_engine import _resolve_coords_template
    coords = _resolve_coords_template(action["coords_template"], variables)
    if coords:
        action["x_pct"] = coords["x_pct"]
        action["y_pct"] = coords["y_pct"]
        if coords.get("bbox_pct"):
            action["bbox_pct"] = coords["bbox_pct"]
        del action["coords_template"]  # résolu, pas besoin de garder le ref
    else:
        logger.warning(
            f"coords_template {action['coords_template']} unresolved — skipping action"
        )
        # skip → next action

Avantage Option 2: Pas de string→float conversion. Les coords restent des floats du navigate handler au click handler. Plus clean, plus safe.

Inconvénient Option 2: _resolve_coords_template est une nouvelle fonction + le x_pct = None placeholder nécessite que le client tolère les None temporairement (ou que la resolution se fasse AVANT transmission). Le schema VWB doit documenter coords_template comme champ alternatif à by_position.


Comparative Table — Patches

Aspect Option 1 (Compiler Injection) Option 2 (YAML Templates)
Gap C fix Identique (branche navigate) Identique (branche navigate)
Gap A fix Template strings {{var.field}} dans x_pct/y_pct x_pct = None placeholder + typed resolver
Gap B fix _resolve_runtime_vars + float conversion _resolve_coords_template typed (no conversion)
String→float Nécessaire (design smell) Aucun (floats passent directement)
Nouvelles fonctions 0 (reuse _resolve_runtime_vars) 1 (_resolve_coords_template)
Schema VWB 1 champ coords_var 1 champ coords_template
Temps implémentation ~2h ~4h
Extensibilité Limitée (coupling navigate→click) Extensible (any coords source)
Risque POC Minimal Moyen (placeholder None + typed resolver)
Migration post-POC Option 2 refactor needed Already Option 2

Test Rouge Proposal

Test TR-1 : Prouve Gap C (navigate → [])

def test_edge_to_normalized_actionsnavigate_returns_empty():
    """Gap C: _edge_to_normalized_actions retourne [] pour navigate type."""
    from agent_v0.server_v1.replay_engine import _edge_to_normalized_actions
    
    edge = WorkflowEdge(
        edge_id="e1",
        from_node="n1",
        to_node="n2",
        action=ActionSpec(
            type="navigate",
            parameters={"login_coords_var": "navigate_login_coords"},
        ),
    )
    result = _edge_to_normalized_actions(edge, {})
    # BEFORE fix: result == [] (Gap C)
    # AFTER fix: result == [{"type": "navigate", "parameters": {...}}]
    assert len(result) >= 1, "navigate must produce at least 1 normalized action"
    assert result[0]["type"] == "navigate"

Test TR-2 : Prouve coords_var resolution (Option 1)

def test_coords_var_runtime_resolution():
    """Option 1: coords_var template resolved + float conversion."""
    from agent_v0.server_v1.replay_engine import _resolve_runtime_vars
    
    variables = {
        "navigate_login_coords": {
            "x_pct": 0.15,
            "y_pct": 0.35,
            "method": "ocr+vlm",
        }
    }
    action = {
        "type": "click",
        "x_pct": "{{navigate_login_coords.x_pct}}",
        "y_pct": "{{navigate_login_coords.y_pct}}",
        "coords_var": "navigate_login_coords",
    }
    resolved = _resolve_runtime_vars(action, variables)
    # resolved["x_pct"] == "0.15" (string) → needs float conversion
    assert resolved["x_pct"] == "0.15"  # string from resolver
    assert float(resolved["x_pct"]) == 0.15  # conversion works

Test TR-3 : Prouve coords_template typed resolution (Option 2)

def test_coords_template_typed_resolution():
    """Option 2: coords_template returns floats directly, no conversion."""
    from agent_v0.server_v1.replay_engine import _resolve_coords_template
    
    variables = {
        "navigate_login_coords": {
            "x_pct": 0.15,
            "y_pct": 0.35,
            "method": "ocr+vlm",
        }
    }
    coords = _resolve_coords_template("navigate_login_coords", variables)
    assert coords is not None
    assert isinstance(coords["x_pct"], float)  # float, not string
    assert coords["x_pct"] == 0.15
    assert coords["y_pct"] == 0.35

BFS Ordonnancement — Risque scheduling

Le dispatch loop (api_stream.py:get_next_action) traite les actions séquentiellement par path BFS. Navigate est serveur-side → traité en boucle interne avant transmission. Click/type consommant coords_var/template sont visuels → transmis au client.

Flows correct:

  1. BFS traverse edge navigate → normalized action type=navigate
  2. Loop interne: _handle_navigate_action → stocke coords dans variables
  3. BFS traverse edge click → normalized action avec coords_var
  4. Loop: resolution runtime → float conversion → transmission client

Risque: Si le BFS ordonnance le click AVANT le navigate (par ex. edges parallèles), coords_var sera unresolved → fallback 0.0/0.0.

Mitigation: VWB builder doit garantir que navigate edge précède click consommateur dans le path topologique. C'est une contrainte de schema, pas un bug runtime.


Decision Matrix

Critère Option 1 Option 2 Recommandation POC
Temps 2h 4h Option 1
Risque runtime string→float edge None placeholder Option 1 (conversion simple)
Extensibilité Limitée Extensible Option 1 pour POC, migration Option 2 post-POC
Code mort risk 0 nouvelles fonctions 1 nouvelle fonction Option 1
Test coverage TR-1 + TR-2 TR-1 + TR-3 Option 1

Recommandation Qwen: Option 1 pour POC (2h, minimal risk, reuse infrastructure existante). Migration Option 2 post-POC si scaling multi-coords est confirmé (search, dossier).

GO requis: Dom + Claude (décision D1).


Qwen — plan implémentation D1 déposé, awaiting GO.