From 029dbf57bd9d8a1433a5e838524d17fb7dd52cfe Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 6 Jun 2026 13:20:36 +0200 Subject: [PATCH] feat(core): auto-commit con 10 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- functions/browser/cdp_click_human.go | 30 +------ functions/browser/cdp_click_ref.go | 40 ++++++++++ functions/browser/cdp_click_ref.md | 51 ++++++++++++ functions/browser/cdp_click_xy_human.go | 49 ++++++++++++ functions/browser/cdp_click_xy_human.md | 62 +++++++++++++++ functions/browser/cdp_hover_ref.go | 19 +++++ functions/browser/cdp_hover_ref.md | 53 +++++++++++++ functions/browser/cdp_type_ref.go | 16 ++++ functions/browser/cdp_type_ref.md | 51 ++++++++++++ python/functions/core/render_ax_outline.py | 92 +++++++++++++++------- 10 files changed, 408 insertions(+), 55 deletions(-) create mode 100644 functions/browser/cdp_click_ref.go create mode 100644 functions/browser/cdp_click_ref.md create mode 100644 functions/browser/cdp_click_xy_human.go create mode 100644 functions/browser/cdp_click_xy_human.md create mode 100644 functions/browser/cdp_hover_ref.go create mode 100644 functions/browser/cdp_hover_ref.md create mode 100644 functions/browser/cdp_type_ref.go create mode 100644 functions/browser/cdp_type_ref.md diff --git a/functions/browser/cdp_click_human.go b/functions/browser/cdp_click_human.go index 69de77fd..4b98ff4e 100644 --- a/functions/browser/cdp_click_human.go +++ b/functions/browser/cdp_click_human.go @@ -4,7 +4,6 @@ import ( "fmt" "math/rand" "strings" - "time" ) // CdpClickHuman hace click en el elemento identificado por selector CSS con @@ -53,31 +52,10 @@ func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error { toX := bx + bw/2 + offX toY := by + bh/2 + offY - // Mover el ratón con trayectoria humana - if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil { - return fmt.Errorf("cdp click human: mover raton: %w", err) - } - - // mousePressed - clickParams := map[string]any{ - "type": "mousePressed", - "x": toX, - "y": toY, - "button": "left", - "clickCount": 1, - } - if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil { - return fmt.Errorf("cdp click human: mousePressed: %w", err) - } - - // Micro-pausa humana entre press y release (30–90 ms) - pauseMs := 30 + rand.Intn(61) - time.Sleep(time.Duration(pauseMs) * time.Millisecond) - - // mouseReleased - clickParams["type"] = "mouseReleased" - if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil { - return fmt.Errorf("cdp click human: mouseReleased: %w", err) + // Delegar en el primitivo compartido: mueve el ratón con trayectoria humana + // y despacha press/release con micro-pausa. + if err := CdpClickXYHuman(c, toX, toY, opts); err != nil { + return fmt.Errorf("cdp click human: %w", err) } return nil diff --git a/functions/browser/cdp_click_ref.go b/functions/browser/cdp_click_ref.go new file mode 100644 index 00000000..6314fe8b --- /dev/null +++ b/functions/browser/cdp_click_ref.go @@ -0,0 +1,40 @@ +package browser + +import "fmt" + +// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su +// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas). +func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) { + res, err := c.sendCDP("DOM.getBoxModel", map[string]any{"backendNodeId": backendNodeID}) + if err != nil { + return 0, 0, fmt.Errorf("getBoxModel ref %d: %w", backendNodeID, err) + } + model, ok := res["model"].(map[string]any) + if !ok { + return 0, 0, fmt.Errorf("ref %d: sin boxModel (nodo no visible o inexistente)", backendNodeID) + } + content, ok := model["content"].([]any) + if !ok || len(content) < 8 { + return 0, 0, fmt.Errorf("ref %d: content quad invalido", backendNodeID) + } + num := func(i int) float64 { f, _ := content[i].(float64); return f } + cx := (num(0) + num(2) + num(4) + num(6)) / 4 + cy := (num(1) + num(3) + num(5) + num(7)) / 4 + return cx, cy, nil +} + +// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref. +// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive. +// Hace scroll al elemento si es necesario antes de calcular las coordenadas. +func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error { + if c == nil { + return fmt.Errorf("cdp click ref: conexión nil") + } + // scroll al elemento si no está visible; ignorar error (no fatal) + _, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID}) + cx, cy, err := refBoxCenter(c, backendNodeID) + if err != nil { + return fmt.Errorf("cdp click ref: %w", err) + } + return CdpClickXYHuman(c, cx, cy, opts) +} diff --git a/functions/browser/cdp_click_ref.md b/functions/browser/cdp_click_ref.md new file mode 100644 index 00000000..3f1da0ed --- /dev/null +++ b/functions/browser/cdp_click_ref.md @@ -0,0 +1,51 @@ +--- +name: cdp_click_ref +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error" +description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel." +tags: [cdp, browser, action, ref, humanized, navegator] +uses_functions: [cdp_click_xy_human_go_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +params: + - name: c + desc: "Conexión CDP activa al tab objetivo." + - name: backendNodeID + desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline." + - name: opts + desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto." +output: "nil si el click se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el click CDP falla." +file_path: "functions/browser/cdp_click_ref.go" +--- + +## Ejemplo + +```go +// Tras un page_perceive que devuelve outline con #ref=1234: +conn, _ := CdpConnect(9222) +err := CdpClickRef(conn, 1234, MouseHumanOpts{}) +if err != nil { + log.Fatal(err) +} +``` + +## Cuando usarla + +Tras `page_perceive` / `render_ax_outline`, cuando el agente tiene el `#ref` de un elemento del outline y quiere hacer click sobre él sin necesitar un selector CSS — cierra el bucle percibir→actuar. Preferir sobre `CdpClickHuman` cuando el nodo viene del AX outline (más estable que un selector). + +## Gotchas + +- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar. +- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado (display:none, fuera del shadow DOM accesible, o ya eliminado). El error describe la causa. +- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal) — si el elemento no es scrollable al viewport el click puede caer en coordenadas incorrectas. +- El click va por `CdpClickXYHuman` (Bézier): no despaches `Input.dispatchMouseEvent` crudo en código que use esta función. diff --git a/functions/browser/cdp_click_xy_human.go b/functions/browser/cdp_click_xy_human.go new file mode 100644 index 00000000..450344e2 --- /dev/null +++ b/functions/browser/cdp_click_xy_human.go @@ -0,0 +1,49 @@ +package browser + +import ( + "fmt" + "math/rand" + "time" +) + +// CdpClickXYHuman hace click en las coordenadas absolutas (x, y) de la página con +// comportamiento humano: mueve el ratón hasta el punto por una trayectoria de +// Bézier cúbica (CdpMoveMouseHuman) y despacha mousePressed/mouseReleased con una +// micro-pausa variable (30-90 ms) entre ambos. +// +// Es el PRIMITIVO de click compartido por las tres vías de acción del agente: +// - por selector CSS → CdpClickHuman (obtiene el bbox y llama aquí). +// - por #ref del AX tree → CdpClickRef (resuelve backendDOMNodeId → bbox → aquí). +// - por visión → click sobre el bounding box que devuelve OCR/YOLO. +// Construir un único primitivo evita tener tres caminos de click divergentes. +func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error { + if c == nil { + return fmt.Errorf("cdp click xy human: conexion nula") + } + + // Mover el ratón hasta el destino con trayectoria humana. + if err := CdpMoveMouseHuman(c, x, y, opts); err != nil { + return fmt.Errorf("cdp click xy human: mover raton: %w", err) + } + + clickParams := map[string]any{ + "type": "mousePressed", + "x": x, + "y": y, + "button": "left", + "clickCount": 1, + } + if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil { + return fmt.Errorf("cdp click xy human: mousePressed: %w", err) + } + + // Micro-pausa humana entre press y release (30-90 ms). + time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond) + + clickParams["type"] = "mouseReleased" + if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil { + return fmt.Errorf("cdp click xy human: mouseReleased: %w", err) + } + + return nil +} diff --git a/functions/browser/cdp_click_xy_human.md b/functions/browser/cdp_click_xy_human.md new file mode 100644 index 00000000..23a60a9b --- /dev/null +++ b/functions/browser/cdp_click_xy_human.md @@ -0,0 +1,62 @@ +--- +id: cdp_click_xy_human_go_browser +name: cdp_click_xy_human +kind: function +lang: go +domain: browser +purity: impure +version: 1.0.0 +tested: false +description: "Click humanizado en coordenadas absolutas (x,y): mueve el ratón con trayectoria Bézier y despacha mousePressed/mouseReleased con micro-pausa variable. Primitivo de click compartido por las tres vías de acción del agente: por selector, por #ref del AX tree y por visión (bounding box de OCR/YOLO)." +tags: [cdp, browser, action, humanized, click, navegator] +signature: "func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error" +uses_functions: + - cdp_move_mouse_human_go_browser +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +file_path: "functions/browser/cdp_click_xy_human.go" +example: | + conn, _ := browser.CdpConnect(9333) + defer browser.CdpClose(conn, 0) + // Click humanizado en el centro de un elemento detectado por visión (bbox): + browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}) +params: + - name: c + desc: "Conexión CDP activa (de CdpConnect)." + - name: x + desc: "Coordenada X absoluta en la página, en px CSS del viewport." + - name: y + desc: "Coordenada Y absoluta en la página, en px CSS del viewport." + - name: opts + desc: "Opciones de la trayectoria humana (zero-value = defaults). Origen del movimiento via FromX/FromY." +output: "error si el movimiento del ratón o el despacho de eventos falla; nil en éxito." +--- + +## Ejemplo + +```go +conn, _ := browser.CdpConnect(9333) +defer browser.CdpClose(conn, 0) +// El centro del bounding box lo da el #ref del AX tree (DOM.getBoxModel) o la +// detección de visión (OCR/YOLO). Aquí, click humanizado sobre ese punto: +if err := browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}); err != nil { + log.Fatal(err) +} +``` + +## Cuando usarla + +Cuando ya tienes las coordenadas de píxel del objetivo: el centro del bounding box de un elemento +(resuelto por `#ref` del AX outline vía `DOM.getBoxModel`, o detectado por visión OCR/YOLO). Es el +único primitivo de click del agente — no despaches `Input.dispatchMouseEvent` a mano. + +## Gotchas + +- Coordenadas en el sistema de la página (px CSS del viewport), no de pantalla física. +- La humanización añade latencia (movimiento Bézier + micro-pausa). Para scraping masivo de alto + volumen, el llamador debe usar un preset rápido de `MouseHumanOpts` (política de sesión `fast`), + no humanización completa por acción. +- El destino debe estar dentro del viewport visible; haz scroll al elemento antes si hace falta. diff --git a/functions/browser/cdp_hover_ref.go b/functions/browser/cdp_hover_ref.go new file mode 100644 index 00000000..2e28dcd0 --- /dev/null +++ b/functions/browser/cdp_hover_ref.go @@ -0,0 +1,19 @@ +package browser + +import "fmt" + +// CdpHoverRef mueve el ratón con trayectoria humanizada (Bézier) sobre el +// elemento del #ref. Útil para activar menús y tooltips que reaccionan a hover. +// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive. +func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error { + if c == nil { + return fmt.Errorf("cdp hover ref: conexión nil") + } + // scroll al elemento si no está visible; ignorar error (no fatal) + _, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID}) + cx, cy, err := refBoxCenter(c, backendNodeID) + if err != nil { + return fmt.Errorf("cdp hover ref: %w", err) + } + return CdpMoveMouseHuman(c, cx, cy, opts) +} diff --git a/functions/browser/cdp_hover_ref.md b/functions/browser/cdp_hover_ref.md new file mode 100644 index 00000000..e57f74d9 --- /dev/null +++ b/functions/browser/cdp_hover_ref.md @@ -0,0 +1,53 @@ +--- +name: cdp_hover_ref +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error" +description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM." +tags: [cdp, browser, action, ref, humanized, navegator] +uses_functions: [cdp_move_mouse_human_go_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +params: + - name: c + desc: "Conexión CDP activa al tab objetivo." + - name: backendNodeID + desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline." + - name: opts + desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto." +output: "nil si el movimiento de ratón se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el movimiento CDP falla." +file_path: "functions/browser/cdp_hover_ref.go" +--- + +## Ejemplo + +```go +// Activar un menú desplegable cuyo trigger tiene #ref=9999: +conn, _ := CdpConnect(9222) +err := CdpHoverRef(conn, 9999, MouseHumanOpts{}) +if err != nil { + log.Fatal(err) +} +// esperar a que el menú aparezca y re-percibir el outline +``` + +## Cuando usarla + +Tras `page_perceive` / `render_ax_outline`, cuando el agente necesita hacer hover sobre un elemento del `#ref` para revelar contenido oculto (menús, submenús, tooltips, dropdowns) — cierra el bucle percibir→actuar para interacciones hover. Seguir con otro `page_perceive` tras el hover para capturar el nuevo estado del DOM. + +## Gotchas + +- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar. +- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado. El error describe la causa. +- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal). +- Solo mueve el ratón — no hace click. Para activar elementos que requieren click usar `CdpClickRef`. +- Algunos menús hover requieren un pequeño `time.Sleep` o `CdpWaitIdle` tras el hover para que el DOM se actualice antes del siguiente `page_perceive`. diff --git a/functions/browser/cdp_type_ref.go b/functions/browser/cdp_type_ref.go new file mode 100644 index 00000000..aa6ed59d --- /dev/null +++ b/functions/browser/cdp_type_ref.go @@ -0,0 +1,16 @@ +package browser + +import "fmt" + +// CdpTypeRef enfoca el elemento del #ref vía CDP y escribe el texto dado. +// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive. +// Equivale a: focus(ref) → CdpTypeText. El elemento debe aceptar input de texto. +func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error { + if c == nil { + return fmt.Errorf("cdp type ref: conexión nil") + } + if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil { + return fmt.Errorf("cdp type ref: focus ref %d: %w", backendNodeID, err) + } + return CdpTypeText(c, text) +} diff --git a/functions/browser/cdp_type_ref.md b/functions/browser/cdp_type_ref.md new file mode 100644 index 00000000..f9d85760 --- /dev/null +++ b/functions/browser/cdp_type_ref.md @@ -0,0 +1,51 @@ +--- +name: cdp_type_ref +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error" +description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText. El #ref es el backendDOMNodeId estable del nodo DOM. El elemento debe aceptar input de texto (input, textarea, contenteditable)." +tags: [cdp, browser, action, ref, humanized, navegator] +uses_functions: [cdp_type_text_go_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +params: + - name: c + desc: "Conexión CDP activa al tab objetivo." + - name: backendNodeID + desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline." + - name: text + desc: "Texto a escribir en el elemento enfocado. Se envía carácter a carácter simulando escritura humana." +output: "nil si el focus y la escritura se completaron; error si la conexión es nil, DOM.focus falla, o CdpTypeText falla." +file_path: "functions/browser/cdp_type_ref.go" +--- + +## Ejemplo + +```go +// Tras un page_perceive que devuelve un input con #ref=5678: +conn, _ := CdpConnect(9222) +err := CdpTypeRef(conn, 5678, "hola mundo") +if err != nil { + log.Fatal(err) +} +``` + +## Cuando usarla + +Tras `page_perceive` / `render_ax_outline`, cuando el agente quiere escribir en un campo de texto identificado por su `#ref` — cierra el bucle percibir→actuar para inputs. Preferir sobre secuencias manuales `DOM.focus` + `CdpTypeText` cuando el nodo viene directamente del AX outline. + +## Gotchas + +- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar. +- `DOM.focus` falla si el elemento no es focusable (no es `input`, `textarea`, `contenteditable`, o similar). El error indica el ref y la causa. +- Si el elemento necesita un click previo para activarse (algunos inputs con JS custom), combinar con `CdpClickRef` antes de `CdpTypeRef`. +- No hace scroll previo — si el elemento no está visible en el viewport el focus CDP puede fallar en algunos navegadores. Combinar con `CdpClickRef` (que sí hace scroll) si hay dudas. diff --git a/python/functions/core/render_ax_outline.py b/python/functions/core/render_ax_outline.py index 6eafa703..84023b19 100644 --- a/python/functions/core/render_ax_outline.py +++ b/python/functions/core/render_ax_outline.py @@ -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= 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: