chore: auto-commit (286 archivos)

- .claude/agents/fn-orquestador/SKILL.md
- .claude/commands/fn_claude.md
- .claude/rules/INDEX.md
- .claude/rules/cpp_apps.md
- .claude/rules/ids_naming.md
- CHANGELOG.md
- apps/dag_engine/README.md
- apps/dag_engine/api.go
- apps/dag_engine/dags_migrated/example.yaml
- apps/dag_engine/dags_migrated/example_lineage_tracking.yaml
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 16:33:22 +02:00
parent 0b9af8f1bb
commit a03675113a
281 changed files with 12596 additions and 19526 deletions
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_export_to_gcs(client: BQClient, dataset_id: str, table_id: str, destination_uri: str, destination_format: str = 'CSV', compression: str = 'NONE') -> dict"
description: "Exporta una tabla BigQuery a Google Cloud Storage usando extract_table del SDK. Soporta CSV, JSON, Avro y Parquet con compresion opcional."
tags: [bigquery, gcp, export, gcs, google-cloud, python, etl, pendiente-usar]
tags: [bigquery, gcp, export, gcs, google-cloud, python, etl, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_get_table(client: BQClient, dataset_id: str, table_id: str) -> dict"
description: "Obtiene los metadatos completos de una tabla BigQuery incluyendo schema, estadisticas y configuracion. Usa client._client.get_table() del SDK oficial."
tags: [bigquery, gcp, table, get, google-cloud, python, pendiente-usar]
tags: [bigquery, gcp, table, get, google-cloud, python, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_insert_rows(client: BQClient, dataset_id: str, table_id: str, rows: list[dict]) -> dict"
description: "Inserta filas en una tabla BigQuery usando streaming insert (insert_rows_json). Retorna el conteo de filas insertadas y errores por fila."
tags: [bigquery, gcp, insert, streaming, google-cloud, python, pendiente-usar]
tags: [bigquery, gcp, insert, streaming, google-cloud, python, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_list_tables(client: BQClient, dataset_id: str) -> list[dict]"
description: "Lista todas las tablas (y vistas) de un dataset BigQuery con informacion resumida. Usa client._client.list_tables() del SDK oficial."
tags: [bigquery, gcp, table, list, google-cloud, python, pendiente-usar]
tags: [bigquery, gcp, table, list, google-cloud, python, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_load_from_file(client: BQClient, file_path: str, dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
description: "Carga datos desde un archivo local a una tabla BigQuery usando load_table_from_file del SDK. Equivalente a bq_load_from_gcs pero para disco local."
tags: [bigquery, gcp, load, file, google-cloud, python, etl, pendiente-usar]
tags: [bigquery, gcp, load, file, google-cloud, python, etl, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_load_from_gcs(client: BQClient, uri: str | list[str], dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
description: "Carga datos desde uno o varios URIs de Google Cloud Storage a una tabla BigQuery configurando un LoadJob. Espera la finalizacion del job."
tags: [bigquery, gcp, load, gcs, google-cloud, python, etl, pendiente-usar]
tags: [bigquery, gcp, load, gcs, google-cloud, python, etl, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_preview_rows(client: BQClient, dataset_id: str, table_id: str, max_results: int = 10) -> dict"
description: "Obtiene una muestra de filas de una tabla BigQuery sin ejecutar query SQL, sin coste de procesamiento. Usa client._client.list_rows() del SDK oficial."
tags: [bigquery, gcp, table, preview, google-cloud, python, pendiente-usar]
tags: [bigquery, gcp, table, preview, google-cloud, python, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def bq_query(client: BQClient, sql: str, params: list[dict] | None = None, dry_run: bool = False) -> dict"
description: "Ejecuta una query SQL en BigQuery con soporte para parametros tipados y modo dry-run para estimacion de costos."
tags: [bigquery, gcp, query, sql, google-cloud, python, pendiente-usar]
tags: [bigquery, gcp, query, sql, google-cloud, python, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+57
View File
@@ -0,0 +1,57 @@
---
name: chunk_ax_tree
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def chunk_ax_tree(nodes: list[dict], max_chars: int = 25000) -> list[list[dict]]"
description: "Divide una lista de AXNode en chunks de hasta max_chars (serializado JSON). Hace DFS desde raíz y añade nodo 'context' con path-from-root al inicio de cada chunk no inicial."
tags: [navegator, accessibility, chunking, pure, ax-tree, llm]
uses_functions: [trim_ax_tree_py_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [json]
params:
- name: nodes
desc: "Lista de AXNode en formato CDP (con nodeId, childIds, role, name). Usar después de trim_ax_tree para reducir ruido."
- name: max_chars
desc: "Límite de caracteres al serializar el chunk a JSON. Default 25000 (~6k tokens)."
output: "Lista de chunks. Cada chunk es lista de AXNode. Los chunks no iniciales arrancan con un nodo sintético role='context' que contiene el path desde la raíz."
tested: true
tests:
- "lista vacia devuelve lista vacia"
- "nodos que caben en un chunk devuelven un solo chunk"
- "nodos que no caben generan multiples chunks"
- "cada chunk serializado no supera max_chars mas overhead"
test_file_path: "python/functions/core/chunk_ax_tree_test.py"
file_path: "python/functions/core/chunk_ax_tree.py"
---
## Ejemplo
```python
from core.trim_ax_tree import trim_ax_tree
from core.chunk_ax_tree import chunk_ax_tree
# Obtener AX tree de CDP, luego:
trimmed = trim_ax_tree(raw_nodes)
chunks = chunk_ax_tree(trimmed, max_chars=25000)
for i, chunk in enumerate(chunks):
print(f"Chunk {i}: {len(chunk)} nodos")
# Enviar chunk a claude_cli_prompt o a la API de Claude
```
## Cuando usarla
Cuando el AX tree de una página compleja supera el context window del modelo (>25k chars). Chunkearlo permite procesar secciones independientes manteniendo el path-from-root como contexto para que el LLM entienda la estructura jerárquica.
## Gotchas
- El nodo `context` sintético (role="context") al inicio de cada chunk no es un nodo CDP real — filtrarlo si se necesita reconstruir el árbol.
- `max_chars` es límite aproximado: el nodo de contexto puede hacer que el chunk supere ligeramente el límite. Se recomienda un 10-20% de margen.
- Usar `trim_ax_tree` antes para reducir el número de nodos y mejorar la utilidad de cada chunk.
- Árboles muy profundos con pocos nodos por nivel generan chunks con mucho overhead de contexto.
+91
View File
@@ -0,0 +1,91 @@
"""Divide una lista de AXNode en chunks de tamaño máximo para enviar a LLMs."""
import json
def chunk_ax_tree(nodes: list[dict], max_chars: int = 25000) -> list[list[dict]]:
"""Splittea AX tree en chunks de hasta max_chars cuando se serializa a JSON.
Estrategia DFS desde el nodo raíz:
- Acumula nodos en el chunk actual.
- Cuando añadir el siguiente subárbol superaría max_chars, cierra el chunk
y abre uno nuevo.
- Cada chunk incluye al inicio los nodos "context" (path desde root hasta
el primer nodo del chunk) para que el LLM tenga orientación estructural.
Args:
nodes: Lista de AXNode en formato CDP (con nodeId, childIds, role, name).
max_chars: Límite de caracteres al serializar el chunk a JSON. Default 25000.
Returns:
Lista de chunks. Cada chunk es una lista de AXNode. El primer elemento de
cada chunk (salvo el primero) es un nodo sintético con role="context" que
contiene el path desde la raíz.
"""
if not nodes:
return []
# Construir lookup y encontrar raíz
by_id: dict[str, dict] = {n["nodeId"]: n for n in nodes}
all_children: set[str] = set()
for n in nodes:
for cid in n.get("childIds", []):
all_children.add(cid)
roots = [n for n in nodes if n["nodeId"] not in all_children]
if not roots:
# Fallback: usar el primer nodo como raíz
roots = [nodes[0]]
# DFS para obtener orden de visita + path desde raíz
visit_order: list[tuple[dict, list[dict]]] = [] # (nodo, ancestors)
def dfs(node: dict, ancestors: list[dict]) -> None:
visit_order.append((node, list(ancestors)))
for cid in node.get("childIds", []):
child = by_id.get(cid)
if child:
dfs(child, ancestors + [node])
for root in roots:
dfs(root, [])
# Agrupar en chunks respetando max_chars
chunks: list[list[dict]] = []
current_chunk: list[dict] = []
current_chars: int = 2 # "[]"
for node, ancestors in visit_order:
node_json = json.dumps(node, ensure_ascii=False)
node_chars = len(node_json) + 2 # ", " separator
if current_chunk and current_chars + node_chars > max_chars:
chunks.append(current_chunk)
# Nuevo chunk con contexto (path desde root)
current_chunk = []
current_chars = 2
if ancestors:
context_node = {
"nodeId": "context",
"role": {"value": "context"},
"name": {"value": "path-from-root"},
"childIds": [],
"context_path": [
{
"nodeId": a["nodeId"],
"role": a.get("role", {}).get("value", "") if isinstance(a.get("role"), dict) else str(a.get("role", "")),
"name": a.get("name", {}).get("value", "") if isinstance(a.get("name"), dict) else str(a.get("name", "")),
}
for a in ancestors
],
}
ctx_chars = len(json.dumps(context_node, ensure_ascii=False)) + 2
current_chunk.append(context_node)
current_chars += ctx_chars
current_chunk.append(node)
current_chars += node_chars
if current_chunk:
chunks.append(current_chunk)
return chunks
@@ -0,0 +1,75 @@
"""Tests para chunk_ax_tree."""
import json
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from chunk_ax_tree import chunk_ax_tree
def _node(node_id: str, child_ids=None) -> dict:
return {
"nodeId": node_id,
"role": {"value": "generic"},
"name": {"value": f"node-{node_id}"},
"childIds": child_ids or [],
}
def _make_linear_tree(n: int) -> list[dict]:
"""Cadena lineal: 0->1->2->...->n-1"""
nodes = []
for i in range(n):
child = [str(i + 1)] if i < n - 1 else []
nodes.append(_node(str(i), child))
return nodes
def test_lista_vacia_devuelve_lista_vacia():
result = chunk_ax_tree([])
assert result == [], f"Expected [], got {result}"
def test_nodos_que_caben_en_un_chunk_devuelven_un_solo_chunk():
nodes = _make_linear_tree(3)
result = chunk_ax_tree(nodes, max_chars=10000)
assert len(result) == 1, f"Expected 1 chunk, got {len(result)}"
# Todos los nodos deben estar presentes
ids_in_chunk = [n["nodeId"] for n in result[0]]
for node in nodes:
assert node["nodeId"] in ids_in_chunk, f"{node['nodeId']} missing from chunk"
def test_nodos_que_no_caben_generan_multiples_chunks():
# 100 nodos, cada uno ~80 chars serializado -> ~8000 chars total
# con max_chars=200 debe generar varios chunks
nodes = [_node(str(i)) for i in range(100)]
# Hacer árbol plano: raíz con todos como hijos
nodes[0]["childIds"] = [str(i) for i in range(1, 100)]
result = chunk_ax_tree(nodes, max_chars=200)
assert len(result) > 1, f"Expected >1 chunks, got {len(result)}"
def test_cada_chunk_serializado_no_supera_max_chars_mas_overhead():
nodes = [_node(str(i)) for i in range(100)]
nodes[0]["childIds"] = [str(i) for i in range(1, 100)]
max_chars = 500
result = chunk_ax_tree(nodes, max_chars=max_chars)
overhead_factor = 1.5 # permitir 50% de overhead por nodo contexto
for i, chunk in enumerate(result):
serialized = json.dumps(chunk, ensure_ascii=False)
limit = max_chars * overhead_factor
assert len(serialized) < limit, (
f"Chunk {i} tiene {len(serialized)} chars, supera {limit} "
f"(max_chars={max_chars}, overhead_factor={overhead_factor})"
)
if __name__ == "__main__":
test_lista_vacia_devuelve_lista_vacia()
test_nodos_que_caben_en_un_chunk_devuelven_un_solo_chunk()
test_nodos_que_no_caben_generan_multiples_chunks()
test_cada_chunk_serializado_no_supera_max_chars_mas_overhead()
print("All tests passed.")
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def coerce_types(data: dict, schema: dict[str, str]) -> tuple[dict, list[str]]"
description: "Convierte valores de un dict a los tipos esperados segun un schema declarativo. Soporta int, float, str, bool, datetime, list[str]. Util para normalizar datos de CSV, JSON o query params. Nunca muta el original. Coerciones imposibles generan warning y mantienen el valor original."
tags: [coercion, types, normalization, pure, core, csv, json, pendiente-usar]
tags: [coercion, types, normalization, pure, core, csv, json, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "csv_to_parquet_duckdb(csv_path: str | Path, parquet_path: str | Path, column_casts: dict[str, str] | None = None, overwrite: bool = False) -> bool"
description: "Convierte un CSV a Parquet usando DuckDB read_csv_auto. Si overwrite=False y el parquet ya existe no hace nada. column_casts permite sobreescribir tipos inferidos por columna. Retorna True si escribió."
tags: [csv, parquet, duckdb, etl, core, pendiente-usar]
tags: [csv, parquet, duckdb, etl, core, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "from_csv(text: str, delimiter: str = ',', has_header: bool = True) -> list[dict]"
description: "Parser CSV a datos tabulares. Complemento de to_csv. Soporta campos entre comillas con escaping RFC 4180. Si has_header=False, genera keys col_0, col_1, etc."
tags: [csv, parser, import, tabular, format, pendiente-usar]
tags: [csv, parser, import, tabular, format, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
@@ -0,0 +1,53 @@
---
name: infer_json_rows_schema
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def infer_json_rows_schema(data: object) -> dict"
description: "Dado un JSON deserializado, localiza el primer array de objetos con >=1 elementos y devuelve su schema: root_path, fields [{name, type, sample}], count."
tags: [navegator, json, schema, scraping, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: data
desc: "JSON deserializado: dict, list, o cualquier valor Python resultante de json.loads()."
output: "dict con {root_path: str, fields: [{name, type, sample}], count: int}. Si no encuentra array de objetos devuelve root_path='' y fields=[]."
tested: true
tests:
- "lista de dicts directa retorna root_path dolar"
- "dict con clave results retorna root_path dolar punto results"
- "input vacio retorna schema vacio"
test_file_path: "python/functions/core/infer_json_rows_schema_test.py"
file_path: "python/functions/core/infer_json_rows_schema.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from core.infer_json_rows_schema import infer_json_rows_schema
schema = infer_json_rows_schema([{"id": 1, "name": "foo"}])
# {"root_path": "$", "fields": [{"name": "id", "type": "int", "sample": "1"}, ...], "count": 1}
schema2 = infer_json_rows_schema({"results": [{"a": "x"}]})
# {"root_path": "$.results", "fields": [...], "count": 1}
```
## Cuando usarla
Cuando recibes JSON de una API o scraping y necesitas descubrir automaticamente que campos contiene y sus tipos, sin conocer la estructura de antemano. Paso previo a generar un recipe YAML o mapear campos a un schema destino.
## Notas
- Prefiere claves `results, items, data, records, rows, entries` en DFS.
- Tipos: string|int|float|bool|null|array|object.
- `sample` truncado a 80 chars de la primera fila.
- Funcion pura: sin I/O, sin estado.
@@ -0,0 +1,79 @@
"""Infiere schema de un JSON deserializado localizando el primer array de objetos."""
def _type(value) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
return "int"
if isinstance(value, float):
return "float"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
return "string"
def _truncate(value, max_chars: int = 80) -> str:
s = str(value)
return s[:max_chars] if len(s) > max_chars else s
_PREFERRED_KEYS = ("results", "items", "data", "records", "rows", "entries")
def _find_array_of_dicts(obj, path: str = "$"):
"""DFS buscando el primer array de dicts con >=1 elementos."""
if isinstance(obj, list) and len(obj) >= 1 and isinstance(obj[0], dict):
return path, obj
if isinstance(obj, dict):
# Primero probar claves preferidas en orden
for key in _PREFERRED_KEYS:
if key in obj:
result = _find_array_of_dicts(obj[key], f"{path}.{key}")
if result is not None:
return result
# Luego resto de claves
for key, val in obj.items():
if key in _PREFERRED_KEYS:
continue
result = _find_array_of_dicts(val, f"{path}.{key}")
if result is not None:
return result
return None
def infer_json_rows_schema(data: object) -> dict:
"""Dado un JSON deserializado, localiza el primer array de objetos con >=1 elementos.
Args:
data: JSON deserializado (dict, list, u otro valor Python).
Returns:
Schema con root_path, fields y count. Si no encuentra array, devuelve
root_path vacío y fields vacío.
"""
result = _find_array_of_dicts(data)
if result is None:
return {"root_path": "", "fields": [], "count": 0}
root_path, rows = result
first_row = rows[0]
fields = []
for name, value in first_row.items():
fields.append({
"name": name,
"type": _type(value),
"sample": _truncate(value),
})
return {
"root_path": root_path,
"fields": fields,
"count": len(rows),
}
@@ -0,0 +1,62 @@
"""Tests para infer_json_rows_schema."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from core.infer_json_rows_schema import infer_json_rows_schema
def test_lista_de_dicts_directa_retorna_root_path_dolar():
data = [{"a": 1, "b": "x"}]
result = infer_json_rows_schema(data)
assert result["root_path"] == "$"
assert len(result["fields"]) == 2
fields_by_name = {f["name"]: f for f in result["fields"]}
assert fields_by_name["a"]["type"] == "int"
assert fields_by_name["b"]["type"] == "string"
assert result["count"] == 1
def test_dict_con_clave_results_retorna_root_path_dolar_punto_results():
data = {"results": [{"id": 42, "label": "foo"}]}
result = infer_json_rows_schema(data)
assert result["root_path"] == "$.results"
assert result["count"] == 1
fields_by_name = {f["name"]: f for f in result["fields"]}
assert fields_by_name["id"]["type"] == "int"
assert fields_by_name["label"]["type"] == "string"
def test_input_vacio_retorna_schema_vacio():
assert infer_json_rows_schema({}) == {"root_path": "", "fields": [], "count": 0}
assert infer_json_rows_schema([]) == {"root_path": "", "fields": [], "count": 0}
def test_tipos_varios():
data = [{"s": "hello", "i": 1, "f": 1.5, "b": True, "n": None, "arr": [1], "obj": {"x": 1}}]
result = infer_json_rows_schema(data)
by_name = {f["name"]: f["type"] for f in result["fields"]}
assert by_name["s"] == "string"
assert by_name["i"] == "int"
assert by_name["f"] == "float"
assert by_name["b"] == "bool"
assert by_name["n"] == "null"
assert by_name["arr"] == "array"
assert by_name["obj"] == "object"
def test_sample_truncado():
long_val = "x" * 100
data = [{"field": long_val}]
result = infer_json_rows_schema(data)
assert len(result["fields"][0]["sample"]) == 80
if __name__ == "__main__":
test_lista_de_dicts_directa_retorna_root_path_dolar()
test_dict_con_clave_results_retorna_root_path_dolar_punto_results()
test_input_vacio_retorna_schema_vacio()
test_tipos_varios()
test_sample_truncado()
print("All tests passed.")
+55
View File
@@ -0,0 +1,55 @@
---
name: trim_ax_tree
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def trim_ax_tree(nodes: list[dict]) -> list[dict]"
description: "Compacta lista de AXNode CDP descartando nodos ignorados, roles genéricos sin contenido y StaticText vacíos. Colapsa nodos con un único hijo del mismo role."
tags: [navegator, accessibility, dom, scraping, pure, ax-tree]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: nodes
desc: "Lista de AXNode en formato CDP (Accessibility.getFullAXTree). Cada nodo es un dict con nodeId, ignored, role, name, childIds."
output: "Lista reducida de AXNode con misma estructura. Nodos descartados y colapsados segun las reglas de trimming."
tested: true
tests:
- "descarta nodo generic sin name ni childIds"
- "mantiene nodo button con name"
- "descarta nodo ignored"
- "descarta StaticText con name vacio"
- "colapsa nodo con un hijo del mismo role"
test_file_path: "python/functions/core/trim_ax_tree_test.py"
file_path: "python/functions/core/trim_ax_tree.py"
---
## Ejemplo
```python
from core.trim_ax_tree import trim_ax_tree
nodes = [
{"nodeId": "1", "ignored": False, "role": {"value": "RootWebArea"}, "name": {"value": ""}, "childIds": ["2", "3"]},
{"nodeId": "2", "ignored": False, "role": {"value": "generic"}, "name": {"value": ""}, "childIds": []},
{"nodeId": "3", "ignored": False, "role": {"value": "button"}, "name": {"value": "Enviar"}, "childIds": []},
]
result = trim_ax_tree(nodes)
# result: nodo "2" descartado, quedan "1" y "3"
print([n["nodeId"] for n in result]) # ["1", "3"]
```
## Cuando usarla
Antes de pasar un AX tree a un LLM o de chunkear con `chunk_ax_tree` — reduce el numero de nodos eliminando ruido (wrappers genéricos, texto vacío, nodos ignorados) y mejora la relacion señal/ruido del contexto enviado al modelo.
## Gotchas
- Funcion pura: no modifica los dicts originales (crea copias al fusionar).
- El colapso iterativo puede cambiar la profundidad del arbol — parentIds de nodos externos no se actualizan.
- Los nodos cuyo nodeId ya no existe en el resultado pero aparecen como childIds de otros nodos quedan como referencias colgantes — filtrar antes de usar el arbol para navegacion.
+93
View File
@@ -0,0 +1,93 @@
"""Compacta una lista de AXNode CDP descartando nodos irrelevantes."""
def trim_ax_tree(nodes: list[dict]) -> list[dict]:
"""Compacta lista de AXNode (formato CDP Accessibility.getFullAXTree).
Descarta:
- Nodos con ignored=true.
- role 'generic' o 'none' sin name no-vacio Y sin childIds.
- role 'StaticText' con name vacio.
Colapsa: si un nodo tiene exactamente 1 hijo y el mismo role, fusiona
el hijo en el padre (hereda childIds del hijo, descarta el nodo hijo).
Preserva: nodeId, parentId, childIds, role.value, name.value, value.value.
Args:
nodes: Lista de AXNode en formato CDP. Cada nodo es un dict con
campos 'nodeId', 'ignored', 'role', 'name', 'childIds', etc.
Returns:
Lista reducida de AXNode con la misma estructura de campos.
"""
if not nodes:
return []
def _role(node: dict) -> str:
r = node.get("role", {})
if isinstance(r, dict):
return r.get("value", "")
return str(r)
def _name(node: dict) -> str:
n = node.get("name", {})
if isinstance(n, dict):
return n.get("value", "")
return str(n) if n else ""
def _child_ids(node: dict) -> list[str]:
return node.get("childIds", [])
# Paso 1: descartar nodos ignorados y roles descartables
def _should_discard(node: dict) -> bool:
if node.get("ignored", False):
return True
role = _role(node)
name = _name(node)
child_ids = _child_ids(node)
if role in ("generic", "none") and not name and not child_ids:
return True
if role == "StaticText" and not name:
return True
return False
kept = [n for n in nodes if not _should_discard(n)]
# Construir lookup por nodeId para el paso de colapso
by_id: dict[str, dict] = {n["nodeId"]: n for n in kept}
# Paso 2: colapso de nodo con 1 solo hijo del mismo role
# Iteramos hasta que no haya mas fusiones (convergencia)
changed = True
while changed:
changed = False
new_kept = []
removed: set[str] = set()
for node in by_id.values():
if node["nodeId"] in removed:
continue
child_ids = _child_ids(node)
if len(child_ids) == 1:
child_id = child_ids[0]
child = by_id.get(child_id)
if child and _role(child) == _role(node):
# Fusionar: heredar childIds del hijo
merged = dict(node)
merged["childIds"] = _child_ids(child)
by_id[node["nodeId"]] = merged
removed.add(child_id)
changed = True
if changed:
by_id = {k: v for k, v in by_id.items() if k not in removed}
# Preservar orden original
original_order = [n["nodeId"] for n in nodes]
result = []
seen: set[str] = set()
for nid in original_order:
if nid in by_id and nid not in seen:
result.append(by_id[nid])
seen.add(nid)
return result
@@ -0,0 +1,87 @@
"""Tests para trim_ax_tree."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from trim_ax_tree import trim_ax_tree
def _node(node_id: str, role: str, name: str = "", child_ids=None, ignored: bool = False) -> dict:
return {
"nodeId": node_id,
"ignored": ignored,
"role": {"value": role},
"name": {"value": name},
"childIds": child_ids or [],
}
def test_descarta_nodo_generic_sin_name_ni_childIds():
nodes = [
_node("1", "RootWebArea", "", ["2"]),
_node("2", "generic", "", []),
]
result = trim_ax_tree(nodes)
ids = [n["nodeId"] for n in result]
assert "2" not in ids, f"generic sin name/childIds debe descartarse, got {ids}"
def test_mantiene_nodo_button_con_name():
nodes = [
_node("1", "RootWebArea", "", ["2"]),
_node("2", "button", "Click me", []),
]
result = trim_ax_tree(nodes)
ids = [n["nodeId"] for n in result]
assert "2" in ids, f"button con name debe mantenerse, got {ids}"
def test_descarta_nodo_ignored():
nodes = [
_node("1", "RootWebArea", "", ["2"]),
_node("2", "button", "Visible", [], ignored=True),
]
result = trim_ax_tree(nodes)
ids = [n["nodeId"] for n in result]
assert "2" not in ids, f"nodo ignored debe descartarse, got {ids}"
def test_descarta_StaticText_con_name_vacio():
nodes = [
_node("1", "RootWebArea", "", ["2", "3"]),
_node("2", "StaticText", "", []),
_node("3", "StaticText", "Hola", []),
]
result = trim_ax_tree(nodes)
ids = [n["nodeId"] for n in result]
assert "2" not in ids, f"StaticText vacio debe descartarse, got {ids}"
assert "3" in ids, f"StaticText con name debe mantenerse, got {ids}"
def test_colapsa_nodo_con_un_hijo_del_mismo_role():
nodes = [
_node("1", "RootWebArea", "", ["2"]),
_node("2", "group", "", ["3"]),
_node("3", "group", "", ["4"]),
_node("4", "button", "OK", []),
]
result = trim_ax_tree(nodes)
ids = [n["nodeId"] for n in result]
# Nodo 2 y 3 tienen mismo role y uno tiene 1 solo hijo — deben colapsar
assert "4" in ids, f"button debe estar presente, got {ids}"
# Tras colapso, el nodo raiz group debe tener childIds apuntando a button
root_group = next((n for n in result if n["nodeId"] in ("2", "3")), None)
if root_group:
assert "4" in root_group.get("childIds", []), \
f"grupo colapsado debe tener button como hijo, got {root_group}"
if __name__ == "__main__":
test_descarta_nodo_generic_sin_name_ni_childIds()
test_mantiene_nodo_button_con_name()
test_descarta_nodo_ignored()
test_descarta_StaticText_con_name_vacio()
test_colapsa_nodo_con_un_hijo_del_mismo_role()
print("All tests passed.")
@@ -0,0 +1,61 @@
---
name: validate_recipe_yaml
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def validate_recipe_yaml(yaml_text: str) -> dict"
description: "Parsea y valida recipe YAML del navegator. Comprueba name (snake_case), url_pattern (regex valido), steps (js o wait_selector), output.schema y output.sink."
tags: [navegator, recipe, validation, yaml, pure, validator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re, yaml]
params:
- name: yaml_text
desc: "Texto completo de la recipe en formato YAML."
output: "dict {valid: bool, errors: [str], warnings: [str], parsed: dict}. valid=True solo si errors esta vacio."
tested: true
tests:
- "recipe valida minima retorna valid true"
- "name ausente produce error"
- "url_pattern invalido produce error"
- "step sin js ni wait_selector produce error"
- "sink invalido produce error"
test_file_path: "python/functions/core/validate_recipe_yaml_test.py"
file_path: "python/functions/core/validate_recipe_yaml.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from core.validate_recipe_yaml import validate_recipe_yaml
yaml_text = """
name: product_list
url_pattern: "https://shop\\.example\\.com/.*"
steps:
- wait_selector: ".product-card"
- js: "Array.from(document.querySelectorAll('.product-card')).map(e => e.innerText)"
output:
sink: stdout
"""
result = validate_recipe_yaml(yaml_text)
# {"valid": True, "errors": [], "warnings": [], "parsed": {...}}
```
## Cuando usarla
Antes de ejecutar una recipe con `cdp_extract_recipe`: validar el YAML en seco sin necesitar Chrome. Tambien util en un editor de recipes para dar feedback al usuario en tiempo real.
## Gotchas
- Requiere `pyyaml` en el entorno; si falta devuelve `valid=False` con mensaje claro (no lanza excepcion).
- Funcion pura: no accede a red ni a disco.
- `url_pattern` se compila con `re.compile` — patrones validos en Python pero invalidos en otros motores no se detectan aqui.
@@ -0,0 +1,124 @@
"""Parsea y valida recipe YAML del navegator."""
import re
def validate_recipe_yaml(yaml_text: str) -> dict:
"""Parsea + valida recipe YAML del navegator.
Args:
yaml_text: Texto YAML de la recipe.
Returns:
{valid: bool, errors: [str], warnings: [str], parsed: dict}
"""
try:
import yaml
except ImportError:
return {
"valid": False,
"errors": ["pyyaml no disponible: instalar con 'pip install pyyaml'"],
"warnings": [],
"parsed": {},
}
errors = []
warnings = []
parsed = {}
try:
parsed = yaml.safe_load(yaml_text) or {}
except yaml.YAMLError as e:
return {
"valid": False,
"errors": [f"YAML invalido: {e}"],
"warnings": [],
"parsed": {},
}
if not isinstance(parsed, dict):
return {
"valid": False,
"errors": ["El YAML debe ser un objeto (dict) en el nivel raiz."],
"warnings": [],
"parsed": parsed,
}
# name obligatorio, snake_case
name = parsed.get("name")
if not name:
errors.append("Campo 'name' obligatorio.")
elif not isinstance(name, str):
errors.append("Campo 'name' debe ser string.")
elif not re.fullmatch(r"[a-z][a-z0-9_]*", name):
errors.append(f"Campo 'name' debe ser snake_case (solo [a-z0-9_], empieza con letra): '{name}'.")
# url_pattern obligatorio, regex valido
url_pattern = parsed.get("url_pattern")
if not url_pattern:
errors.append("Campo 'url_pattern' obligatorio.")
elif not isinstance(url_pattern, str):
errors.append("Campo 'url_pattern' debe ser string.")
else:
try:
re.compile(url_pattern)
except re.error as e:
errors.append(f"Campo 'url_pattern' no es un regex valido: {e}")
# steps lista no vacia, cada step requiere js O wait_selector
steps = parsed.get("steps")
if not steps:
errors.append("Campo 'steps' obligatorio y no puede estar vacio.")
elif not isinstance(steps, list):
errors.append("Campo 'steps' debe ser una lista.")
else:
for i, step in enumerate(steps):
if not isinstance(step, dict):
errors.append(f"Step {i}: debe ser un objeto.")
continue
has_js = "js" in step
has_wait = "wait_selector" in step
if not has_js and not has_wait:
errors.append(f"Step {i}: requiere 'js' o 'wait_selector'.")
if has_js and has_wait:
warnings.append(f"Step {i}: tiene tanto 'js' como 'wait_selector'; se usara 'js'.")
# output opcional pero validado si presente
output = parsed.get("output")
if output is not None:
if not isinstance(output, dict):
errors.append("Campo 'output' debe ser un objeto.")
else:
schema = output.get("schema")
if schema is not None:
if not isinstance(schema, list):
errors.append("Campo 'output.schema' debe ser una lista.")
else:
valid_types = {"string", "int", "float", "bool", "date"}
for j, item in enumerate(schema):
if not isinstance(item, dict):
errors.append(f"output.schema[{j}]: debe ser un objeto.")
continue
if "field" not in item:
errors.append(f"output.schema[{j}]: falta campo 'field'.")
if "type" not in item:
errors.append(f"output.schema[{j}]: falta campo 'type'.")
elif item["type"] not in valid_types:
warnings.append(
f"output.schema[{j}]: tipo '{item['type']}' no reconocido "
f"(esperado: {sorted(valid_types)})."
)
sink = output.get("sink")
valid_sinks = {"data_factory.runs", "stdout", "json_file"}
if sink is not None and sink not in valid_sinks:
errors.append(
f"Campo 'output.sink' debe ser uno de {sorted(valid_sinks)}, got '{sink}'."
)
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"parsed": parsed,
}
@@ -0,0 +1,115 @@
"""Tests para validate_recipe_yaml."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from core.validate_recipe_yaml import validate_recipe_yaml
VALID_YAML = """
name: product_list
url_pattern: 'https://shop\\.example\\.com/.*'
steps:
- wait_selector: ".product-card"
- js: "document.querySelector('.price').innerText"
output:
sink: stdout
"""
def test_recipe_valida_minima_retorna_valid_true():
result = validate_recipe_yaml(VALID_YAML)
assert result["valid"] is True
assert result["errors"] == []
assert isinstance(result["parsed"], dict)
assert result["parsed"]["name"] == "product_list"
def test_name_ausente_produce_error():
yaml_text = """
url_pattern: "https://example.com"
steps:
- js: "1+1"
"""
result = validate_recipe_yaml(yaml_text)
assert result["valid"] is False
assert any("name" in e for e in result["errors"])
def test_url_pattern_invalido_produce_error():
yaml_text = """
name: my_recipe
url_pattern: "[invalid("
steps:
- js: "1"
"""
result = validate_recipe_yaml(yaml_text)
assert result["valid"] is False
assert any("url_pattern" in e for e in result["errors"])
def test_step_sin_js_ni_wait_selector_produce_error():
yaml_text = """
name: bad_steps
url_pattern: "https://example.com"
steps:
- description: "solo descripcion sin accion"
"""
result = validate_recipe_yaml(yaml_text)
assert result["valid"] is False
assert any("js" in e or "wait_selector" in e for e in result["errors"])
def test_sink_invalido_produce_error():
yaml_text = """
name: bad_sink
url_pattern: "https://example.com"
steps:
- js: "1"
output:
sink: invalid_sink
"""
result = validate_recipe_yaml(yaml_text)
assert result["valid"] is False
assert any("sink" in e for e in result["errors"])
def test_name_no_snake_case_produce_error():
yaml_text = """
name: MyRecipe
url_pattern: "https://example.com"
steps:
- js: "1"
"""
result = validate_recipe_yaml(yaml_text)
assert result["valid"] is False
assert any("snake_case" in e for e in result["errors"])
def test_output_schema_valido():
yaml_text = """
name: with_schema
url_pattern: "https://example.com"
steps:
- js: "1"
output:
sink: stdout
schema:
- field: price
type: float
- field: name
type: string
"""
result = validate_recipe_yaml(yaml_text)
assert result["valid"] is True
if __name__ == "__main__":
test_recipe_valida_minima_retorna_valid_true()
test_name_ausente_produce_error()
test_url_pattern_invalido_produce_error()
test_step_sin_js_ni_wait_selector_produce_error()
test_sink_invalido_produce_error()
test_name_no_snake_case_produce_error()
test_output_schema_valido()
print("All tests passed.")
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def aggregate_by_group(rows: list[dict], group_by: list[str], aggs: dict[str, str]) -> list[dict]"
description: "GROUP BY + agregaciones sobre datos tabulares. aggs es un dict de columna a funcion (sum, mean, count, min, max, first, last, collect). collect acumula valores en lista. None se ignora en agregaciones numericas."
tags: [datascience, tabular, groupby, aggregate, transform, python, pendiente-usar]
tags: [datascience, tabular, groupby, aggregate, transform, python, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def align_relations_to_entities(triplets: list[dict], entity_names: list[str]) -> list[dict]"
description: "Filtra y alinea triplets REBEL/mREBEL a nombres canonicos de entidades. Para cada triplet, resuelve head y tail contra entity_names con match exacto case-insensitive o substring (gana el nombre mas largo). Descarta triplets donde algun lado no resuelve o head==tail."
tags: [rebel, mrebel, relation-extraction, nlp, align, knowledge-graph, datascience, python]
tags: [rebel, mrebel, relation-extraction, nlp, align, knowledge-graph, datascience, python, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def clip(data: list, lo: float, hi: float) -> list"
description: "Recorta los valores de la lista al rango [lo, hi]."
tags: [clipping, bounds, python, pendiente-usar]
tags: [clipping, bounds, python, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def deduplicate_entities(candidates: list[EntityCandidate], name_threshold: float = 0.85, same_type_only: bool = True) -> DeduplicationResult"
description: "Agrupa entidades candidatas que refieren a la misma entidad real usando fuzzy matching de nombres (Levenshtein + Jaccard) y Union-Find para clusters transitivos. Retorna entidades mergeadas con mapas de resolucion de IDs y log de merges."
tags: [deduplication, entity, fuzzy, levenshtein, jaccard, union-find, knowledge-graph, nlp, fuzzygraph, datascience]
tags: [deduplication, entity, fuzzy, levenshtein, jaccard, union-find, knowledge-graph, nlp, fuzzygraph, datascience, transformer]
uses_functions:
- normalize_entity_name_py_core
- merge_entity_attributes_py_core
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def deduplicate_relations(relations: list[RelationCandidate], entity_id_map: dict[str, str]) -> list[RelationCandidate]"
description: "Deduplica relaciones candidatas resolviendo from_name/to_name a entity IDs finales via entity_id_map. Descarta self-loops y relaciones sin match. Mergea duplicados (mismo from_id, to_id, relation_type) concatenando descripciones unicas y tomando max confidence."
tags: [datascience, extraction, knowledge-graph, nlp, deduplication, fuzzy-match, fuzzygraph]
tags: [datascience, extraction, knowledge-graph, nlp, deduplication, fuzzy-match, fuzzygraph, transformer]
uses_functions:
- levenshtein_distance_py_cybersecurity
uses_types:
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def detect_drift(history: list[dict], current: dict, fields: list[str], threshold: float = 2.0) -> list[dict]"
description: "Detecta drift estadistico comparando metricas de la ejecucion actual contra el historial usando z-score. Si |z| > threshold, el campo ha drifteado. Util para monitorizar executions en operations.db."
tags: [drift, statistics, z-score, monitoring, executions, operations, datascience, pendiente-usar]
tags: [drift, statistics, z-score, monitoring, executions, operations, datascience, pendiente-usar, validator]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def detect_outliers(data: list, threshold: float) -> list"
description: "Detecta outliers por z-score. Retorna lista de bools, True donde |z-score| > threshold."
tags: [statistics, outliers, python, pendiente-usar]
tags: [statistics, outliers, python, pendiente-usar, validator]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def diff_entities(before: list[dict], after: list[dict], key: str = 'id', ignore_fields: list[str] | None = None, compare_fields: list[str] | None = None) -> dict"
description: "Compara dos snapshots de entities y devuelve diferencias campo a campo. Detecta añadidas, eliminadas, modificadas e inalteradas. Ignora created_at y updated_at por defecto."
tags: [diff, entities, snapshot, operations, comparison, datascience, pendiente-usar]
tags: [diff, entities, snapshot, operations, comparison, datascience, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def diff_relations(before: list[dict], after: list[dict], key: tuple[str, str, str] = ('source_id', 'target_id', 'relation_type'), ignore_fields: list[str] | None = None, compare_fields: list[str] | None = None) -> dict"
description: "Compara relaciones entre dos snapshots usando key compuesta (source_id, target_id, relation_type). Detecta relaciones añadidas, eliminadas y modificadas con detalle campo a campo."
tags: [diff, relations, graph, snapshot, operations, comparison, datascience, pendiente-usar]
tags: [diff, relations, graph, snapshot, operations, comparison, datascience, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def histogram(data: list, buckets: int) -> list"
description: "Calcula histograma con N buckets. Retorna lista de conteos por bucket."
tags: [statistics, histogram, python, pendiente-usar]
tags: [statistics, histogram, python, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "def impute(data: list) -> list"
description: "Reemplaza None y NaN con la media de los valores validos."
tags: [imputation, missing, python, pendiente-usar]
tags: [imputation, missing, python, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+2
View File
@@ -1,6 +1,8 @@
from .setup_logger import setup_logger, get_logger
from .generate_app_icon import generate_app_icon
__all__ = [
"setup_logger",
"get_logger",
"generate_app_icon",
]
@@ -0,0 +1,62 @@
---
name: claude_cli_prompt
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def claude_cli_prompt(prompt: str, timeout_s: int = 60, model: str | None = None, max_chars_response: int = 200_000, extra_args: list[str] | None = None) -> str"
description: "Invoca `claude -p` via subprocess y devuelve la respuesta completa como string. Valida presencia del CLI, captura stderr en errores, y trunca respuestas largas."
tags: [claude, llm, cli, ai, navegator, subprocess]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [shutil, subprocess]
params:
- name: prompt
desc: "Texto del prompt a enviar a Claude."
- name: timeout_s
desc: "Segundos antes de raise TimeoutExpired. Default 60."
- name: model
desc: "Modelo a usar (ej. claude-opus-4-5). None usa el default del CLI."
- name: max_chars_response
desc: "Trunca stdout a este numero de caracteres. Default 200_000."
- name: extra_args
desc: "Lista de argumentos adicionales para el CLI."
output: "Respuesta de Claude como texto plano (stdout del proceso)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/claude_cli_prompt.py"
---
## Ejemplo
```python
from infra.claude_cli_prompt import claude_cli_prompt
# Pregunta simple
respuesta = claude_cli_prompt("Suma 2+2 y devuelve solo el numero")
print(respuesta) # "4"
# Con modelo especifico y timeout extendido
respuesta = claude_cli_prompt(
prompt="Resume este texto en 3 puntos: ...",
model="claude-opus-4-5",
timeout_s=120,
)
```
## Cuando usarla
Cuando necesitas invocar Claude desde un script Python sin mantener sesion de chat — un prompt puntual (clasificacion, resumen, extraccion) que se resuelve en una sola llamada. Ideal para pipelines que procesan chunks de AX tree, texto, o cualquier contenido que requiera razonamiento LLM.
## Gotchas
- Requiere `claude` CLI instalado y autenticado en PATH. Si no esta: `FileNotFoundError`.
- Cada llamada lanza un subproceso nuevo — no hay cache ni sesion persistente.
- El CLI puede tardar varios segundos al cold-start. Usa `timeout_s` conservador (>=30s).
- `extra_args` se pasan literalmente — validar antes de pasar input de usuario.
- En CI/CD sin display interactivo puede requerir `--no-interactive` en `extra_args`.
@@ -0,0 +1,59 @@
"""Invoca `claude -p` via subprocess y devuelve la respuesta como string."""
import shutil
import subprocess
def claude_cli_prompt(
prompt: str,
timeout_s: int = 60,
model: str | None = None,
max_chars_response: int = 200_000,
extra_args: list[str] | None = None,
) -> str:
"""Invoca `claude -p "<prompt>"` via subprocess.
Args:
prompt: Texto del prompt a enviar a Claude.
timeout_s: Timeout en segundos antes de raise TimeoutExpired.
model: Modelo a usar (ej. "claude-opus-4-5"). None usa el default de `claude -p`.
max_chars_response: Trunca stdout a este numero de caracteres.
extra_args: Argumentos adicionales para el CLI (ej. ["--output-format", "json"]).
Returns:
Respuesta de Claude como texto (stdout), truncada a max_chars_response.
Raises:
FileNotFoundError: Si `claude` no esta en PATH.
RuntimeError: Si exit code != 0 (incluye primeros 500 chars de stderr).
subprocess.TimeoutExpired: Si la llamada supera timeout_s segundos.
"""
if shutil.which("claude") is None:
raise FileNotFoundError(
"'claude' CLI no encontrado en PATH. Instala Claude Code."
)
cmd = ["claude", "-p", prompt]
if model:
cmd.extend(["--model", model])
if extra_args:
cmd.extend(extra_args)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_s,
)
if result.returncode != 0:
stderr_snippet = result.stderr[:500] if result.stderr else "(sin stderr)"
raise RuntimeError(
f"claude -p failed (exit {result.returncode}): {stderr_snippet}"
)
stdout = result.stdout
if len(stdout) > max_chars_response:
stdout = stdout[:max_chars_response]
return stdout
@@ -0,0 +1,80 @@
---
name: generate_app_icon
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "generate_app_icon(phosphor_icon_name: str, accent_hex: str, out_ico_path: str, *, weight: str = 'fill', sizes: list[int] = None, phosphor_root: str = None) -> str"
description: "Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent y exporta un .ico multi-resolucion (default 16,24,32,48,64,128,256). Devuelve el path absoluto del .ico escrito. El glyph se renderiza en blanco al 70% del canvas sobre fondo con esquinas redondeadas al 16%."
tags: [cpp-windows, icon, windows, phosphor, ico, pillow, cairosvg]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [cairosvg, PIL]
params:
- name: phosphor_icon_name
desc: "Nombre del icono Phosphor sin sufijo de weight (ej. 'chart-bar', 'tree-structure', 'gauge'). Ver https://phosphoricons.com para el catalogo."
- name: accent_hex
desc: "Color de fondo en formato hexadecimal '#RRGGBB' (ej. '#0ea5e9', '#7c3aed'). Define la identidad visual de la app."
- name: out_ico_path
desc: "Ruta de salida del .ico. Absoluta o relativa al cwd. El directorio padre se crea si no existe. Colocar en <app_dir>/appicon.ico para que add_imgui_app lo detecte automaticamente."
- name: weight
desc: "Variante Phosphor: 'fill' (default), 'regular', 'bold', 'light', 'thin', 'duotone'."
- name: sizes
desc: "Lista de resoluciones a incluir. Default [16,24,32,48,64,128,256]. Cada tamano se renderiza independientemente para crispness."
- name: phosphor_root
desc: "Carpeta raiz de assets phosphor-core (contiene subdirs fill/, regular/, etc.). Default: <registry_root>/sources/phosphor-core/assets."
output: "Ruta absoluta (str) del archivo .ico generado y escrito a disco."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/generate_app_icon.py"
---
## Ejemplo
```python
from infra import generate_app_icon
# Generar icono para una app C++ del registry
ico_path = generate_app_icon(
phosphor_icon_name="chart-bar",
accent_hex="#0ea5e9",
out_ico_path="apps/chart_demo/appicon.ico",
)
print(ico_path) # /home/lucas/fn_registry/apps/chart_demo/appicon.ico
```
```python
# Desde la CLI directa para prueba rapida
import sys
sys.path.insert(0, "python/functions")
from infra import generate_app_icon
generate_app_icon("gauge", "#059669", "/tmp/registry_dashboard.ico")
```
```bash
# Generar iconos para todas las apps del registry con el script batch
python dev/gen_app_icons.py
```
## Cuando usarla
Cuando una app C++ del registry necesita un `.ico` de Windows para distinguirse en el escritorio y taskbar. El macro `add_imgui_app` de `cpp/CMakeLists.txt` detecta `<app_dir>/appicon.ico` y lo enlaza al `.exe` via `windres` automaticamente en builds Windows. Ejecutar esta funcion antes de compilar en Windows o antes de `fn run redeploy_cpp_app_windows <app>`.
## Gotchas
- **Requiere `sources/phosphor-core/`**: el repo debe estar clonado. Si falta: `git clone --depth=1 https://github.com/phosphor-icons/core.git sources/phosphor-core` desde la raiz del registry. La funcion lanza `FileNotFoundError` con el comando exacto si el SVG no existe.
- **`cairosvg` y `Pillow` en el venv**: deben estar instalados en `python/.venv`. Si faltan: `cd python && uv pip install cairosvg pillow`. Ya presentes en el venv por defecto del registry.
- **El `.ico` se sobreescribe sin warning**: si ya existe `appicon.ico` se reemplaza silenciosamente. Hacer backup si se necesita preservar la version anterior.
- **Re-build del `.exe` necesario**: Windows no refleja el icono nuevo hasta que se recompila el ejecutable. Tras generar el `.ico` ejecutar `fn run redeploy_cpp_app_windows <app>` o compilar manualmente con CMake.
- **Solo formato `#RRGGBB`**: `accent_hex` debe tener exactamente 6 digitos hex. Formatos con alpha o notacion corta `#RGB` lanzan `ValueError`.
- **Peso "fill" por defecto**: Phosphor "fill" tiene las formas mas solidas y visibles en tamanos pequeños (16x16). Para iconos lineales usar `weight="regular"` pero verificar legibilidad a 16px.
## Capability growth log
*(sin cambios desde v1.0.0)*
+153
View File
@@ -0,0 +1,153 @@
"""Genera un icono .ico multi-resolucion para apps C++ del registry.
Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent
y exporta un .ico con multiples resoluciones (16, 24, 32, 48, 64, 128, 256).
"""
import io
import os
from pathlib import Path
import cairosvg
from PIL import Image, ImageDraw
DEFAULT_SIZES = [16, 24, 32, 48, 64, 128, 256]
def _find_registry_root() -> Path:
"""Descubre la raiz del registry caminando hacia arriba hasta encontrar registry.db."""
env_root = os.environ.get("FN_REGISTRY_ROOT")
if env_root:
return Path(env_root).resolve()
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "registry.db").exists():
return parent
raise FileNotFoundError(
"No se encontro registry.db caminando desde el archivo hasta la raiz. "
"Define FN_REGISTRY_ROOT en el entorno."
)
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
"""Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente."""
svg = svg_path.read_text(encoding="utf-8")
# Phosphor usa fill="currentColor" — forzar blanco.
svg = svg.replace('fill="currentColor"', 'fill="#ffffff"')
png_bytes = cairosvg.svg2png(
bytestring=svg.encode("utf-8"),
output_width=size,
output_height=size,
)
return Image.open(io.BytesIO(png_bytes)).convert("RGBA")
def _make_icon_image(svg_path: Path, accent_hex: str, size: int) -> Image.Image:
"""Compone fondo redondeado con color accent + glyph blanco centrado al 70%."""
bg_color = _hex_to_rgb(accent_hex) + (255,)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
radius = max(2, size // 6) # ~16% de radio redondeado
draw.rounded_rectangle(
[(0, 0), (size - 1, size - 1)],
radius=radius,
fill=bg_color,
)
# El glyph ocupa ~70% del canvas (padding ~15% en cada lado).
glyph_size = int(size * 0.7)
if glyph_size < 8:
glyph_size = max(8, size - 2)
glyph = _render_glyph_white(svg_path, glyph_size)
off = ((size - glyph_size) // 2, (size - glyph_size) // 2)
canvas.alpha_composite(glyph, dest=off)
return canvas
def generate_app_icon(
phosphor_icon_name: str,
accent_hex: str,
out_ico_path: str,
*,
weight: str = "fill",
sizes: list[int] = None,
phosphor_root: str = None,
) -> str:
"""Genera un icono .ico multi-resolucion a partir de un SVG Phosphor.
Rasteriza el icono Phosphor indicado sobre un fondo redondeado del color
accent y exporta un .ico con multiples resoluciones ordenadas de mayor a
menor para maxima compatibilidad con Windows.
Args:
phosphor_icon_name: Nombre del icono Phosphor sin sufijo de weight
(ej. "chart-bar", "tree-structure", "gauge").
accent_hex: Color de fondo en formato hexadecimal "#RRGGBB"
(ej. "#0ea5e9", "#7c3aed").
out_ico_path: Ruta de salida para el archivo .ico. Puede ser absoluta
o relativa al directorio de trabajo actual. El directorio padre
se crea si no existe.
weight: Variante del icono Phosphor. Default "fill". Otros valores
validos segun el repositorio: "regular", "bold", "light",
"thin", "duotone".
sizes: Lista de resoluciones a incluir en el .ico. Default
[16, 24, 32, 48, 64, 128, 256]. El orden no importa; se
renderiza cada tamano individualmente para maxima crispness.
phosphor_root: Ruta a la carpeta raiz de assets de phosphor-core
(la que contiene subdirectorios "fill", "regular", etc.).
Default: <registry_root>/sources/phosphor-core/assets.
Returns:
Ruta absoluta del archivo .ico generado.
Raises:
FileNotFoundError: Si el SVG del icono no existe en phosphor_root.
ValueError: Si accent_hex no tiene el formato "#RRGGBB".
"""
if sizes is None:
sizes = DEFAULT_SIZES
# Resolver raiz de phosphor
if phosphor_root is None:
registry_root = _find_registry_root()
phosphor_assets = registry_root / "sources" / "phosphor-core" / "assets"
else:
phosphor_assets = Path(phosphor_root)
svg_file = phosphor_assets / weight / f"{phosphor_icon_name}-{weight}.svg"
if not svg_file.exists():
raise FileNotFoundError(
f"Icono Phosphor no encontrado: {svg_file}\n"
f"Asegurate de que sources/phosphor-core/ existe. Si no:\n"
f" git clone --depth=1 https://github.com/phosphor-icons/core.git "
f"sources/phosphor-core"
)
# Validar formato del color
h = accent_hex.lstrip("#")
if len(h) != 6:
raise ValueError(f"accent_hex debe tener formato #RRGGBB, recibido: {accent_hex!r}")
# Renderizar cada resolucion individualmente para crispness en tamanos pequeños
images = {s: _make_icon_image(svg_file, accent_hex, s) for s in sorted(sizes, reverse=True)}
out = Path(out_ico_path)
if not out.is_absolute():
out = Path.cwd() / out
out.parent.mkdir(parents=True, exist_ok=True)
sorted_sizes = sorted(sizes, reverse=True)
biggest = images[sorted_sizes[0]]
others = [images[s] for s in sorted_sizes[1:]]
biggest.save(
out,
format="ICO",
sizes=[(s, s) for s in sorted_sizes],
append_images=others,
)
return str(out.resolve())
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "http_download_file(url: str, dest_path: str, headers: dict[str, str] | None = None, timeout: float = 120.0, chunk_size: int = 8192) -> dict"
description: "Descarga un archivo por HTTP en streaming (sin cargar todo en memoria). Crea directorios intermedios si no existen. Retorna dict con path, size_bytes y content_type."
tags: [http, download, file, streaming, network, stdlib, infra, pendiente-usar]
tags: [http, download, file, streaming, network, stdlib, infra, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "http_get_json(url: str, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float = 30.0) -> dict"
description: "GET request que espera JSON. Agrega Accept: application/json automaticamente. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body."
tags: [http, json, get, client, network, stdlib, infra, pendiente-usar]
tags: [http, json, get, client, network, stdlib, infra, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "http_post_json(url: str, body: dict, headers: dict[str, str] | None = None, timeout: float = 30.0) -> dict"
description: "POST request con body JSON. Agrega Content-Type: application/json y Accept: application/json. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body."
tags: [http, json, post, client, network, stdlib, infra, pendiente-usar]
tags: [http, json, post, client, network, stdlib, infra, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
@@ -0,0 +1,61 @@
---
name: llm_propose_scraping_schema
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def llm_propose_scraping_schema(url: str, ax_tree: list, max_chunks: int = 5, max_chars_per_chunk: int = 25000) -> dict"
description: "Orquesta trim_ax_tree -> chunk_ax_tree -> N llamadas a Claude CLI -> merge. Propone schema de scraping (fields, selectors, types) a partir del AX tree de una pagina."
tags: [navegator, ai, llm, scraping, schema]
uses_functions: [trim_ax_tree_py_core, chunk_ax_tree_py_core, claude_cli_prompt_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, re, sys, os]
params:
- name: url
desc: "URL de la pagina (se incluye en el prompt a Claude para contexto)."
- name: ax_tree
desc: "AX tree como lista de dicts obtenida via CDP (cdp_get_ax_tree)."
- name: max_chunks
desc: "Maximo de chunks a procesar. Default 5. Si hay mas, truncated=True."
- name: max_chars_per_chunk
desc: "Caracteres maximos por chunk de AX tree enviado a Claude. Default 25000."
output: "dict {schema: [{field, selector, sample_value, type, source_role}], notes: str, chunks_processed: int, truncated: bool}"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/llm_propose_scraping_schema.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.llm_propose_scraping_schema import llm_propose_scraping_schema
# ax_tree obtenido previamente con cdp_get_ax_tree
result = llm_propose_scraping_schema(
url="https://shop.example.com/products",
ax_tree=ax_tree,
max_chunks=3,
)
# {"schema": [{"field": "price", "selector": ".product-price", ...}], "notes": "...", ...}
for field in result["schema"]:
print(field["field"], "->", field["selector"])
```
## Cuando usarla
Cuando tienes el AX tree de una pagina y quieres que Claude proponga automaticamente que campos extraer y con que selectores CSS. Paso de discovery antes de escribir la recipe YAML a mano o de forma asistida.
## Gotchas
- Requiere `claude` CLI instalado y disponible en PATH (validado por `claude_cli_prompt`).
- Cada chunk genera una llamada a Claude (coste de tokens). Usar `max_chunks` conservador en paginas muy grandes.
- La respuesta de Claude se parsea tolerando fenced code blocks (```json ... ```). Si Claude devuelve prosa sin JSON, el chunk se omite con nota de error.
- Dedup por `field`: primera ocurrencia gana si el mismo campo aparece en varios chunks.
- No accede a red directamente — delega en `claude_cli_prompt`.
@@ -0,0 +1,102 @@
"""Orquesta trim_ax_tree -> chunk_ax_tree -> N llamadas claude_cli_prompt -> merge schema."""
import json
import re
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from core.trim_ax_tree import trim_ax_tree
from core.chunk_ax_tree import chunk_ax_tree
from infra.claude_cli_prompt import claude_cli_prompt
def _parse_json_response(text: str) -> dict:
"""Extrae JSON de la respuesta de Claude, tolerante a fenced code blocks."""
# Intentar fenced ```json ... ```
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if m:
return json.loads(m.group(1))
# Intentar JSON directo
m = re.search(r"\{.*\}", text, re.DOTALL)
if m:
return json.loads(m.group(0))
raise ValueError(f"No se encontro JSON valido en respuesta: {text[:200]}")
def _build_prompt(url: str, chunk_json: str) -> str:
return (
f"Analiza este accessibility tree de la pagina {url}.\n"
"Identifica campos de datos extraibles (tablas, listas, valores estructurados).\n"
"Para cada campo propone:\n"
" - field (snake_case)\n"
" - selector CSS robusto que se pueda usar con document.querySelector\n"
" - sample_value (valor visible representativo)\n"
" - type (string|int|float|bool|date)\n"
" - source_role (role del AXNode origen)\n"
"\n"
'Devuelve JSON valido SIN explicacion:\n'
'{"schema": [...], "notes": "..."}\n'
"\n"
"AX tree:\n"
f"{chunk_json}"
)
def llm_propose_scraping_schema(
url: str,
ax_tree: list,
max_chunks: int = 5,
max_chars_per_chunk: int = 25000,
) -> dict:
"""Orquesta: trim_ax_tree -> chunk_ax_tree -> N llamadas claude_cli_prompt -> merge.
Args:
url: URL de la pagina (se incluye en el prompt para contexto).
ax_tree: AX tree como lista de dicts obtenida via CDP.
max_chunks: Maximo de chunks a procesar (trunca el resto).
max_chars_per_chunk: Caracteres maximos por chunk antes de pasar a Claude.
Returns:
{schema: [{field, selector, sample_value, type, source_role}],
notes: str,
chunks_processed: int,
truncated: bool}
"""
trimmed = trim_ax_tree(ax_tree)
chunks = chunk_ax_tree(trimmed, max_chars=max_chars_per_chunk)
truncated = len(chunks) > max_chunks
chunks = chunks[:max_chunks]
merged_schema: list = []
seen_fields: set = set()
notes_parts: list = []
for chunk in chunks:
chunk_json = json.dumps(chunk, ensure_ascii=False)
prompt = _build_prompt(url, chunk_json)
try:
response = claude_cli_prompt(prompt, timeout_s=60)
parsed = _parse_json_response(response)
except Exception as e:
notes_parts.append(f"[chunk error: {e}]")
continue
for item in parsed.get("schema", []):
field = item.get("field", "")
if field and field not in seen_fields:
seen_fields.add(field)
merged_schema.append(item)
note = parsed.get("notes", "")
if note:
notes_parts.append(note)
return {
"schema": merged_schema,
"notes": " | ".join(notes_parts),
"chunks_processed": len(chunks),
"truncated": truncated,
}
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def metabase_create_card(client: MetabaseClient, name: str, dataset_query: dict, display: str = 'table', collection_id: int = 0, description: str = '') -> dict"
description: "Crea una card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card."
tags: [metabase, card, question, create, api, python]
tags: [metabase, card, question, create, api, python, sink]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def metabase_create_card_alert(client: MetabaseClient, card_id: int, cron_schedule: str, recipients: list[dict], send_condition: str = 'has_result', send_once: bool = False) -> dict"
description: "Crea una alerta sobre los resultados de una card en Metabase. Envia email segun cron cuando la card cumple la condicion (has_result, goal_above, goal_below). Endpoint: POST /api/notification."
tags: [metabase, notification, alert, card, create, api, python, pendiente-usar]
tags: [metabase, notification, alert, card, create, api, python, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def metabase_create_dashboard_subscription(client: MetabaseClient, dashboard_id: int, cron_schedule: str, recipients: list[dict]) -> dict"
description: "Crea una suscripcion periodica a un dashboard de Metabase. Envia el dashboard completo por email segun el cron configurado. Endpoint: POST /api/notification."
tags: [metabase, notification, subscription, dashboard, create, api, python, pendiente-usar]
tags: [metabase, notification, subscription, dashboard, create, api, python, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def metabase_execute_card(client: MetabaseClient, card_id: int, parameters: list[dict] | None = None) -> dict"
description: "Ejecuta la query de una card guardada y retorna resultados con columnas y filas. Soporta parametros. Endpoint: POST /api/card/:id/query."
tags: [metabase, card, question, execute, query, api, python, pendiente-usar]
tags: [metabase, card, question, execute, query, api, python, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def metabase_execute_query(client: MetabaseClient, database_id: int, sql: str, max_results: int = 0) -> dict"
description: "Ejecuta query SQL ad-hoc contra Metabase sin guardarla como card. Util para exploracion rapida. Endpoint: POST /api/dataset."
tags: [metabase, query, execute, sql, dataset, api, python]
tags: [metabase, query, execute, sql, dataset, api, python, extractor]
uses_functions: []
uses_types: []
returns: []
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def metabase_export_card(client: MetabaseClient, card_id: int, format: str = 'csv') -> bytes"
description: "Exporta los resultados de una card de Metabase en CSV, XLSX o JSON. Endpoint: POST /api/card/:id/query/:format."
tags: [metabase, card, export, csv, xlsx, api, python, pendiente-usar]
tags: [metabase, card, export, csv, xlsx, api, python, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "def jupyter_read_cells(notebook_path: str, server_url: str = 'http://localhost:8888', token: str = '', cell_index: int | None = None) -> list[dict]"
description: "Lee celdas de un notebook Jupyter abierto via el protocolo de colaboracion en tiempo real (CRDT/Y.js). Devuelve el estado actual incluyendo cambios no guardados. Expone tambien jupyter_notebook_info() para metadata rapida."
tags: [jupyter, notebook, crdt, yjs, websocket, cells, read, realtime, pendiente-usar]
tags: [jupyter, notebook, crdt, yjs, websocket, cells, read, realtime, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
@@ -0,0 +1,69 @@
---
name: cdp_extract_recipe
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def cdp_extract_recipe(recipe_path: str, debug_port: int = 9222, tab_id: str | None = None, record_run: bool = True) -> dict"
description: "Ejecuta una recipe YAML contra Chrome remoto via CDP. Valida recipe, busca tab por url_pattern, ejecuta steps (wait_selector/js) y envia resultado al sink declarado."
tags: [navegator, cdp, recipe, scraping, pipeline]
uses_functions: [validate_recipe_yaml_py_core, data_factory_record_run_py_pipelines]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, re, sys, os, time, urllib.request, websocket]
params:
- name: recipe_path
desc: "Ruta al archivo .yaml de la recipe (absoluta o relativa al cwd)."
- name: debug_port
desc: "Puerto de depuracion remota de Chrome. Default 9222."
- name: tab_id
desc: "ID del tab a usar. Si None, busca tab cuyo URL matchee url_pattern de la recipe."
- name: record_run
desc: "Si True y output.sink=='data_factory.runs', registra la ejecucion en data_factory."
output: "dict {status: ok|error, rows_out: int, kb_out: float, duration_ms: int, error: str, sample_rows: list}"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/cdp_extract_recipe.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from pipelines.cdp_extract_recipe import cdp_extract_recipe
result = cdp_extract_recipe(
recipe_path="recipes/product_list.yaml",
debug_port=9222,
)
print(result["status"], result["rows_out"], "rows")
# ok 42 rows
```
Recipe de ejemplo (`recipes/product_list.yaml`):
```yaml
name: product_list
url_pattern: "https://shop\\.example\\.com/products.*"
steps:
- wait_selector: ".product-card"
- js: "Array.from(document.querySelectorAll('.product-card')).map(e => ({name: e.querySelector('h2').innerText, price: e.querySelector('.price').innerText}))"
output:
sink: stdout
```
## Cuando usarla
Cuando tienes una recipe YAML validada y Chrome corriendo con remote debugging, y quieres extraer datos en un solo paso sin montar pipeline manualmente. Encadena con `cdp_open_url_and_wait` si necesitas abrir la URL primero.
## Gotchas
- Chrome debe estar corriendo con `--remote-debugging-port=<debug_port>`.
- `wait_selector` usa polling sync sobre el WebSocket (200ms interval, 10s timeout) — no apto para paginas con lazy load muy largo.
- El ultimo step `js` debe devolver el dato final (array o valor). Steps intermedios pueden preparar el DOM.
- `data_factory_record_run` falla silenciosamente si no hay DB configurada — el dato ya fue extraido y devuelto.
- `websocket-client` debe estar instalado en el venv.
@@ -0,0 +1,210 @@
"""Ejecuta una recipe YAML contra Chrome remoto via CDP."""
import json
import re
import sys
import os
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import urllib.request
import websocket
from core.validate_recipe_yaml import validate_recipe_yaml
def _ws_send_recv(ws, msg_id: int, method: str, params: dict, timeout: float = 10.0) -> dict:
"""Envia un mensaje CDP y espera respuesta con el mismo id."""
import threading
result_holder = {}
event = threading.Event()
original_on_message = ws.on_message
def on_message_wrapper(ws_app, message):
try:
msg = json.loads(message)
if msg.get("id") == msg_id:
result_holder["result"] = msg
event.set()
except Exception:
pass
if original_on_message:
original_on_message(ws_app, message)
ws.on_message = on_message_wrapper
ws.send(json.dumps({"id": msg_id, "method": method, "params": params}))
event.wait(timeout=timeout)
ws.on_message = original_on_message
return result_holder.get("result", {})
def _poll_selector(ws, selector: str, timeout_s: float = 10.0) -> bool:
"""Polling cada 200ms hasta que document.querySelector(selector) no sea null."""
deadline = time.time() + timeout_s
msg_id = 1000
while time.time() < deadline:
ws.send(json.dumps({
"id": msg_id,
"method": "Runtime.evaluate",
"params": {
"expression": f"!!document.querySelector({json.dumps(selector)})",
"returnByValue": True,
}
}))
time.sleep(0.2)
msg_id += 1
# Leer respuesta en loop simple (websocket-client sync)
# Para modo sync usamos recv()
try:
raw = ws.sock.recv()
if raw:
msg = json.loads(raw)
val = msg.get("result", {}).get("result", {}).get("value", False)
if val:
return True
except Exception:
pass
return False
def cdp_extract_recipe(
recipe_path: str,
debug_port: int = 9222,
tab_id: str | None = None,
record_run: bool = True,
) -> dict:
"""Ejecuta una recipe YAML contra Chrome remoto via CDP.
Args:
recipe_path: Ruta al archivo .yaml de la recipe.
debug_port: Puerto de depuracion remota de Chrome. Default 9222.
tab_id: ID del tab a usar. Si None, busca tab cuyo URL matchee url_pattern.
record_run: Si True y output.sink=='data_factory.runs', llama data_factory_record_run.
Returns:
{status, rows_out, kb_out, duration_ms, error, sample_rows}
"""
start_ms = int(time.time() * 1000)
# Leer y validar recipe
try:
with open(recipe_path, "r", encoding="utf-8") as f:
yaml_text = f.read()
except OSError as e:
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
"duration_ms": 0, "error": str(e), "sample_rows": []}
validation = validate_recipe_yaml(yaml_text)
if not validation["valid"]:
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
"duration_ms": 0, "error": "recipe invalida: " + "; ".join(validation["errors"]),
"sample_rows": []}
recipe = validation["parsed"]
url_pattern = recipe["url_pattern"]
steps = recipe["steps"]
output_cfg = recipe.get("output", {})
sink = output_cfg.get("sink", "stdout")
# Obtener lista de tabs
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{debug_port}/json/list", timeout=5
) as resp:
tabs = json.loads(resp.read().decode())
except Exception as e:
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
"duration_ms": 0,
"error": f"no se pudo conectar a Chrome en port {debug_port}: {e}",
"sample_rows": []}
# Encontrar tab
ws_url = None
if tab_id:
for tab in tabs:
if tab.get("id") == tab_id:
ws_url = tab.get("webSocketDebuggerUrl")
break
else:
for tab in tabs:
tab_url = tab.get("url", "")
if re.search(url_pattern, tab_url):
ws_url = tab.get("webSocketDebuggerUrl")
break
if not ws_url:
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
"duration_ms": 0,
"error": f"no tab matching pattern: {url_pattern}",
"sample_rows": []}
# Ejecutar steps
last_result = None
try:
ws = websocket.create_connection(ws_url, timeout=10)
try:
for i, step in enumerate(steps):
if "wait_selector" in step:
selector = step["wait_selector"]
found = _poll_selector(ws, selector, timeout_s=10.0)
if not found:
raise RuntimeError(f"step {i}: timeout esperando selector '{selector}'")
elif "js" in step:
ws.send(json.dumps({
"id": i + 1,
"method": "Runtime.evaluate",
"params": {
"expression": step["js"],
"returnByValue": True,
"awaitPromise": True,
}
}))
raw = ws.recv()
msg = json.loads(raw)
result_obj = msg.get("result", {}).get("result", {})
last_result = result_obj.get("value")
finally:
ws.close()
except Exception as e:
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
"duration_ms": int(time.time() * 1000) - start_ms,
"error": str(e), "sample_rows": []}
# Calcular metricas
rows = last_result if isinstance(last_result, list) else (
[last_result] if last_result is not None else []
)
rows_out = len(rows)
kb_out = len(json.dumps(rows, ensure_ascii=False).encode()) / 1024
sample_rows = rows[:5]
duration_ms = int(time.time() * 1000) - start_ms
# Sink
if sink == "stdout":
print(json.dumps(rows, ensure_ascii=False, indent=2))
elif sink == "json_file":
out_path = output_cfg.get("path", "output.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=2)
elif sink == "data_factory.runs" and record_run:
try:
from pipelines.data_factory_record_run import data_factory_record_run
data_factory_record_run(
node_id=recipe.get("name", "unknown"),
function_id="cdp_extract_recipe_py_pipelines",
args={"recipe_path": recipe_path, "debug_port": debug_port},
)
except Exception as e:
# No fatal — el dato ya fue extraido
pass
return {
"status": "ok",
"rows_out": rows_out,
"kb_out": round(kb_out, 2),
"duration_ms": duration_ms,
"error": "",
"sample_rows": sample_rows,
}
@@ -0,0 +1,79 @@
---
name: cdp_get_ax_tree
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def cdp_get_ax_tree(debug_port: int, tab_id: str, depth: int = -1) -> list[dict]"
description: "Conecta a Chrome via CDP WebSocket, habilita Accessibility y devuelve el AX tree completo del tab indicado. Usa websocket-client si está disponible, sino websockets async."
tags: [navegator, cdp, chrome, browser, accessibility, ax-tree]
uses_functions: [trim_ax_tree_py_core, chunk_ax_tree_py_core]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, threading, urllib.request, urllib.error, websocket]
params:
- name: debug_port
desc: "Puerto de debug remoto de Chrome (ej. 9222). Lanzar Chrome con --remote-debugging-port=9222."
- name: tab_id
desc: "ID del tab CDP obtenido via GET /json/list (campo 'id'). Usar cdp_list_tabs_go_browser para listarlo."
- name: depth
desc: "Profundidad del árbol a obtener. -1 = completo (default)."
output: "Lista de AXNode en formato CDP. Lista vacía si la página no tiene contenido accesible."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/cdp_get_ax_tree.py"
---
## Ejemplo
```python
import urllib.request, json
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
from core.trim_ax_tree import trim_ax_tree
from core.chunk_ax_tree import chunk_ax_tree
# 1. Listar tabs para obtener tab_id
with urllib.request.urlopen("http://127.0.0.1:9222/json/list") as r:
tabs = json.loads(r.read())
tab_id = tabs[0]["id"]
# 2. Obtener AX tree
nodes = cdp_get_ax_tree(debug_port=9222, tab_id=tab_id)
# 3. Reducir y chunkear para LLM
trimmed = trim_ax_tree(nodes)
chunks = chunk_ax_tree(trimmed, max_chars=25000)
print(f"{len(nodes)} nodos → {len(trimmed)} trimmed → {len(chunks)} chunks")
```
## Cuando usarla
Cuando necesitas obtener el árbol de accesibilidad de una página Chrome ya abierta para procesarlo con un LLM o para automatización accesible (más estable que selectores CSS). Requiere Chrome lanzado con `--remote-debugging-port=PORT`.
## Gotchas
- Chrome debe estar corriendo con `--remote-debugging-port=<port>` y `--no-sandbox` en CI.
- En WSL2 usar `--remote-debugging-address=0.0.0.0` y conectar al IP del host Windows, no a 127.0.0.1.
- El tab no puede tener otro debugger adjunto (DevTools abierto) — cierra DevTools antes de llamar.
- `Accessibility.getFullAXTree` puede tardar 2-5s en páginas grandes.
- Timeout total de 15s — aumentar si la página es muy pesada.
- Tests automáticos requieren Chrome corriendo. Para probar manualmente:
```bash
# Lanzar Chrome en WSL2
chrome.exe --remote-debugging-port=9222 --headless=new https://example.com
# Verificar
curl http://127.0.0.1:9222/json/list | python3 -m json.tool
# Ejecutar
python3 -c "
import json, urllib.request
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
with urllib.request.urlopen('http://127.0.0.1:9222/json/list') as r:
tabs = json.loads(r.read())
nodes = cdp_get_ax_tree(9222, tabs[0]['id'])
print(f'{len(nodes)} nodos')
"
```
@@ -0,0 +1,211 @@
"""Obtiene el AX tree completo de un tab Chrome via CDP WebSocket."""
import json
import threading
import urllib.request
import urllib.error
def cdp_get_ax_tree(
debug_port: int,
tab_id: str,
depth: int = -1,
) -> list[dict]:
"""Conecta al Chrome remoto via WebSocket (CDP) y devuelve el AX tree completo.
Pasos:
1. HTTP GET /json/list para obtener webSocketDebuggerUrl del tab.
2. WebSocket connect (usa websocket-client si disponible, sino implementa
minimal RFC6455 con socket stdlib).
3. Envía Accessibility.enable y espera ack.
4. Envía Accessibility.getFullAXTree con depth=-1.
5. Lee response y devuelve la lista de AXNode.
Args:
debug_port: Puerto de debug remoto de Chrome (ej. 9222).
tab_id: ID del tab obtenido via /json/list (campo "id").
depth: Profundidad del árbol. -1 = completo.
Returns:
Lista de AXNode en formato CDP.
Raises:
RuntimeError: Si no se encuentra el tab, falla la conexión WS,
o la respuesta CDP contiene error.
TimeoutError: Si el servidor no responde en 10 segundos.
"""
# 1. Obtener webSocketDebuggerUrl del tab
ws_url = _get_ws_url(debug_port, tab_id)
# 2. Conectar y obtener nodos
return _cdp_get_ax_nodes(ws_url, depth)
def _get_ws_url(debug_port: int, tab_id: str) -> str:
"""Obtiene el webSocketDebuggerUrl del tab via HTTP /json/list."""
url = f"http://127.0.0.1:{debug_port}/json/list"
try:
with urllib.request.urlopen(url, timeout=10) as resp:
tabs = json.loads(resp.read().decode())
except urllib.error.URLError as e:
raise RuntimeError(
f"No se pudo conectar a Chrome en puerto {debug_port}: {e}"
) from e
for tab in tabs:
if tab.get("id") == tab_id:
ws_url = tab.get("webSocketDebuggerUrl")
if not ws_url:
raise RuntimeError(
f"Tab {tab_id} no tiene webSocketDebuggerUrl "
"(puede estar adjunto a otro debugger)"
)
return ws_url
raise RuntimeError(
f"Tab {tab_id} no encontrado. Tabs disponibles: "
f"{[t.get('id') for t in tabs]}"
)
def _cdp_get_ax_nodes(ws_url: str, depth: int) -> list[dict]:
"""Conecta via WebSocket y ejecuta la secuencia CDP para obtener AX tree."""
try:
import websocket # websocket-client
return _cdp_via_websocket_client(ws_url, depth)
except ImportError:
pass
# Fallback: websockets (async) via threading
try:
import websockets # noqa: F401
return _cdp_via_websockets(ws_url, depth)
except ImportError:
pass
raise RuntimeError(
"Ninguna librería WebSocket disponible. "
"Instala websocket-client: pip install websocket-client"
)
def _cdp_via_websocket_client(ws_url: str, depth: int) -> list[dict]:
"""Implementación usando websocket-client (síncrono)."""
import websocket
results: dict = {}
error_container: list = []
def on_message(ws, message):
try:
msg = json.loads(message)
msg_id = msg.get("id")
if msg_id in (1, 2):
results[msg_id] = msg
if msg_id == 2 or "error" in msg:
ws.close()
except Exception as e:
error_container.append(e)
ws.close()
def on_error(ws, error):
error_container.append(RuntimeError(f"WebSocket error: {error}"))
def on_open(ws):
# Paso 3: habilitar Accessibility
ws.send(json.dumps({"id": 1, "method": "Accessibility.enable"}))
# Paso 4: obtener AX tree completo
params: dict = {}
if depth != -1:
params["depth"] = depth
ws.send(json.dumps({
"id": 2,
"method": "Accessibility.getFullAXTree",
"params": params,
}))
ws_app = websocket.WebSocketApp(
ws_url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
)
t = threading.Thread(
target=lambda: ws_app.run_forever(ping_timeout=10),
daemon=True,
)
t.start()
t.join(timeout=15)
if error_container:
raise error_container[0]
if 2 not in results:
raise TimeoutError(
"No se recibió respuesta de Accessibility.getFullAXTree en 15s"
)
resp = results[2]
if "error" in resp:
raise RuntimeError(f"CDP error: {resp['error']}")
result_data = resp.get("result", {})
nodes = result_data.get("nodes", [])
return nodes
def _cdp_via_websockets(ws_url: str, depth: int) -> list[dict]:
"""Fallback usando websockets (async), ejecutado en thread con asyncio."""
import asyncio
async def _run():
import websockets
async with websockets.connect(ws_url, open_timeout=10) as ws:
# Habilitar Accessibility
await ws.send(json.dumps({"id": 1, "method": "Accessibility.enable"}))
await ws.recv() # ack
# Obtener AX tree
params: dict = {}
if depth != -1:
params["depth"] = depth
await ws.send(json.dumps({
"id": 2,
"method": "Accessibility.getFullAXTree",
"params": params,
}))
# Leer hasta recibir respuesta con id=2
import asyncio as _asyncio
async with _asyncio.timeout(10):
while True:
raw = await ws.recv()
msg = json.loads(raw)
if msg.get("id") == 2:
if "error" in msg:
raise RuntimeError(f"CDP error: {msg['error']}")
return msg.get("result", {}).get("nodes", [])
result_holder: list = []
error_holder: list = []
def _thread_run():
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
nodes = loop.run_until_complete(_run())
result_holder.append(nodes)
except Exception as e:
error_holder.append(e)
t = threading.Thread(target=_thread_run, daemon=True)
t.start()
t.join(timeout=15)
if error_holder:
raise error_holder[0]
if not result_holder:
raise TimeoutError("No se recibió respuesta en 15s")
return result_holder[0]
@@ -0,0 +1,51 @@
---
name: cdp_open_url_and_wait
kind: function
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def cdp_open_url_and_wait(debug_port: int, url: str, timeout_s: int = 30) -> str"
description: "Crea tab nuevo en Chrome remoto via CDP, navega a URL y espera Page.loadEventFired. Devuelve tab_id."
tags: [navegator, cdp, chrome, browser]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, threading, urllib.request, urllib.parse, websocket]
params:
- name: debug_port
desc: "Puerto de depuracion remota de Chrome (tipicamente 9222)."
- name: url
desc: "URL completa a la que navegar en el tab nuevo."
- name: timeout_s
desc: "Segundos maximos esperando Page.loadEventFired. Default 30."
output: "tab_id (str) del tab recien creado en Chrome."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/cdp_open_url_and_wait.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
tab_id = cdp_open_url_and_wait(9222, "https://example.com", timeout_s=15)
print(tab_id) # "B1C2D3E4-..."
```
## Cuando usarla
Cuando necesites abrir una URL nueva en Chrome remoto y asegurarte de que la pagina cargo antes de interactuar con ella via CDP. Paso previo a cualquier extraccion de AX tree o ejecucion de JS.
## Gotchas
- Chrome debe estar corriendo con `--remote-debugging-port=<debug_port>` y `--remote-allow-origins=*`.
- PUT a `/json/new?<url>` crea el tab; si Chrome no acepta PUT responde 404 (version antigua).
- `Page.loadEventFired` puede no dispararse en SPAs con routing sin recarga — usar `timeout_s` conservador o esperar selector via `cdp_extract_recipe`.
- `websocket-client` debe estar instalado en el venv.
@@ -0,0 +1,79 @@
"""Abre tab nuevo en Chrome remoto, navega a URL, espera Page.loadEventFired."""
import json
import threading
import urllib.request
import urllib.parse
import websocket
def cdp_open_url_and_wait(
debug_port: int,
url: str,
timeout_s: int = 30,
) -> str:
"""Crea tab nuevo en Chrome remoto, navega a url, espera Page.loadEventFired.
Args:
debug_port: Puerto de depuracion remota de Chrome (ej. 9222).
url: URL a la que navegar.
timeout_s: Timeout total en segundos para esperar el load event.
Returns:
tab_id (string) del tab recien creado.
Raises:
RuntimeError: Si Chrome no responde, la navegacion falla o se agota timeout.
"""
encoded = urllib.parse.quote(url, safe=":/?#[]@!$&'()*+,;=%")
new_tab_url = f"http://127.0.0.1:{debug_port}/json/new?{encoded}"
req = urllib.request.Request(new_tab_url, method="PUT")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
tab_info = json.loads(resp.read().decode())
except Exception as e:
raise RuntimeError(f"cdp_open_url_and_wait: no se pudo crear tab en port {debug_port}: {e}") from e
tab_id = tab_info.get("id", "")
ws_url = tab_info.get("webSocketDebuggerUrl", "")
if not ws_url:
raise RuntimeError(f"cdp_open_url_and_wait: tab sin webSocketDebuggerUrl: {tab_info}")
load_event = threading.Event()
errors = []
def on_message(ws_app, message):
try:
msg = json.loads(message)
if msg.get("method") == "Page.loadEventFired":
load_event.set()
except Exception:
pass
def on_error(ws_app, error):
errors.append(str(error))
load_event.set()
def on_open(ws_app):
ws_app.send(json.dumps({"id": 1, "method": "Page.enable", "params": {}}))
ws = websocket.WebSocketApp(
ws_url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
)
t = threading.Thread(target=ws.run_forever, daemon=True)
t.start()
fired = load_event.wait(timeout=timeout_s)
ws.close()
if errors:
raise RuntimeError(f"cdp_open_url_and_wait: WS error: {errors[0]}")
if not fired:
raise RuntimeError(f"cdp_open_url_and_wait: timeout ({timeout_s}s) esperando Page.loadEventFired para {url}")
return tab_id
@@ -0,0 +1,62 @@
---
name: data_factory_record_run
kind: function
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def data_factory_record_run(node_id, function_id, args=None, db_path=None, trigger='manual') -> dict"
description: "Wrappea `fn run <function_id>` capturando rows/kb/duration y persiste el resultado en data_factory.db.runs. Requiere que el node_id exista previamente en nodes."
tags: [data-pipeline, factory, record-run, pipelines, subprocess, registry]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: node_id
desc: "ID del nodo en data_factory.db.nodes que es propietario de esta ejecucion. FK enforced — debe existir antes de llamar."
- name: function_id
desc: "ID de funcion del registry a ejecutar (se pasa a `fn run`). Ejemplo: 'bq_query_py_infra'."
- name: args
desc: "Lista de args CLI adicionales que se reenvian a `fn run` despues del function_id. Default None = sin args extra."
- name: db_path
desc: "Ruta absoluta a data_factory.db. Default: ${FN_REGISTRY_ROOT}/apps/data_factory/data_factory.db."
- name: trigger
desc: "Origen de la ejecucion: 'manual'|'cron'|'dag'|'api'. Default 'manual'."
output: "dict con claves: run_id (str), status ('success'|'failed'), rows_out (int), kb_out (int), duration_ms (int), stdout (str), stderr (str)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/data_factory_record_run.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.environ["FN_REGISTRY_ROOT"] + "/python/functions/pipelines")
from data_factory_record_run import data_factory_record_run
result = data_factory_record_run(
node_id="bq_users_extractor",
function_id="bq_query_py_infra",
args=["--project", "my-gcp", "--sql", "SELECT * FROM users LIMIT 1000"],
)
print(f"run {result['run_id']}: {result['rows_out']} rows in {result['duration_ms']}ms")
# run a3f1c8e2d7b04e91: 1000 rows in 4230ms
```
## Cuando usarla
Cuando un nodo del data_factory deba ejecutar una funcion del registry y dejar trazabilidad completa (duration, rows, error) en `data_factory.db`. Usa este wrapper en lugar de llamar `fn run` directamente desde el DAG engine o desde scripts de ingesta.
## Gotchas
- `FN_REGISTRY_ROOT` debe estar en el entorno — sin ella la funcion lanza `RuntimeError` inmediato.
- El `node_id` debe existir en `nodes` antes del INSERT (FK con `ON DELETE CASCADE`). Si no existe, la funcion devuelve error claro en vez de silencio.
- `rows_out` se parsea buscando patron `^(rows|extracted|written|count)[:= ]+(\d+)` en stdout. Si la funcion destino no imprime nada con ese patron, `rows_out=0` — esto es correcto, no un bug.
- El binario `fn` se busca en `${FN_REGISTRY_ROOT}/fn`. Si no esta compilado, compilar con `CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/` desde la raiz del registry.
- `db_path` apunta a la BD de la app data_factory, NO a `registry.db`.
- Solo stdlib Python — sin pandas, polars ni dependencias externas.
@@ -0,0 +1,152 @@
"""data_factory_record_run — wraps `fn run <function_id>` and persists metrics in data_factory.db."""
import os
import re
import sqlite3
import subprocess
import uuid
from datetime import datetime, timezone
from pathlib import Path
def _now_iso8601() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _elapsed_ms(start: float) -> int:
import time
return int((time.monotonic() - start) * 1000)
def _parse_rows_out(stdout: str) -> int:
"""Parse first line matching rows/extracted/written/count[:= ]+N (case insensitive)."""
pattern = re.compile(r'^(?:rows|extracted|written|count)[:=\s]+(\d+)', re.IGNORECASE | re.MULTILINE)
m = pattern.search(stdout)
return int(m.group(1)) if m else 0
def _kb_out(stdout: str) -> int:
return round(len(stdout.encode("utf-8")) / 1024)
def data_factory_record_run(
node_id: str,
function_id: str,
args: list | None = None,
db_path: str | None = None,
trigger: str = "manual",
) -> dict:
"""Wrap `fn run <function_id>` and record execution metrics in data_factory.db.
Args:
node_id: ID of the node in data_factory.db.nodes that owns this run.
function_id: Registry function ID to execute (passed to `fn run`).
args: Extra CLI args forwarded to `fn run` after function_id.
db_path: Absolute path to data_factory.db. Defaults to
${FN_REGISTRY_ROOT}/apps/data_factory/data_factory.db.
trigger: Origin of the run — 'manual'|'cron'|'dag'|'api'.
Returns:
dict with keys: run_id, status, rows_out, kb_out, duration_ms, stdout, stderr.
"""
import time
# --- resolve FN_REGISTRY_ROOT ---
registry_root = os.environ.get("FN_REGISTRY_ROOT", "").strip()
if not registry_root:
raise RuntimeError(
"FN_REGISTRY_ROOT env var is not set. "
"Export it before calling data_factory_record_run."
)
registry_root = Path(registry_root)
# --- resolve db_path ---
if db_path is None:
db_path = registry_root / "apps" / "data_factory" / "data_factory.db"
db_path = Path(db_path)
if not db_path.exists():
raise FileNotFoundError(f"data_factory.db not found at {db_path}")
# --- resolve fn binary ---
fn_bin = registry_root / "fn"
if not fn_bin.exists():
raise FileNotFoundError(
f"fn binary not found at {fn_bin}. "
"Run `CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/` in FN_REGISTRY_ROOT."
)
# --- generate run_id ---
run_id = uuid.uuid4().hex[:16]
# --- INSERT running record ---
started_at = _now_iso8601()
try:
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys = ON")
try:
conn.execute(
"INSERT INTO runs(id, node_id, started_at, status, trigger) VALUES (?,?,?,?,?)",
(run_id, node_id, started_at, "running", trigger),
)
conn.commit()
except sqlite3.IntegrityError as e:
conn.close()
raise RuntimeError(
f"FK violation — node_id '{node_id}' does not exist in nodes table. "
f"Insert the node first. SQLite error: {e}"
)
except sqlite3.Error as e:
raise RuntimeError(f"Failed to open/write data_factory.db at {db_path}: {e}")
# --- run fn ---
cmd = [str(fn_bin), "run", function_id] + (args or [])
t0 = time.monotonic()
try:
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(registry_root))
except Exception as e:
duration_ms = _elapsed_ms(t0)
finished_at = _now_iso8601()
conn.execute(
"UPDATE runs SET finished_at=?, status=?, duration_ms=?, error=? WHERE id=?",
(finished_at, "failed", duration_ms, str(e)[:2000], run_id),
)
conn.commit()
conn.close()
return {
"run_id": run_id,
"status": "failed",
"rows_out": 0,
"kb_out": 0,
"duration_ms": duration_ms,
"stdout": "",
"stderr": str(e),
}
duration_ms = _elapsed_ms(t0)
finished_at = _now_iso8601()
stdout = result.stdout or ""
stderr = result.stderr or ""
status = "success" if result.returncode == 0 else "failed"
rows_out = _parse_rows_out(stdout)
kb = _kb_out(stdout)
error_text = stderr[:2000] if status == "failed" else ""
conn.execute(
"""UPDATE runs
SET finished_at=?, status=?, rows_out=?, kb_out=?,
duration_ms=?, error=?
WHERE id=?""",
(finished_at, status, rows_out, kb, duration_ms, error_text, run_id),
)
conn.commit()
conn.close()
return {
"run_id": run_id,
"status": status,
"rows_out": rows_out,
"kb_out": kb,
"duration_ms": duration_ms,
"stdout": stdout,
"stderr": stderr,
}