feat(core): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user