feat(core): auto-commit con 10 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 13:20:36 +02:00
parent 3f6b652f3f
commit 029dbf57bd
10 changed files with 408 additions and 55 deletions
+63 -29
View File
@@ -1,7 +1,7 @@
"""Convierte una lista de AXNode CDP en un outline indentado legible por LLMs."""
# Roles que se consideran accionables (el LLM puede referirlos con #ref=nodeId).
# Roles que se consideran accionables (el LLM puede referirlos con #ref).
_ACTIONABLE_ROLES = frozenset({
"button",
"link",
@@ -23,9 +23,12 @@ _ACTIONABLE_ROLES = frozenset({
"gridcell",
})
# Roles sin valor semántico para el outline: se omiten si tampoco tienen hijos.
# Roles sin valor semántico para el outline: se omiten (sus hijos se elevan).
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})
# Límite de profundidad: evita RecursionError en árboles AX patológicos.
_MAX_DEPTH = 60
def _role_val(node: dict) -> str:
"""Extrae el valor de role del nodo CDP."""
@@ -43,37 +46,63 @@ def _name_val(node: dict) -> str:
return str(n) if n else ""
def _value_val(node: dict) -> str:
"""Estado actual del nodo: texto escrito en un input, valor de un slider o
combobox, etc. El LLM necesita saber qué hay ya en el campo."""
v = node.get("value", {})
if isinstance(v, dict):
val = v.get("value", "")
return str(val) if val not in (None, "") else ""
return str(v) if v else ""
def _ref_id(node: dict) -> str:
"""Ref ESTABLE para acciones por referencia.
Usa backendDOMNodeId (apunta al nodo DOM real, estable mientras el nodo viva)
en lugar del nodeId del AX tree, que es efímero y cambia en cada
Accessibility.getFullAXTree. Esto hace que el #ref que lee el LLM siga siendo
válido cuando actúa sobre él un instante después. Fallback al nodeId si el
backend no viene poblado.
"""
bid = node.get("backendDOMNodeId")
if bid is not None:
return str(bid)
return str(node.get("nodeId", ""))
def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
"""Convierte nodos AX tree CDP en un outline indentado legible y accionable.
Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera
una representación de texto con indentación de 2 espacios por nivel.
Los nodos accionables llevan un marcador #ref=nodeId para que el LLM
pueda referenciarlos en acciones posteriores.
Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera una
representación de texto con indentación de 2 espacios por nivel. Los nodos
accionables llevan un marcador #ref=<backendDOMNodeId> para que el LLM pueda
referenciarlos en acciones posteriores (click_ref/type_ref/hover_ref). El
estado actual de inputs (texto escrito, valor) se muestra como `= 'valor'`.
Args:
nodes: Lista de AXNode en formato CDP (campos: nodeId, role, name,
childIds, parentId, ignored). Formato devuelto por
cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes
para reducir ruido.
max_chars: Si > 0, trunca la salida a ese número de caracteres y
añade una línea final '…[outline truncado]'. 0 = sin límite.
nodes: Lista de AXNode en formato CDP (campos: nodeId, backendDOMNodeId,
role, name, value, childIds, parentId, ignored). Formato devuelto
por cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes para
reducir ruido (conserva backendDOMNodeId y value.value).
max_chars: Si > 0, trunca la salida a ese número de caracteres y añade una
línea final '…[outline truncado]'. 0 = sin límite.
Returns:
String multi-línea con el outline indentado. Vacío si nodes es vacío
o todos los nodos son ignorados/sin role útil.
String multi-línea con el outline indentado. Vacío si nodes es vacío o
todos los nodos son ignorados/sin role útil.
"""
if not nodes:
return ""
# Construir lookup por nodeId
# Lookup por nodeId (la jerarquía childIds usa nodeId, no backendDOMNodeId).
by_id: dict[str, dict] = {}
for node in nodes:
nid = node.get("nodeId")
if nid:
by_id[nid] = node
# Detectar nodos raíz: nodeId no aparece como childId de nadie visible
# Detectar nodos raíz: nodeId que no aparece como childId de nadie visible.
all_child_ids: set[str] = set()
for node in nodes:
for cid in node.get("childIds", []):
@@ -81,19 +110,25 @@ def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
roots = [n for n in nodes if n.get("nodeId") not in all_child_ids]
if not roots:
# Fallback: usar el primer nodo
roots = [nodes[0]]
lines: list[str] = []
visited: set[str] = set() # guard de ciclo: un nodeId no se renderiza dos veces
def _render_node(node: dict, depth: int) -> None:
"""Renderiza un nodo y sus hijos recursivamente."""
nid = node.get("nodeId")
if depth > _MAX_DEPTH or (nid and nid in visited):
return
if nid:
visited.add(nid)
if node.get("ignored", False):
return
role = _role_val(node)
if not role or role in _SKIP_ROLES:
# Nodos sin role útil: intentar renderizar hijos directamente
# Nodos sin role útil: elevar los hijos al nivel actual.
for cid in node.get("childIds", []):
child = by_id.get(cid)
if child:
@@ -101,27 +136,26 @@ def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
return
name = _name_val(node)
node_id = node.get("nodeId", "")
indent = " " * depth
# Construir línea base: role ["name"]
if name:
base = f'{indent}{role} "{name}"'
else:
base = f"{indent}{role}"
# Añadir #ref si es accionable
if role in _ACTIONABLE_ROLES and node_id:
# Alinear #ref a columna 60 para legibilidad, o adjunto si la línea es larga
ref_tag = f"#ref={node_id}"
if len(base) < 58:
base = base.ljust(60) + ref_tag
else:
base = base + " " + ref_tag
# Estado actual del campo (texto escrito, valor del slider/combobox).
value = _value_val(node)
if value:
base += f" = {value!r}"
# Ref accionable, sin padding (el relleno con espacios gastaba tokens).
if role in _ACTIONABLE_ROLES:
ref = _ref_id(node)
if ref:
base += f" #ref={ref}"
lines.append(base)
# Renderizar hijos en orden
for cid in node.get("childIds", []):
child = by_id.get(cid)
if child: