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>
This commit is contained in:
Dom
2026-07-02 18:49:32 +02:00
parent fd9efdbbf5
commit 931cf13217
5 changed files with 841 additions and 6 deletions

View File

@@ -1948,6 +1948,21 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
normalized["parameters"]["temperature"] = action_params.get("temperature")
return [normalized]
elif action_type == "navigate":
normalized["type"] = "navigate"
normalized["parameters"] = {
"action": action_params.get("action", "login"),
"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"),
}
login_config_keys = ("login_field", "password_field", "submit_button",
"success_elements", "context")
for key in login_config_keys:
if action_params.get(key) is not None:
normalized["parameters"][key] = action_params[key]
return [normalized]
else:
logger.warning(f"Type d'action inconnu : {action_type}")
return []
@@ -2045,6 +2060,38 @@ def _resolve_runtime_vars(value: Any, variables: Dict[str, Any]) -> Any:
return value
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).
Jamais fallback 0.0/0.0 — un clic sur coords (0,0) = top-left = potentiellement dangereux.
Appelé APRÈS _resolve_runtime_vars dans la boucle dispatch (api_stream.py).
"""
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
# =========================================================================
# Handlers pour les actions exécutées côté serveur (extract_text, t2a_decision)
# =========================================================================