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 d6175964e4
commit 212875ed0d
290 changed files with 12703 additions and 19778 deletions
+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,
}