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:
@@ -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)*
|
||||
@@ -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())
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user