Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8917105184 | |||
| 88eabb0457 | |||
| ebb00d8a42 | |||
| e142ef026d | |||
| c4cff5ed5b | |||
| caf8c25d99 | |||
| 7ac69ab4fb | |||
| 02301aaed3 | |||
| 2729629f0a | |||
| 6cc90558d4 | |||
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 | |||
| c79f33265e | |||
| 31c2f6ac7f | |||
| 3bc97828e3 | |||
| ccdd529bdc | |||
| 741724f633 | |||
| 2be62f6ef6 | |||
| 8e9e1e6c8a | |||
| ec46aae04c | |||
| b173ac2703 | |||
| ec0a5e53ac | |||
| 5280499df5 | |||
| 346f859b86 | |||
| 604d3d4feb | |||
| 287abbd6ee | |||
| f8793f96ac | |||
| 643ebfb849 | |||
| 537516e32e | |||
| ca07b25297 | |||
| fbbff7d5e7 | |||
| bdd841d9af | |||
| 7d33b39859 | |||
| a1074d32e7 | |||
| fd16453691 | |||
| 5494507c39 | |||
| dfb3eda087 | |||
| 83738d4035 | |||
| b77d223f01 | |||
| e178ab8d2d | |||
| cda36408d0 | |||
| 10dbc510b7 | |||
| d3d846f748 | |||
| a5748cb147 | |||
| 0eefb7cfcd | |||
| 9f0d2e2338 | |||
| 2bab120d7c | |||
| d08667df9b | |||
| 9f1d643013 | |||
| 914def9e5c | |||
| 1012355998 | |||
| 1585e986c1 | |||
| e1f1be02ce | |||
| a27dcc028c | |||
| 8a4cc323a3 | |||
| 2a7c77cb56 | |||
| fa94f7a235 | |||
| 0ce1c31fb9 | |||
| 5a0818ee9c | |||
| 1a8093a7be | |||
| ba302dd793 | |||
| 0421bc6d4f | |||
| 5662a54fa7 | |||
| b45165dbc5 | |||
| dbb040aa12 | |||
| 91cf683289 | |||
| 696148d56b | |||
| 19ad2b3e5d | |||
| b88730b7cb | |||
| 6add50311b | |||
| ab27c253c5 | |||
| 8fb10fdf8a | |||
| 0c1d2aa4fc | |||
| 2ff111bae4 | |||
| d7387d9d2c | |||
| 03df14df97 | |||
| d0960bed70 | |||
| 0dd2718c95 | |||
| 4c4eec4b1d | |||
| f5387aa30e | |||
| 3980fbbffb | |||
| 4886305d49 | |||
| 404e2e4d0c | |||
| 3f465aceed | |||
| 3be8b28a8f | |||
| aeefd09f19 | |||
| e57da2f6d5 | |||
| 9508fff282 | |||
| 8121e4b04e | |||
| 4302212b34 | |||
| 394221f8c7 | |||
| 69d9aed46a | |||
| c36c80dda9 | |||
| 3887e59092 | |||
| d5660aa13f | |||
| a56b6e36ea | |||
| 5f0df32728 | |||
| 6d1b66167d | |||
| 04ecf9f394 | |||
| 46954d8584 | |||
| 6f4b440762 | |||
| bcf731275e | |||
| 974cc06bc7 | |||
| 70d541fca9 | |||
| e8a66f0dad | |||
| 898502a321 | |||
| 2fe36e314e | |||
| 11ef8ef6db | |||
| 3e75d1bf79 | |||
| 68f0ce0dae | |||
| c0b2dce3b0 | |||
| ff41f4f053 | |||
| f686b338d6 | |||
| 3823a28d1c | |||
| 337f75b527 | |||
| d3f05a19a5 | |||
| d7245efa59 | |||
| 1311c7e585 | |||
| db4f454f8a | |||
| f12272d002 | |||
| 495f545ec1 | |||
| f34badb500 | |||
| 3289c67986 | |||
| bcc1fe1738 | |||
| 7619347be8 | |||
| f55e41cf74 | |||
| e2e8669edf | |||
| 86d68dc9f0 | |||
| b18759823d | |||
| a59d50238d | |||
| f17d957a8f | |||
| c1f355ffa5 | |||
| 237f763c19 | |||
| 3cf8b21fea |
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
description: Modo ausente — el orquestador itera solo (lanza agentes, verifica cierres, genera tareas del roadmap, push periódico) sin supervisión, hasta que el humano vuelva. Auto-continúa con ScheduleWakeup.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /ausente — orquestador autónomo desatendido
|
||||||
|
|
||||||
|
Activa un **loop autónomo del modo orquestador**: el humano se va y tú sigues trabajando solo
|
||||||
|
—lanzando agentes, verificando sus cierres, cerrando los que cumplen su DoD, generando tareas
|
||||||
|
nuevas cuando la flota se vacía, y sincronizando— **hasta que el humano vuelva**. Es el modo
|
||||||
|
orquestador (`.claude/commands/orquestador.md` + `.claude/rules/orchestration.md`) corriendo sin
|
||||||
|
prompts humanos, con un mecanismo de auto-continuación.
|
||||||
|
|
||||||
|
Requisito: estar ya en modo orquestador (`role=orchestrator`). `/ausente` NO sustituye al
|
||||||
|
orquestador, lo deja en piloto automático.
|
||||||
|
|
||||||
|
## Configuración de esta sesión (elegida por el humano)
|
||||||
|
- **Al vaciarse la flota**: seguir el **roadmap ComfyUI** — generar tareas nuevas sin parar.
|
||||||
|
- **Git**: **push periódico** — `/full-git-push` tras cada bloque de tareas cerrado.
|
||||||
|
- **Límite**: **hasta que el humano vuelva** — heartbeat ~25 min + el watcher; tope DURO de 6
|
||||||
|
ejecutores a la vez; parar en cuanto el humano escriba.
|
||||||
|
|
||||||
|
(Si se reinvoca `/ausente` en otra sesión, re-confirmar estas 3 con el humano vía AskUserQuestion.)
|
||||||
|
|
||||||
|
## El bucle (cada vez que te re-invocan: por FLEET-DONE del watcher o por el heartbeat)
|
||||||
|
|
||||||
|
1. **Drena la flota**: `./fn run drain_fleet_events`. Para cada ejecutor `DICE_TERMINADO`:
|
||||||
|
**verifica de primera mano** (lee su report + comprueba en disco/CDP que el golden existe — no
|
||||||
|
te fíes del autodeclarado). Si cumple el DoD → `set_dod_contract <sid> "<c>" met` y **ciérralo
|
||||||
|
con `kill <PID>` directo** (NUNCA `kill_fleet_agent`/`kill-window`: cierra windows ajenas y se
|
||||||
|
llevó la console de fleetview — incidente real). Si falla → nudge con el gap concreto.
|
||||||
|
2. **Nudge** a los `ESTANCADO` (idle > 10 min con DoD sin cerrar). NUNCA a `waiting`.
|
||||||
|
3. **¿Flota con hueco?** (< 6 ejecutores y hay backlog) → **genera la siguiente tarea del roadmap**
|
||||||
|
(lista abajo), escribe su prompt autocontenido con aislamiento + DoD-contrato, lánzala con
|
||||||
|
`spawn_fleet_agent --parent <tu-sid>`, fíjale nombre (`fleet_set_name`) + DoD. Respeta el tope
|
||||||
|
de 6 y la disjunción de recursos (server/venv/GPU vs functions+fn_index vs disco — ver
|
||||||
|
`orchestration.md`): solo UN agente dueño del server/venv a la vez; solo UNO toca
|
||||||
|
`functions/`+`fn index` a la vez; los descargadores de modelos van a carpetas distintas.
|
||||||
|
4. **Push periódico**: cuando cierres un bloque (>=1 tarea met e integrada), corre
|
||||||
|
`./fn run full_git_push_bash_pipelines ""` y verifica que el padre queda alineado con
|
||||||
|
`origin/master`. Diagnostica y reintenta si falla (regla de `/full-git-push`).
|
||||||
|
5. **Bitácora**: añade una línea al report de bitácora `reports/NNNN-ausente-bitacora.md` (créalo
|
||||||
|
la primera vez): timestamp + qué cerraste + qué lanzaste + push. Es lo que el humano lee al
|
||||||
|
volver.
|
||||||
|
6. **Reprograma el heartbeat**: `ScheduleWakeup(delaySeconds≈1500, prompt="/ausente",
|
||||||
|
reason="loop ausente: vigilar flota + roadmap ComfyUI")`. Si hay agentes en vuelo, el watcher
|
||||||
|
te empujará sus FLEET-DONE antes (no hace falta wakeup corto); el heartbeat es el fallback para
|
||||||
|
cuando la flota está vacía y hay que generar tareas nuevas.
|
||||||
|
|
||||||
|
## Supervivencia a la compactación de contexto
|
||||||
|
El loop es de larga duración → el contexto se llenará. **Cuando te quedes sin contexto, deja que
|
||||||
|
el harness compacte la conversación y CONTINÚA el modo ausente** — no lo trates como una parada.
|
||||||
|
El modo sobrevive porque su estado es **durable fuera del contexto**:
|
||||||
|
- El `ScheduleWakeup(prompt="/ausente")` re-inyecta el modo en cada heartbeat (y el FLEET-DONE del
|
||||||
|
watcher también te re-entra).
|
||||||
|
- La **bitácora** `reports/ausente-bitacora-2026-06-24.md` es la memoria persistente: qué se cerró,
|
||||||
|
qué se lanzó, qué falta del backlog, último push. **Tras una compactación, lo PRIMERO es releer
|
||||||
|
la bitácora** (y `fleet_list`) para reconstruir el estado y seguir donde lo dejaste.
|
||||||
|
- Mantén la bitácora al día en CADA turno (no solo al cerrar bloques) para que la compactación
|
||||||
|
nunca pierda progreso. El comando `/ausente` + `orchestration.md` reconstruyen la doctrina.
|
||||||
|
Una compactación NO es el humano volviendo — sigue iterando con normalidad.
|
||||||
|
|
||||||
|
## Parada
|
||||||
|
- **El humano vuelve** = recibes un prompt que NO es un FLEET-DONE ni el `/ausente` del heartbeat
|
||||||
|
(es texto del humano). Entonces: **no reprogrames el wakeup**, resume todo lo hecho durante la
|
||||||
|
ausencia (lee la bitácora) y vuelve al modo orquestador interactivo normal.
|
||||||
|
- Si el backlog del roadmap se agota del todo (raro): haz un último push, deja la flota cerrada,
|
||||||
|
escribe el resumen en la bitácora, programa un heartbeat largo y queda a la espera.
|
||||||
|
|
||||||
|
## Reglas duras (más estrictas sin supervisión)
|
||||||
|
- **Nada destructivo ni irreversible sin el humano**: no borrar datos/modelos/repos, no `git push
|
||||||
|
--force`, no tocar producción/VPS, no mandar nada hacia afuera (correos, mensajes, APIs con
|
||||||
|
efecto), no pagar/descargar gated de pago. Ante la duda, NO lo hagas: déjalo anotado en la
|
||||||
|
bitácora como "pendiente de revisión humana".
|
||||||
|
- **Aislamiento git por agente** SIEMPRE (sub-repo / worktree / scope disjunto). Ningún agente
|
||||||
|
commitea el padre salvo el push periódico que corres tú.
|
||||||
|
- **Tope 6 ejecutores**. Encola el resto.
|
||||||
|
- **Cierre por `kill <PID>`**, jamás `pkill`/`killall`/`kill_fleet_agent` (protege la TUI/console
|
||||||
|
de fleetview y a ti mismo).
|
||||||
|
- **Verificación adversarial**: el golden de cada cierre se comprueba en disco/CDP/ejecución, no
|
||||||
|
por lo que el agente diga. Honestidad en la bitácora (gaps incluidos).
|
||||||
|
- Cada agente full-capaz sigue registry-first y delega a `fn-constructor`; tú no escribes lógica
|
||||||
|
reutilizable inline.
|
||||||
|
|
||||||
|
## Backlog del roadmap ComfyUI (fuente de tareas a generar; prioriza arriba→abajo)
|
||||||
|
Base: `reports/0064-comfyui-roadmap-plan.md` + propuestas de los reports 0069/0073/0075/0079.
|
||||||
|
1. **Funciones 3D propuestas pendientes**: `comfyui_build_view_3d_workflow`,
|
||||||
|
`comfyui_generate_views_from_image` (Zero123/SV3D), `comfyui_text_to_3d_oneshot` (pipeline),
|
||||||
|
`comfyui_build_multiview_textured_3d_workflow`. (Dueño de functions/+fn index, uno a la vez.)
|
||||||
|
2. **`comfyui_download_workflow`** (detecta Drive/GitHub/Civitai/PNG → API format) — del catálogo
|
||||||
|
de fuentes (report `comfyui-wf-sources`).
|
||||||
|
3. **P2 del roadmap**: `comfyui_batch_generate`, `comfyui_interrupt_queue`,
|
||||||
|
`comfyui_ensure_server` (systemd-user con --lowvram + health).
|
||||||
|
4. **Vídeo end-to-end**: montar workflow LTX-Video y Wan2.1 (modelos ya en /mnt/2tb), generar un
|
||||||
|
clip corto SFW de prueba, validar VRAM 8GB; capitalizar `comfyui_build_video_workflow`.
|
||||||
|
5. **Calidad 3D**: decimación de mesh (`fast_simplification`, gap del 0069) + watertight
|
||||||
|
(`VoxelToMesh`); función `comfyui_simplify_mesh`.
|
||||||
|
6. **Librería de workflows**: bajar+validar los ejemplos recomendados por `comfyui-wf-sources`,
|
||||||
|
dejarlos en una librería local validada contra nuestro server.
|
||||||
|
7. **Higiene**: `fn doctor` sobre las funciones nuevas (uses-functions/unused), capability page
|
||||||
|
`docs/capabilities/comfyui.md` al día, tests de las funciones sin cobertura.
|
||||||
|
8. Cuando ideas concretas se agoten: un agente "completeness critic" que audite el grupo `comfyui`
|
||||||
|
y proponga el siguiente lote.
|
||||||
|
|
||||||
|
Cada tarea generada respeta el patrón del orquestador: prompt autocontenido (objetivo, dir,
|
||||||
|
aislamiento, qué entrega, DoD-contrato golden+edge+error), `--parent`, nombre + DoD fijados al
|
||||||
|
lanzar, verificación de primera mano al cerrar.
|
||||||
|
|
||||||
|
## Relación
|
||||||
|
- `.claude/commands/orquestador.md` — el modo base; `/ausente` es su versión desatendida.
|
||||||
|
- `.claude/rules/orchestration.md` — maquinaria (drain, clasificación, verificador, nudge, tope).
|
||||||
|
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox). `/ausente` NO es
|
||||||
|
eso: aquí TÚ (el orquestador interactivo) sigues conduciendo la flota de Claudes interactivos.
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
---
|
||||||
|
description: Genera en un vault Obsidian un resumen capítulo a capítulo de uno o varios libros, siguiendo el formato de notas del vault captacion_clientes (MOC de libro + una nota por capítulo + MOC de categoría, todo enlazado con wikilinks).
|
||||||
|
---
|
||||||
|
|
||||||
|
# /capitulos — resumen de libros capítulo a capítulo en Obsidian
|
||||||
|
|
||||||
|
Genera notas de estudio de un libro (o varios) en un vault Obsidian, replicando el formato
|
||||||
|
canónico del vault `captacion_clientes`: una nota MOC por libro, una nota por capítulo, y una
|
||||||
|
nota MOC de categoría que agrupa los libros. Todo enlazado con wikilinks `[[ ]]` para que
|
||||||
|
Obsidian construya el grafo.
|
||||||
|
|
||||||
|
## Argumentos
|
||||||
|
|
||||||
|
`$ARGUMENTS` contiene, en lenguaje natural, los libros a procesar y opcionalmente el destino.
|
||||||
|
Interpreta:
|
||||||
|
|
||||||
|
- **Libros** — uno o varios títulos. Pueden venir con autor ("Forecasting de Hyndman"). Si el
|
||||||
|
usuario dice "los libros que me has dicho" o similar, usa los que se recomendaron en la
|
||||||
|
conversación previa.
|
||||||
|
- **Vault destino** — si no se especifica, **PREGUNTA** antes de escribir (ver Decisiones).
|
||||||
|
Vault por defecto de ejemplo de formato: `/home/enmanuel/Obsidian/captacion_clientes`.
|
||||||
|
- **Categoría** — la subcarpeta bajo `Libros/` que agrupa los libros (ej. "Marca y Mercado",
|
||||||
|
"Datos e Inversión"). Si no se da, propón una coherente con el tema de los libros y confírmala.
|
||||||
|
- **Profundidad** — `completo` (default, como The Mom Test: idea central + puntos clave +
|
||||||
|
citas + aplicación por capítulo) o `breve` (idea central + 3 bullets por capítulo).
|
||||||
|
|
||||||
|
## Decisiones a confirmar antes de escribir (si faltan en los argumentos)
|
||||||
|
|
||||||
|
Usa `AskUserQuestion` para resolver lo que cambie el trabajo, NO inventes:
|
||||||
|
|
||||||
|
1. **Vault y categoría destino** — dónde se crean las notas.
|
||||||
|
2. **Alcance** — qué libros exactamente y cuántos (si la lista es grande, confirma si son
|
||||||
|
todos o un subconjunto; cada libro es trabajo no trivial).
|
||||||
|
3. **Enfoque de "Aplicación"** — el ángulo desde el que se escribe la sección "Aplicación a mi
|
||||||
|
negocio / a mi caso" de cada capítulo (ej. inversión cuantitativa, data-analyst, SaaS…).
|
||||||
|
El vault de captación lo orienta al negocio del usuario; mantén ese espíritu pero ajustado
|
||||||
|
al tema real de los libros.
|
||||||
|
|
||||||
|
## Estructura de archivos a crear
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault>/Libros/<Categoría>/
|
||||||
|
<Categoría> - MOC.md # MOC de categoría (crear o ACTUALIZAR, no sobrescribir)
|
||||||
|
<Libro>/
|
||||||
|
<Libro> - MOC.md # MOC del libro
|
||||||
|
01 - <Título capítulo>.md # una nota por capítulo, NN zero-padded a 2 dígitos
|
||||||
|
02 - <Título capítulo>.md
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Carpeta por libro, archivo por capítulo. Nombre de capítulo: `NN - <Título>.md` con `NN`
|
||||||
|
empezando en `01`. Si el capítulo tiene título original en otro idioma, puedes incluir la
|
||||||
|
traducción entre paréntesis como en el vault (`01 - The Mom Test (El test de la madre).md`).
|
||||||
|
- Nombres de archivo sin caracteres que rompan en Obsidian (evita `/`, `:`; los paréntesis y
|
||||||
|
acentos son válidos).
|
||||||
|
|
||||||
|
## Determinar los capítulos de cada libro
|
||||||
|
|
||||||
|
Para listar los capítulos reales de un libro:
|
||||||
|
|
||||||
|
1. Usa tu conocimiento del libro si lo conoces con fiabilidad (índice real, no inventado).
|
||||||
|
2. Si no estás seguro del índice exacto, **búscalo en la web** (`WebSearch` / `WebFetch` sobre
|
||||||
|
la tabla de contenidos del libro) antes de escribir. No inventes capítulos.
|
||||||
|
3. Indica en el MOC del libro si el índice procede de una edición concreta.
|
||||||
|
|
||||||
|
**Regla dura:** nunca te inventes el número o los títulos de los capítulos. Si no puedes
|
||||||
|
verificarlos, dilo y pregunta al usuario en vez de fabricar un índice plausible.
|
||||||
|
|
||||||
|
## Plantilla — MOC del libro (`<Libro> - MOC.md`)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Libro> - MOC
|
||||||
|
book: <Libro>
|
||||||
|
author: <Autor>
|
||||||
|
year: <Año>
|
||||||
|
type: book-moc
|
||||||
|
tags:
|
||||||
|
- <slug-libro>
|
||||||
|
- <tema-1>
|
||||||
|
- moc
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Libro> — Mapa de contenidos (MOC)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
- **Autor:** <Autor>
|
||||||
|
- **Año:** <Año> (<edición si aplica>)
|
||||||
|
- **Subtítulo:** *<subtítulo original>* (<traducción>)
|
||||||
|
- **Tema:** <de qué va en una frase>
|
||||||
|
- **Por qué importa:** <2-3 frases sobre qué problema resuelve y para quién>
|
||||||
|
|
||||||
|
## Resumen global
|
||||||
|
<Un párrafo denso (8-15 líneas) que sintetiza la tesis del libro y recorre el hilo de los
|
||||||
|
capítulos sin enumerarlos uno a uno: cuenta el argumento completo en prosa.>
|
||||||
|
|
||||||
|
## Capítulos
|
||||||
|
1. [[01 - <Título capítulo>]]
|
||||||
|
2. [[02 - <Título capítulo>]]
|
||||||
|
...
|
||||||
|
|
||||||
|
## Aplicación a mi caso (visión transversal)
|
||||||
|
<Párrafo que conecta el libro entero con el objetivo concreto del usuario (el enfoque
|
||||||
|
confirmado en las Decisiones): qué capítulos son los más relevantes y por qué.>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plantilla — nota de capítulo (`NN - <Título>.md`)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Título capítulo>
|
||||||
|
book: <Libro>
|
||||||
|
author: <Autor>
|
||||||
|
chapter: <N>
|
||||||
|
type: chapter-summary
|
||||||
|
tags:
|
||||||
|
- <slug-libro>
|
||||||
|
- <tema>
|
||||||
|
---
|
||||||
|
|
||||||
|
# NN. <Título capítulo>
|
||||||
|
|
||||||
|
> Libro: [[<Libro> - MOC]]
|
||||||
|
|
||||||
|
## Idea central
|
||||||
|
<1-3 frases con la tesis del capítulo.>
|
||||||
|
|
||||||
|
## Puntos clave
|
||||||
|
- <bullet sustantivo, no genérico>
|
||||||
|
- <…>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## Ejemplos / citas
|
||||||
|
- <ejemplo concreto del capítulo o cita textual con su traducción si es en otro idioma>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## Aplicación a mi caso
|
||||||
|
<Párrafo concreto: cómo aplicar la idea del capítulo al caso del usuario.>
|
||||||
|
|
||||||
|
---
|
||||||
|
Anterior: [[NN-1 - <Título anterior>]] · Siguiente: [[NN+1 - <Título siguiente>]] · Índice: [[<Libro> - MOC]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Notas de la plantilla:
|
||||||
|
- El primer capítulo: `Anterior: —`. El último: `Siguiente: —`. (Ver patrón en el vault.)
|
||||||
|
- La sección "Aplicación" es obligatoria y debe ser específica del caso del usuario, no un
|
||||||
|
consejo genérico. Es lo que da valor a estas notas frente a un resumen cualquiera.
|
||||||
|
- En profundidad `breve`, omite "Ejemplos / citas" y deja "Puntos clave" en 3 bullets.
|
||||||
|
|
||||||
|
## Plantilla — MOC de categoría (`<Categoría> - MOC.md`)
|
||||||
|
|
||||||
|
Si ya existe, **ACTUALÍZALO** añadiendo los libros nuevos a la sección que corresponda (no lo
|
||||||
|
reescribas perdiendo lo previo). Si no existe, créalo:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Categoría> — MOC
|
||||||
|
type: moc
|
||||||
|
tags:
|
||||||
|
- libros
|
||||||
|
- <tema-categoría>
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Categoría> — Mapa de contenidos
|
||||||
|
|
||||||
|
<Frase que describe el tema común de los libros de esta categoría.>
|
||||||
|
|
||||||
|
Cada libro tiene su propia nota MOC con el índice de capítulos enlazados.
|
||||||
|
|
||||||
|
## <Sub-tema 1>
|
||||||
|
- [[<Libro A> - MOC]] — <Autor>. <una línea de qué aporta>.
|
||||||
|
- [[<Libro B> - MOC]] — <Autor>. <…>.
|
||||||
|
|
||||||
|
## Orden de lectura recomendado
|
||||||
|
1. **<Libro>** — <por qué primero>.
|
||||||
|
2. ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo de ejecución
|
||||||
|
|
||||||
|
1. Parsear `$ARGUMENTS`: libros, vault, categoría, profundidad, enfoque.
|
||||||
|
2. Resolver decisiones faltantes con `AskUserQuestion`.
|
||||||
|
3. Para cada libro: verificar el índice real de capítulos (conocimiento fiable o WebSearch).
|
||||||
|
4. Crear carpeta del libro. Escribir el MOC del libro y todas las notas de capítulo con
|
||||||
|
wikilinks y navegación correctos.
|
||||||
|
5. Crear o actualizar el MOC de categoría enlazando los libros nuevos.
|
||||||
|
6. **Paralelización:** si son varios libros, cada libro es independiente (carpetas disjuntas).
|
||||||
|
En modo orquestador, lanza un ejecutor por libro (o por lote de libros) escribiendo en
|
||||||
|
carpetas distintas del mismo vault. Cada ejecutor escribe SOLO su carpeta de libro; el MOC
|
||||||
|
de categoría lo actualiza UN único agente al final (o el orquestador) para evitar que dos
|
||||||
|
ejecutores editen el mismo archivo a la vez.
|
||||||
|
7. Reportar: lista de archivos creados (MOC + nº de capítulos por libro) y la ruta del vault
|
||||||
|
para abrirlo en Obsidian.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **El vault es artefacto local** (gitignored en fn_registry, symlink a `~/Obsidian/<vault>`).
|
||||||
|
Escribir notas NO toca el repo `fn_registry`. Si el vault es su propio repo git, NO commitees
|
||||||
|
desde varios ejecutores a la vez (race): deja el commit/sync al usuario o a un único paso final.
|
||||||
|
- **No sobrescribas** un MOC de categoría existente ni notas de capítulo ya escritas a mano sin
|
||||||
|
confirmarlo. Ante colisión de nombre, pregunta.
|
||||||
|
- **Índices inventados = bug.** Verifica los capítulos reales antes de escribir.
|
||||||
|
- **Wikilinks deben resolver:** el texto dentro de `[[ ]]` debe coincidir exactamente con el
|
||||||
|
nombre de archivo (sin extensión). Un typo rompe el enlace en Obsidian.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
description: EDA (exploratory data analysis) de una tabla o de una base entera con el grupo `eda` del registry. Perfila, escribe el report (JSON + Markdown + PDF móvil) y monta un analysis Jupyter lanzado en el navegador colaborativo y ejecutado en vivo por Claude.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /eda — Exploratory Data Analysis con el grupo `eda`
|
||||||
|
|
||||||
|
Cuando Enmanuel pide un EDA ("hazme un EDA de X", "analiza esta tabla", "qué hay en estos datos"), **no escribas análisis inline**: usa el grupo de capacidad `eda` del registry, escribe los reports y monta el analysis Jupyter en su navegador colaborativo, ejecutando las celdas tú mismo en vivo. Respeta la memoria `eda-workflow-registry` y la regla `.claude/rules/notebook_collaboration.md`.
|
||||||
|
|
||||||
|
Página madre del grupo: `docs/capabilities/eda.md` (léela primero para cargar el cluster entero).
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```
|
||||||
|
/eda /ruta/datos.duckdb tabla # EDA de una tabla DuckDB
|
||||||
|
/eda /ruta/datos.csv # CSV/Parquet → cargar a DuckDB y perfilar
|
||||||
|
/eda postgresql://user:pass@host:5432/db tabla # EDA de una tabla PostgreSQL (backend="postgres")
|
||||||
|
/eda /ruta/datos.duckdb --all # EDA de TODA la base (todas las tablas + FK + join graph)
|
||||||
|
/eda /ruta/datos.duckdb ventas --series --pdf # con análisis de serie temporal + PDF móvil
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ARGUMENTS` lleva la fuente y, opcionalmente, la tabla y flags. Interpreta:
|
||||||
|
- **Fuente**: ruta a `.duckdb`/`.csv`/`.parquet`, o un DSN PostgreSQL (`postgresql://...` o `postgres://...`).
|
||||||
|
- **Tabla**: nombre de la tabla. Si no se da y la fuente es un único archivo CSV/Parquet, usa su nombre base. Si se pide "toda la base" / `--all`, usa `profile_database`.
|
||||||
|
- **Flags** (actívalos según lo que pida el usuario; pregunta solo si es ambiguo y costoso):
|
||||||
|
- `--models` → `run_models=True` (PCA/KMeans/IsolationForest/normalidad).
|
||||||
|
- `--llm` → `run_llm=True` (1 call LLM sobre el perfil agregado).
|
||||||
|
- `--series` → `run_series=True` (estacionariedad ADF+KPSS, ACF/PACF, STL, retornos por columna numérica).
|
||||||
|
- `--pdf` → `emit_pdf=True` (PDF A5 vertical legible en móvil).
|
||||||
|
|
||||||
|
Por defecto, para un EDA "completo" cuando el usuario no especifica, activa `run_models`, `run_series` y `emit_pdf`; deja `run_llm` para cuando lo pida o cuando interese la interpretación semántica (es la única parte que gasta tokens del modelo).
|
||||||
|
|
||||||
|
## Reglas duras
|
||||||
|
|
||||||
|
1. **Registry-first**: invoca las funciones del grupo `eda`, no reescribas lógica de perfilado ni de gráficos inline (regla `registry_first.md`).
|
||||||
|
2. **CSV/Parquet/Excel** entran cargándolos antes a DuckDB (`read_csv_auto`/`read_parquet`/`read_xlsx`) — DuckDB es el motor por defecto. No traigas la tabla entera a RAM.
|
||||||
|
3. **Secretos**: si la fuente es un DSN PostgreSQL con credenciales, NO las imprimas en los reports ni en el notebook; resuélvelas vía `resolve_pg_dsn`/`pass` cuando aplique.
|
||||||
|
4. **El report es un artefacto local**: vive en `reports/` (gitignored), no se sube a Gitea ni se versiona. Compartir = pasar la ruta (regla `reports.md`).
|
||||||
|
5. **Entrega las 4 salidas**: JSON sidecar + Markdown + **PDF móvil** + **notebook Jupyter colaborativo ejecutado en vivo**.
|
||||||
|
|
||||||
|
## Paso 1 — Perfilar y escribir los reports
|
||||||
|
|
||||||
|
Una tabla (caso normal):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
from pipelines.profile_table import profile_table
|
||||||
|
r = profile_table(
|
||||||
|
"/ruta/datos.duckdb", "ventas",
|
||||||
|
run_models=True, run_series=True, emit_pdf=True, run_llm=False,
|
||||||
|
)
|
||||||
|
print("status:", r["status"])
|
||||||
|
print("md: ", r["report_md_path"])
|
||||||
|
print("json: ", r["report_json_path"])
|
||||||
|
print("pdf: ", r["pdf_path"])
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Una base entera (todas las tablas + relaciones FK):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
from pipelines.profile_database import profile_database
|
||||||
|
r = profile_database("/ruta/datos.duckdb")
|
||||||
|
print(r["db_profile"]["join_graph"]["mermaid"])
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Lee el Markdown resultante y resume a Enmanuel lo esencial: forma, calidad, correlaciones fuertes (ya corregidas por FDR), series no estacionarias, transformaciones sugeridas y avisos exploratorios.
|
||||||
|
|
||||||
|
## Paso 2 — Notebook Jupyter colaborativo, ejecutado en vivo por Claude
|
||||||
|
|
||||||
|
Sigue la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`:
|
||||||
|
|
||||||
|
1. Genera el notebook con `build_eda_notebook` (mismo perfil de la tabla):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
from datascience import build_eda_notebook
|
||||||
|
build_eda_notebook("/ruta/datos.duckdb", "ventas",
|
||||||
|
"analysis/eda_ventas/notebooks/01_eda.ipynb", run_models=True)
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
(o crea un analysis dedicado con `fn run init_jupyter_analysis eda_ventas duckdb` y escribe el notebook dentro de `notebooks/`).
|
||||||
|
|
||||||
|
2. Confirma que hay Jupyter colaborativo activo con `jupyter_discover` (o lánzalo con el `run-jupyter-lab.sh` del analysis) y **ábrelo en el navegador colaborativo** para que Enmanuel lo vea en vivo.
|
||||||
|
|
||||||
|
3. **Ejecuta tú las celdas** (no se las dejes para que las corra él): usa las funciones del dominio `notebook` (`jupyter_exec` append+execute / `jupyter_read`) descritas en `notebook_collaboration.md`, o el MCP `jupyter` si está conectado en la sesión del analysis. Ejecuta de arriba a abajo, comenta cada bloque relevante y deja el notebook navegable.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El `TableProfile` lleva ahora, además del perfilado base y las correlaciones con FDR: `series` (por columna numérica, con `run_series`), `reexpression` por columna numérica (escalera de Tukey) y `caveats` (siempre, avisos exploratorios). El Markdown y el PDF renderizan estas secciones automáticamente cuando están presentes.
|
||||||
|
- El PDF (`emit_pdf`) está pensado para leerse en el móvil (A5 vertical, tipografía grande, gráficos Tufte). Se escribe junto al Markdown en `reports/`.
|
||||||
|
- `run_series` ordena por la primera columna datetime si existe; si no, por el orden físico de filas. Necesita ≥8 puntos válidos por columna.
|
||||||
|
- Fuentes: DuckDB (CSV/Parquet/Excel cargados antes) y PostgreSQL (`backend="postgres"`). `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
description: "Espejo de requisitos: Claude reformula con detalle la última tarea pedida (objetivo, alcance, entregables, supuestos, criterios de aceptación, fuera de alcance y dudas) para confirmar alineación antes de ejecutar. No ejecuta nada."
|
||||||
|
argument-hint: "[opcional: matiz o foco a tener en cuenta al reformular]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /equal — confirmar alineación reformulando la tarea pedida
|
||||||
|
|
||||||
|
Mecanismo de **espejo de requisitos**. Cuando el usuario invoca `/equal`, NO ejecutas la tarea: devuelves tu interpretación detallada y estructurada del encargo más reciente, para que el usuario confirme o corrija antes de que empieces a trabajar.
|
||||||
|
|
||||||
|
El objetivo es eliminar el malentendido silencioso: prefieres gastar un turno reflejando lo que crees que se te pide que arrancar en la dirección equivocada.
|
||||||
|
|
||||||
|
## Qué hacer al invocarse
|
||||||
|
|
||||||
|
1. **Identifica la tarea más reciente que el usuario te ha pedido** en la conversación actual: la última petición de trabajo real, no el `/equal` en sí ni un comando de utilidad anterior. Si hay `$ARGUMENTS`, úsalos como matiz o foco adicional al reformular (p. ej. "céntrate en el alcance" o "asume que es solo el backend"), no como la tarea nueva.
|
||||||
|
|
||||||
|
2. **Reformula esa tarea de forma detallada y estructurada**, con estas secciones (omite una sección solo si es genuinamente no aplicable, no para abreviar):
|
||||||
|
|
||||||
|
- **Objetivo** — qué se quiere conseguir, en una o dos frases claras. El "para qué", no solo el "qué".
|
||||||
|
- **Alcance / qué incluye** — los trozos concretos de trabajo que entiendes incluidos. Lista, no párrafo.
|
||||||
|
- **Entregables** — qué archivos, cambios, salidas o artefactos concretos vas a producir.
|
||||||
|
- **Supuestos** — lo que estás asumiendo por defecto al no estar dicho explícitamente (stack, ubicación, convenciones, datos, alcance temporal). Hazlos visibles para que el usuario los pueda tumbar.
|
||||||
|
- **Criterios de aceptación** — cómo sabremos que está bien hecho. Condiciones verificables, no deseos vagos. Cuando aplique, golden + edge + caso de error (alineado con `dod_quality.md`).
|
||||||
|
- **Fuera de alcance** — lo que NO vas a hacer, para acotar expectativas y evitar scope creep.
|
||||||
|
- **Dudas / ambigüedades a confirmar** — preguntas concretas sobre lo que no está claro. Numéralas para que el usuario pueda responder por número. Si no hay dudas reales, dilo explícitamente ("sin dudas bloqueantes").
|
||||||
|
|
||||||
|
3. **Cierra pidiendo validación**: una línea clara del tipo "¿Alineado? Corrige lo que no cuadre y arranco." No empieces a trabajar hasta que el usuario confirme.
|
||||||
|
|
||||||
|
## Caso sin tarea previa
|
||||||
|
|
||||||
|
Si **no hay** una petición de trabajo clara que reformular (la conversación está vacía de encargos, o lo último fueron solo comandos de utilidad / charla), NO inventes una tarea. Dilo directamente y pide al usuario que describa qué quiere hacer, ofreciéndole la misma estructura como guía de qué información es útil (objetivo, entregables, criterios).
|
||||||
|
|
||||||
|
## Reglas duras
|
||||||
|
|
||||||
|
- **No ejecutas la tarea.** `/equal` solo refleja y pregunta. Nada de editar archivos, lanzar comandos de trabajo ni delegar a subagentes para "ir avanzando".
|
||||||
|
- **No inventes requisitos.** Lo que no se ha pedido va a *Supuestos* (marcado como asunción) o a *Dudas* (como pregunta), nunca colado como hecho.
|
||||||
|
- **Refleja lo que se pidió, no lo que crees que debería pedirse.** Si detectas una mejora obvia, propónla como una duda al final, no la incorpores al objetivo.
|
||||||
|
- **Sé concreto.** Evita reformulaciones genéricas que valdrían para cualquier tarea. Cita los detalles reales del encargo (nombres de archivos, rutas, tecnologías, cifras que el usuario mencionó).
|
||||||
|
- Mantén el comando **simple**: es un reformulador de requisitos, no un planificador de implementación ni un generador de tareas.
|
||||||
|
|
||||||
|
## Ejemplo de salida
|
||||||
|
|
||||||
|
Para una petición previa como *"añade un endpoint `/health` al servicio de deploy que devuelva 200 y la versión"*:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Objetivo
|
||||||
|
Añadir un health check HTTP al deploy_server para poder verificar de forma
|
||||||
|
programática que el servicio está vivo y saber qué versión corre.
|
||||||
|
|
||||||
|
## Alcance / qué incluye
|
||||||
|
- Nuevo handler GET `/health` en el servidor HTTP de deploy_server.
|
||||||
|
- Respuesta 200 con un cuerpo JSON que incluya la versión del binario.
|
||||||
|
- Registro de la ruta en el router existente.
|
||||||
|
|
||||||
|
## Entregables
|
||||||
|
- Cambio en el código del servidor de `apps/deploy_server/` (handler + ruta).
|
||||||
|
- Sin migración de BD (no toca schema).
|
||||||
|
|
||||||
|
## Supuestos
|
||||||
|
- La versión a devolver es la que ya expone el binario (no hay que crear un
|
||||||
|
sistema de versionado nuevo).
|
||||||
|
- Formato de respuesta JSON `{"status":"ok","version":"x.y.z"}`.
|
||||||
|
- Sin autenticación en `/health` (endpoint público de liveness).
|
||||||
|
|
||||||
|
## Criterios de aceptación
|
||||||
|
- Golden: `GET /health` con el servicio arriba → 200 + JSON con `version`.
|
||||||
|
- Edge: la versión se lee correctamente aunque el binario se compile sin tag.
|
||||||
|
- Error: si un subsistema crítico no está listo, devuelve 503, no 200 falso.
|
||||||
|
|
||||||
|
## Fuera de alcance
|
||||||
|
- Readiness check de dependencias remotas (VPS, SSH).
|
||||||
|
- Métricas / observabilidad más allá del 200.
|
||||||
|
|
||||||
|
## Dudas / ambigüedades a confirmar
|
||||||
|
1. ¿`/health` debe comprobar algo real (DB, disco) o basta con responder vivo?
|
||||||
|
2. ¿La versión sale de un ldflag de build, de un fichero, o de constante?
|
||||||
|
3. ¿Puerto y router son los que ya usa `deploy_server serve`?
|
||||||
|
|
||||||
|
¿Alineado? Corrige lo que no cuadre y arranco.
|
||||||
|
```
|
||||||
|
|
||||||
|
El ejemplo es ilustrativo del **formato y el nivel de detalle**; el contenido real sale siempre de la tarea concreta que el usuario haya pedido en la conversación.
|
||||||
@@ -86,6 +86,19 @@ de tmux.
|
|||||||
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
|
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
|
||||||
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
|
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
|
||||||
|
|
||||||
|
**Nombra cada secundario para diferenciarlo de un vistazo (regla dura).** Cuando lances varios a la
|
||||||
|
vez, el humano tiene que poder distinguirlos rápido en el sidebar de fleetview. Dos cosas:
|
||||||
|
|
||||||
|
1. **`--title` descriptivo y prefijado** en cada `spawn_fleet_agent`: un slug corto y único que diga
|
||||||
|
QUÉ hace ese agente, idealmente con una letra/índice para ordenarlos (`A·mcp-rename`,
|
||||||
|
`B·sql-navision`, `C·kanban`, `D·equal-skill`). Esto nombra la window tmux.
|
||||||
|
2. **El nombre del sidebar fleetview = el campo `goal`** del `~/.claude/goals/<sid>.json`. En cuanto
|
||||||
|
resuelvas el `sessionId` del secundario, fíjale un nombre claro con la tool
|
||||||
|
`mcp__orchestrator__fleet_set_name` (o `./fn run set_fleet_name` cuando exista el fallback CLI) —
|
||||||
|
mismo slug descriptivo que el `--title`. Si esa capacidad aún no está disponible en la sesión,
|
||||||
|
apóyate solo en `--title` y en que el `goal` autogenerado del prompt sea descriptivo, pero el
|
||||||
|
objetivo es que el sidebar liste nombres legibles, no objetivos genéricos repetidos.
|
||||||
|
|
||||||
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
|
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
|
||||||
|
|
||||||
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
|
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
|
|||||||
|
|
||||||
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
|
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
|
||||||
|
|
||||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate, save, bump, harvest, judge, critique`
|
||||||
|
|
||||||
### Excepciones
|
### Excepciones
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ La fuente de verdad del mapeo PID→sessionId→cwd son los archivos `~/.claude/
|
|||||||
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
|
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, tmux_window, age, idle_seconds
|
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, pane_id ("%N", el id estable), tmux_window ("@N", interno para focus/send-keys), age, idle_seconds
|
||||||
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
|
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ devuelven salida estructurada y se registran en la telemetría como cualquier MC
|
|||||||
|
|
||||||
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, tmux_window, age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, **pane_id** (el id estable), age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
||||||
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
|
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
|
||||||
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
|
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
|
||||||
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
|
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
|
||||||
@@ -69,6 +69,16 @@ Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directa
|
|||||||
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
|
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
|
||||||
el sidecar a mano — filtra por el `role` que ya trae cada fila.
|
el sidecar a mano — filtra por el `role` que ya trae cada fila.
|
||||||
|
|
||||||
|
**Identifica a cada agente por su `pane_id` ("%N").** Es el id ESTABLE de por vida del pane: el
|
||||||
|
`fleet_list` del MCP lo expone como el único identificador y **omite a propósito el `tmux_window`
|
||||||
|
("@N")**, que migra cuando el focus-swap mueve el pane entre windows y por eso nunca debe usarse ni
|
||||||
|
mostrarse como id (la persona no tiene referencia mental de "@4"). Las operaciones internas que sí
|
||||||
|
necesitan la window/pane viva — `focus`, `send-keys`/nudge y `kill` — la resuelven BAJO DEMANDA contra
|
||||||
|
tmux a partir del session_id/PID (`kill_fleet_agent` y `fleetview focus` la recalculan por llamada).
|
||||||
|
Para el **nudge** NO leas ni caches el `@N`: usa `fleet_send_text` (grupo `orchestration`), que resuelve
|
||||||
|
el `pane_id` (`%N`) ESTABLE fresco a partir del `sessionId`/PID en el momento del envío — el `@N` migra
|
||||||
|
con el focus-swap y mandaría el texto al agente equivocado (ver sección Nudge).
|
||||||
|
|
||||||
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
|
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
|
||||||
|
|
||||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
||||||
@@ -253,18 +263,24 @@ verificas → `kill_fleet_agent` libera el slot. No uses `pkill`/`killall` ni `k
|
|||||||
### Nudge — `ESTANCADO`
|
### Nudge — `ESTANCADO`
|
||||||
|
|
||||||
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD
|
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD
|
||||||
inyectando en su pane tmux:
|
inyectando texto en su pane con la función `fleet_send_text` (grupo `orchestration`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tmux -L "${FLEET_SOCKET:-fleet}" send-keys -t <window_id> \
|
./fn run fleet_send_text <sessionId> \
|
||||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." Enter
|
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." \
|
||||||
|
--socket "$FLEET_SOCKET"
|
||||||
```
|
```
|
||||||
|
|
||||||
El `window_id` es el campo `tmux_window` (p.ej. `@20`) de `apps/fleetview/fleetview list --json`:
|
`fleet_send_text` resuelve el **`pane_id` (`%N`) ESTABLE FRESCO** del agente justo antes de enviar (a
|
||||||
|
partir del `sessionId` → PID → pane, leyendo `tmux list-panes -a` en el momento), y manda el texto
|
||||||
|
literal y el `Enter` en invocaciones **separadas**, verificando con `capture-pane` que el texto llegó
|
||||||
|
antes de hacer submit (reintenta si no). Acepta el target por `sessionId` (exacto o prefijo) o por PID.
|
||||||
|
|
||||||
```bash
|
**NO uses `tmux send-keys -t <window_id @N>` a mano para esto.** El `window_id` (`@N`, p.ej. `@20`) que
|
||||||
apps/fleetview/fleetview list --json | jq -r '.[] | select(.session_id|startswith("<sid>")) | .tmux_window'
|
expone `fleetview list --json` MIGRA cuando el focus-swap recrea windows (`break-pane`+`join-pane`):
|
||||||
```
|
`@32` → `@34`. Enviar al `@N` viejo (cacheado por el bloque `FLEET-STATE` o leído un instante antes)
|
||||||
|
manda el texto al window equivocado o a otro agente — esa era la causa de "el nudge a veces no llega al
|
||||||
|
agente correcto". `fleet_send_text` nunca usa `@N`; usa el `pane_id` (`%N`), que no migra.
|
||||||
|
|
||||||
**Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un
|
**Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un
|
||||||
empujón del bot.
|
empujón del bot.
|
||||||
@@ -322,6 +338,7 @@ en lote.
|
|||||||
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
|
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
|
||||||
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
|
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
|
||||||
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
|
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
|
||||||
|
| `fleet_send_text_bash_infra` | Empujar texto al input de UN agente (nudge) resolviendo su `pane_id` (`%N`) ESTABLE FRESCO justo antes de enviar — NO el `window_id` (`@N`), que migra con el focus-swap y manda el texto al agente equivocado. Texto literal + `Enter` en invocaciones separadas, verificado con `capture-pane` + reintento. Guard anti-self. Reemplaza el `tmux send-keys -t <@N>` manual del nudge |
|
||||||
| `notify_desktop_go_infra` | Notificación de escritorio del fleet (`notify-send --app-name=fleetview`, degradación silenciosa si no hay `notify-send`). La usa el orquestador/watcher para avisar a la persona de un `RECLAMA` u otro evento urgente cuando no está mirando la terminal |
|
| `notify_desktop_go_infra` | Notificación de escritorio del fleet (`notify-send --app-name=fleetview`, degradación silenciosa si no hay `notify-send`). La usa el orquestador/watcher para avisar a la persona de un `RECLAMA` u otro evento urgente cuando no está mirando la terminal |
|
||||||
|
|
||||||
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
|
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
},
|
},
|
||||||
"enabledMcpjsonServers": [
|
"enabledMcpjsonServers": [
|
||||||
"registry",
|
"registry",
|
||||||
"jupyter"
|
"jupyter",
|
||||||
|
"orchestrator"
|
||||||
],
|
],
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ python/.venv/
|
|||||||
|
|
||||||
# Externalized apps and analysis (each is its own Gitea repo)
|
# Externalized apps and analysis (each is its own Gitea repo)
|
||||||
apps/*/
|
apps/*/
|
||||||
|
cpp/apps/*/
|
||||||
analysis/*/
|
analysis/*/
|
||||||
|
|
||||||
# Projects (each is its own git repo, only project.md templates are versioned)
|
# Projects (each is its own git repo, only project.md templates are versioned)
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
"jupyter": {
|
"jupyter": {
|
||||||
"command": "bash",
|
"command": "bash",
|
||||||
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
|
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
|
||||||
|
},
|
||||||
|
"godot": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:8000/mcp"
|
||||||
|
},
|
||||||
|
"ardour": {
|
||||||
|
"command": "/home/enmanuel/audio-tools/ardour-mcp/target/release/ardour_mcp_server",
|
||||||
|
"args": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "build_wasm_cpp_app(app_name: string, [--no-budget-check]) -> void"
|
signature: "build_wasm_cpp_app(app_name: string, [--no-budget-check]) -> void"
|
||||||
description: "Compila una app C++ del registry (cpp/apps/<name>) a WASM via emscripten. Sale build/wasm/<name>/<name>.{html,js,wasm,wasm.gz}. Falla si gzip > 2 MB."
|
description: "Compila una app C++ del registry (cpp/apps/<name>) a WASM via emscripten. Sale build/wasm/<name>/<name>.{html,js,wasm,wasm.gz}. Falla si gzip > 2 MB."
|
||||||
tags: [wasm, emscripten, cpp, build, gamedev, pendiente-usar]
|
tags: [wasm, emscripten, cpp, build, gamedev-engine, pendiente-usar]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: fleet_send_text
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
signature: "fleet_send_text <sessionId|PID> \"<texto>\" [--socket <s>] [--no-enter] [--retries N] [--dry-run]"
|
||||||
|
description: "Empuja texto a UN agente de la flota tmux de forma fiable, resolviendo su pane_id (%N) ESTABLE FRESCO justo antes de enviar. Es el reemplazo del nudge antiguo del orquestador, que apuntaba al window_id (@N) leido del JSON de la flota: ese @N MIGRA cuando el focus-swap de FleetView (break-pane + join-pane) recrea windows, asi que enviar al @N viejo (cacheado por el bloque FLEET-STATE o leido un instante antes) mandaba el texto al window equivocado o a otro agente. fleet_send_text resuelve sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un ancestro suyo en /proc) es pane_pid, leyendo tmux list-panes -a en el momento del envio, y usa el pane_id (%N) que NO migra. Ademas manda el texto literal (send-keys -l) y el Enter en invocaciones SEPARADAS, verificando con capture-pane que el texto aparecio en el input antes de pulsar Enter; reintenta si no aparece. Guards: NO envia a tu propio pane; error claro si el target no resuelve a un pane vivo. Por defecto EJECUTA; --dry-run imprime el plan sin enviar."
|
||||||
|
tags: [fleet, claude-fleet, orchestration, tmux, nudge, send-keys, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
error_type: error_go_core
|
||||||
|
file_path: "bash/functions/infra/fleet_send_text.sh"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "golden: envio por PID resuelve el pane_id estable, inyecta el texto y se verifica via capture-pane"
|
||||||
|
- "edge: tras break-pane (focus-swap) el pane_id NO migra y el reenvio sigue llegando"
|
||||||
|
- "edge: resolucion por prefijo de sessionId (sessions/<pid>.json) entrega el texto"
|
||||||
|
- "edge: --dry-run no inyecta nada y reporta status=dry-run"
|
||||||
|
- "error: sessionId no resuelto rc=2; falta texto rc=2; PID sin pane vivo rc=4"
|
||||||
|
- "guard: enviar a la sesion actual (self) rc=3"
|
||||||
|
test_file_path: "bash/functions/infra/fleet_send_text_test.sh"
|
||||||
|
params:
|
||||||
|
- name: target
|
||||||
|
desc: "Primer arg posicional: sessionId del agente (exacto o prefijo) o su PID (todo digitos). Por sessionId se busca en sessions/*.json el que case y su archivo (<pid>.json) da el PID; por PID se usa directo."
|
||||||
|
- name: texto
|
||||||
|
desc: "Segundo arg posicional: el texto a inyectar en el input del agente (entre comillas)."
|
||||||
|
- name: --socket
|
||||||
|
desc: "Socket tmux del perfil FleetView donde vive el pane. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
|
||||||
|
- name: --no-enter
|
||||||
|
desc: "Deja el texto en el input sin pulsar Enter (no hace submit). Por defecto envia el Enter en una invocacion separada tras el texto."
|
||||||
|
- name: --retries
|
||||||
|
desc: "Numero de reintentos si el texto no aparece en el pane tras el send (default 2). Cada reintento limpia el input con C-u antes de reenviar."
|
||||||
|
- name: --dry-run
|
||||||
|
desc: "Imprime el plan (PID, sessionId, pane, socket) y NO envia nada. Sin esto, ejecuta."
|
||||||
|
output: "Imprime una linea de plan (target, PID, sessionId, socket, pane resuelto, modo de envio) y una linea final parseable 'pane=%N intento=N status=ok|dry-run'. Exit 0 ok/dry-run; 2 uso incorrecto o target no resuelto a PID; 3 guard (target es la sesion actual); 4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los reintentos."
|
||||||
|
---
|
||||||
|
|
||||||
|
# fleet_send_text
|
||||||
|
|
||||||
|
Empuja texto al input de **un** agente de la flota tmux de forma fiable. Resuelve el `pane_id` (`%N`) **estable** del agente **fresco** justo antes de enviar (nunca cachea el `window_id` `@N`, que migra con el focus-swap), manda el texto literal y el `Enter` en invocaciones **separadas**, y verifica con `capture-pane` que el texto llegó antes de hacer submit. Es el reemplazo del patrón de nudge antiguo (`tmux send-keys -t <window_id @N>`), que fallaba "a veces" porque enviaba al window equivocado tras un focus-swap.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nudge a un ejecutor estancado por sessionId (el orquestador lo llama tras detectar ESTANCADO):
|
||||||
|
./fn run fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 \
|
||||||
|
"Sigues idle con tu DoD-contrato sin cerrar. Falta: el error path con evidencia. Cierralo o reporta el bloqueo." \
|
||||||
|
--socket "$FLEET_SOCKET"
|
||||||
|
|
||||||
|
# Por prefijo de sessionId, en el socket por defecto ($FLEET_SOCKET o "fleet"):
|
||||||
|
./fn run fleet_send_text 32945650 "Recuerda pushear la rama antes de cerrar."
|
||||||
|
|
||||||
|
# Dejar texto en el input sin hacer submit (--no-enter), o solo ver el plan (--dry-run):
|
||||||
|
./fn run fleet_send_text 48213 "borrador..." --no-enter
|
||||||
|
./fn run fleet_send_text 48213 "texto" --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala desde el modo orquestador siempre que necesites **inyectar texto en el input de un agente** de la flota: el **nudge** a un `ESTANCADO`, el aviso de un gap concreto a un ejecutor cuyo cierre falló la verificación, o cualquier mensaje dirigido. Sustituye al `tmux send-keys -t <window_id>` manual. Resuelve el target por sessionId (exacto o prefijo) o por PID. **Solo a idle/ESTANCADO; jamás a un agente en `waiting`/`preguntando`** (esos te reclaman a ti, no un empujón del bot). Para *cerrar* un ejecutor verificado `met` no es esto: usa `kill_fleet_agent`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **El bug que arregla — el `window_id` (`@N`) MIGRA**: el focus-swap de FleetView (`tmux_swap_window_into_console.go`) trae el claude objetivo a la console con `break-pane` + `join-pane`, lo que **recrea windows** y cambia el `@N` del agente (`@32` → `@34`). El bloque `FLEET-STATE` y el JSON de la flota pueden traer un `@N` ya viejo. Enviar a ese `@N` manda el texto al window equivocado o a otro agente. Esta función NUNCA usa `@N`: resuelve el `pane_id` (`%N`), que se **preserva** durante toda la vida del pane aunque el pane se mueva de window. Verificado en test: tras `break-pane` el `window_id` pasa de `@0` a `@1` pero el `pane_id` sigue `%0` y el envío sigue llegando.
|
||||||
|
- **Resolución fresca**: el mapa `pane_pid → pane_id` se lee con `tmux -L <socket> list-panes -a` **en el momento del envío**, no se cachea. La resolución sube por los ancestros de `/proc` desde el PID del agente hasta casar un `pane_pid`: cubre tanto `exec claude` (pane_pid == claude pid, match directo, como hace `spawn_fleet_agent`) como un claude lanzado bajo un shell (pane_pid == shell ancestro).
|
||||||
|
- **Texto y Enter separados**: el texto va con `send-keys -l` (literal, sin interpretar nombres de tecla), luego `sleep 0.3`, y el `Enter` en una **invocación aparte**. Mandar texto+Enter juntos hace que el TUI de Claude Code a veces no interprete el Enter como submit. La verificación con `capture-pane` se hace **antes** del Enter (tras el submit el TUI vacía el input y no se podría comprobar). Si el texto no aparece, limpia el input con `C-u` y reintenta (`--retries`, default 2).
|
||||||
|
- **Impura**: inyecta teclas en un pane ajeno. Por defecto EJECUTA; usa `--dry-run` para inspeccionar el plan antes.
|
||||||
|
- **Guard anti-self**: resuelve el PID de `claude` de la sesión actual subiendo por los ancestros de `/proc`; si el target coincide, rehúsa con exit 3 ("No me autoenvio").
|
||||||
|
- **Verificación por fragmento ancla**: comprueba que aparezcan los primeros 24 caracteres del texto (no el texto completo) para no dar falso negativo cuando el input del TUI wrapea un mensaje largo en varias líneas.
|
||||||
|
- **Socket**: si no pasas `--socket`, usa `$FLEET_SOCKET` o `"fleet"`. Si el agente no está en ese socket, no se encontrará el pane (exit 4).
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fleet_send_text — empuja texto a UN agente de la flota tmux de forma fiable.
|
||||||
|
#
|
||||||
|
# El problema que resuelve: el orquestador "nudgea" a los ejecutores con
|
||||||
|
# `tmux send-keys`. El patron antiguo apuntaba al `window_id` (`@N`) leido del
|
||||||
|
# JSON de la flota. Pero el focus-swap de FleetView (`break-pane` + `join-pane`)
|
||||||
|
# RECREA windows, asi que el `@N` de un agente MIGRA (p.ej. `@32` -> `@34`) cada
|
||||||
|
# vez que se entra/sale de su window. Enviar al `@N` viejo (cacheado por el bloque
|
||||||
|
# FLEET-STATE o leido un instante antes) manda el texto al window equivocado o a
|
||||||
|
# otro agente -> "a veces no llega al agente correcto". Ademas, mandar el texto y
|
||||||
|
# el `Enter` en la MISMA invocacion hace que el TUI de Claude Code a veces no
|
||||||
|
# interprete el Enter como submit.
|
||||||
|
#
|
||||||
|
# Esta funcion arregla las dos cosas:
|
||||||
|
# 1. Resuelve el `pane_id` ESTABLE (`%N`) FRESCO justo antes de enviar. El
|
||||||
|
# `pane_id` se preserva durante toda la vida del pane aunque el pane se mueva
|
||||||
|
# de window con break/join — NO migra como el `window_id`. La resolucion va
|
||||||
|
# sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un
|
||||||
|
# ancestro suyo en /proc) es `pane_pid`, leyendo `tmux list-panes -a` en el
|
||||||
|
# momento del envio.
|
||||||
|
# 2. Manda el texto literal (`send-keys -l`), espera un poco, y el `Enter` en
|
||||||
|
# una invocacion SEPARADA. Verifica con `capture-pane` que el texto aparecio
|
||||||
|
# en el pane antes de pulsar Enter; si no, reintenta.
|
||||||
|
#
|
||||||
|
# Guards: NO envia a tu propio pane (la sesion que invoca la funcion). Error claro
|
||||||
|
# si el sessionId/PID no resuelve a un pane vivo.
|
||||||
|
#
|
||||||
|
# Funcion IMPURA: inyecta teclas en un pane tmux ajeno. Por defecto EJECUTA (es el
|
||||||
|
# caso de uso del bot: nudgear a un ejecutor). Usa --dry-run para ver el plan sin
|
||||||
|
# enviar nada.
|
||||||
|
#
|
||||||
|
# Overrides de entorno (testabilidad, no para uso normal):
|
||||||
|
# FN_FLEET_SESSIONS_DIR directorio de los sessions JSON. Default ~/.claude/sessions
|
||||||
|
# FN_FLEET_SELF_PID fuerza el PID propio (salta la deteccion por /proc)
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$' \t\n'
|
||||||
|
|
||||||
|
# Resuelve el pane_id (%N) ESTABLE de un PID dado, leyendo el mapa fresco de panes
|
||||||
|
# del socket. Sube por la cadena de ancestros del PID en /proc hasta encontrar un
|
||||||
|
# `pane_pid` del mapa: cubre tanto el caso `exec claude` (pane_pid == claude pid,
|
||||||
|
# match directo) como el de un claude lanzado bajo un shell (pane_pid == shell
|
||||||
|
# ancestro). Imprime el pane_id y devuelve 0 si lo encuentra; 1 si no.
|
||||||
|
# $1 = PID objetivo
|
||||||
|
# $2 = texto del mapa "pane_pid pane_id" (una linea por pane)
|
||||||
|
_fleet_resolve_pane_for_pid() {
|
||||||
|
local p="${1:-}" panes_map="${2:-}" guard=0 pane_id
|
||||||
|
while [[ -n "$p" && "$p" != "0" && "$p" != "1" ]]; do
|
||||||
|
pane_id="$(awk -v pp="$p" '$1==pp {print $2; exit}' <<<"$panes_map")"
|
||||||
|
if [[ -n "$pane_id" ]]; then
|
||||||
|
printf '%s\n' "$pane_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
p="$(awk '{print $4}' "/proc/$p/stat" 2>/dev/null || true)"
|
||||||
|
guard=$((guard + 1))
|
||||||
|
[[ "$guard" -gt 64 ]] && break
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fleet_send_text() {
|
||||||
|
local target="" txt="" socket="" do_enter=1 dry=0 retries=2
|
||||||
|
local got_target=0 got_text=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--socket) shift; socket="${1:-}" ;;
|
||||||
|
--no-enter) do_enter=0 ;;
|
||||||
|
--retries) shift; retries="${1:-2}" ;;
|
||||||
|
--dry-run) dry=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<'USAGE'
|
||||||
|
Uso: fleet_send_text <sessionId|PID> "<texto>" [--socket <s>] [--no-enter] [--retries N] [--dry-run]
|
||||||
|
|
||||||
|
Empuja <texto> a UN agente de la flota tmux resolviendo su pane_id (%N) ESTABLE
|
||||||
|
FRESCO justo antes de enviar (no cachea el window_id @N, que migra con el
|
||||||
|
focus-swap). Manda el texto literal y el Enter en invocaciones separadas, y
|
||||||
|
verifica con capture-pane que el texto aparecio antes de pulsar Enter;
|
||||||
|
reintenta si no.
|
||||||
|
|
||||||
|
Argumentos:
|
||||||
|
<sessionId|PID> Primer posicional: sessionId del agente (exacto o prefijo) o
|
||||||
|
su PID (todo digitos). Por sessionId se busca en
|
||||||
|
sessions/*.json el que case; su archivo (<pid>.json) da el PID.
|
||||||
|
"<texto>" Segundo posicional: el texto a inyectar en el input del agente.
|
||||||
|
|
||||||
|
Opciones:
|
||||||
|
--socket <s> Socket tmux del perfil FleetView. Default: $FLEET_SOCKET, o "fleet".
|
||||||
|
--no-enter Deja el texto en el input sin pulsar Enter (no hace submit).
|
||||||
|
--retries N Reintentos si el texto no aparece tras el send (default 2).
|
||||||
|
--dry-run Imprime el plan (PID, sessionId, pane, socket) y NO envia nada.
|
||||||
|
-h, --help Esta ayuda.
|
||||||
|
|
||||||
|
Salida: linea de resultado con `pane=%N` usado e `intento=N`. Exit 0 ok/dry-run;
|
||||||
|
2 uso incorrecto o target no resuelto; 3 guard (target es la sesion actual);
|
||||||
|
4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los
|
||||||
|
reintentos.
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 "Cierra tu DoD o reporta el bloqueo." --socket "$FLEET_SOCKET"
|
||||||
|
fleet_send_text 32945650 "Falta el error path con evidencia." # por prefijo de sessionId
|
||||||
|
fleet_send_text 48213 "texto" --no-enter --dry-run # por PID, solo ver el plan
|
||||||
|
USAGE
|
||||||
|
return 0 ;;
|
||||||
|
--*)
|
||||||
|
echo "fleet_send_text: opcion desconocida '$1' (usa -h)" >&2
|
||||||
|
return 2 ;;
|
||||||
|
*)
|
||||||
|
if [[ "$got_target" -eq 0 ]]; then
|
||||||
|
target="$1"; got_target=1
|
||||||
|
elif [[ "$got_text" -eq 0 ]]; then
|
||||||
|
txt="$1"; got_text=1
|
||||||
|
else
|
||||||
|
echo "fleet_send_text: argumento extra '$1' (target y texto ya fijados)" >&2
|
||||||
|
return 2
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$got_target" -eq 0 ]] && {
|
||||||
|
echo "fleet_send_text: falta el target (sessionId o PID). Usa -h." >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
[[ "$got_text" -eq 0 ]] && {
|
||||||
|
echo "fleet_send_text: falta el texto a enviar. Usa -h." >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
[[ "$retries" =~ ^[0-9]+$ ]] || {
|
||||||
|
echo "fleet_send_text: --retries debe ser un entero (recibido '$retries')" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
local sessions_dir="${FN_FLEET_SESSIONS_DIR:-$HOME/.claude/sessions}"
|
||||||
|
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
|
||||||
|
|
||||||
|
command -v tmux >/dev/null 2>&1 || {
|
||||||
|
echo "fleet_send_text: tmux no esta instalado" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Resolver (PID, sessionId) a partir del target. Mismo patron que
|
||||||
|
# kill_fleet_agent: por PID directo, o por sessionId (exacto/prefijo)
|
||||||
|
# buscando en sessions/*.json.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local pid="" sid=""
|
||||||
|
if [[ "$target" =~ ^[0-9]+$ ]]; then
|
||||||
|
pid="$target"
|
||||||
|
local sfile="$sessions_dir/$pid.json"
|
||||||
|
if [[ -f "$sfile" ]] && command -v jq >/dev/null 2>&1; then
|
||||||
|
sid="$(jq -r '.sessionId // ""' "$sfile" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
command -v jq >/dev/null 2>&1 || {
|
||||||
|
echo "fleet_send_text: jq no esta instalado (necesario para resolver el sessionId)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
local f base candidate_sid
|
||||||
|
for f in "$sessions_dir"/*.json; do
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
candidate_sid="$(jq -r '.sessionId // ""' "$f" 2>/dev/null || true)"
|
||||||
|
[[ -z "$candidate_sid" ]] && continue
|
||||||
|
if [[ "$candidate_sid" == "$target" || "$candidate_sid" == "$target"* ]]; then
|
||||||
|
base="$(basename "$f" .json)"
|
||||||
|
pid="$base"
|
||||||
|
sid="$candidate_sid"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -z "$pid" ]] && {
|
||||||
|
echo "fleet_send_text: no se pudo resolver el target '$target' a un PID (sessions en $sessions_dir)" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Guard — anti-self: no enviar a la sesion que invoca la funcion.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local self_pid="${FN_FLEET_SELF_PID:-}"
|
||||||
|
if [[ -z "$self_pid" ]]; then
|
||||||
|
local walk="$$" guard=0 comm
|
||||||
|
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||||
|
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||||
|
if [[ "$comm" == "claude" ]]; then
|
||||||
|
self_pid="$walk"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||||
|
guard=$((guard + 1))
|
||||||
|
[[ "$guard" -gt 64 ]] && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ -n "$self_pid" && "$pid" == "$self_pid" ]]; then
|
||||||
|
echo "fleet_send_text: REHUSADO — el target (PID $pid) es la sesion actual. No me autoenvio." >&2
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Resolver el pane_id (%N) ESTABLE FRESCO. Mapa pane_pid->pane_id del socket
|
||||||
|
# leido AHORA; subir por ancestros del PID hasta casar un pane_pid.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local panes_map pane=""
|
||||||
|
panes_map="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{pane_id}' 2>/dev/null || true)"
|
||||||
|
if [[ -n "$panes_map" ]]; then
|
||||||
|
pane="$(_fleet_resolve_pane_for_pid "$pid" "$panes_map" || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -z "$pane" ]] && {
|
||||||
|
echo "fleet_send_text: no se encontro un pane vivo para el target '$target' (PID $pid) en el socket '$socket'." >&2
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Plan.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local enter_desc; [[ "$do_enter" -eq 1 ]] && enter_desc="texto + Enter separado" || enter_desc="solo texto (--no-enter)"
|
||||||
|
echo "fleet_send_text — target: $target PID: $pid sessionId: ${sid:-?} socket: $socket pane: $pane envio: $enter_desc retries: $retries"
|
||||||
|
|
||||||
|
if [[ "$dry" -eq 1 ]]; then
|
||||||
|
echo "DRY-RUN: no se ha enviado nada."
|
||||||
|
echo "pane=$pane intento=0 status=dry-run"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Enviar + verificar. El texto se manda literal (-l); el Enter va en una
|
||||||
|
# invocacion separada tras un sleep. Verificamos ANTES del Enter (el texto
|
||||||
|
# esta en el input; tras Enter el TUI vacia el input y no se podria verificar).
|
||||||
|
# Si el texto no aparece, limpiamos el input (C-u) y reintentamos.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local anchor="${txt:0:24}" # fragmento ancla (evita falsos negativos por wrapping)
|
||||||
|
local i cap ok=0 used_try=0
|
||||||
|
for (( i=1; i<=retries+1; i++ )); do
|
||||||
|
tmux -L "$socket" send-keys -t "$pane" -l -- "$txt" 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
cap="$(tmux -L "$socket" capture-pane -p -t "$pane" 2>/dev/null || true)"
|
||||||
|
if grep -qF -- "$anchor" <<<"$cap"; then
|
||||||
|
ok=1; used_try="$i"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
# No aparecio: limpiar el input antes de reintentar.
|
||||||
|
tmux -L "$socket" send-keys -t "$pane" C-u 2>/dev/null || true
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$ok" -ne 1 ]]; then
|
||||||
|
echo "fleet_send_text: texto enviado pero NO verificado en el pane $pane tras $((retries+1)) intentos." >&2
|
||||||
|
echo "pane=$pane intento=$((retries+1)) status=unverified" >&2
|
||||||
|
return 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Texto presente en el input. Ahora el Enter (separado) para hacer submit.
|
||||||
|
if [[ "$do_enter" -eq 1 ]]; then
|
||||||
|
tmux -L "$socket" send-keys -t "$pane" Enter 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "fleet_send_text: OK — texto inyectado en el pane $pane (intento $used_try)$([[ "$do_enter" -eq 1 ]] && echo " + Enter")."
|
||||||
|
echo "pane=$pane intento=$used_try status=ok"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
fleet_send_text "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests para fleet_send_text. Levanta un socket tmux PROPIO de test
|
||||||
|
# (fleet_test_<pid>, nunca el socket "fleet" real) con un pane `cat` vivo, y
|
||||||
|
# verifica: envio + verificacion via capture-pane (golden), supervivencia al
|
||||||
|
# focus-swap (break-pane preserva el pane_id), resolucion por sessionId fake,
|
||||||
|
# y los paths de error/guard. No toca la flota real ni ningun agente.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/fleet_send_text.sh"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local test_name="$1" needle="$2" haystack="$3"
|
||||||
|
if echo "$haystack" | grep -qF "$needle"; then
|
||||||
|
echo "PASS: $test_name"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected to contain '$needle'"
|
||||||
|
echo " got: $haystack"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_not_contains() {
|
||||||
|
local test_name="$1" needle="$2" haystack="$3"
|
||||||
|
if echo "$haystack" | grep -qF "$needle"; then
|
||||||
|
echo "FAIL: $test_name — should NOT contain '$needle'"
|
||||||
|
echo " got: $haystack"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
else
|
||||||
|
echo "PASS: $test_name"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_rc() {
|
||||||
|
local test_name="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$actual" == "$expected" ]]; then
|
||||||
|
echo "PASS: $test_name (rc=$actual)"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected rc=$expected, got rc=$actual"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v tmux >/dev/null 2>&1 || { echo "SKIP: tmux no instalado"; exit 0; }
|
||||||
|
|
||||||
|
# --- Socket de test PROPIO + pane `cat` vivo (con echo de tty) ---
|
||||||
|
SOCK="fleet_test_$$"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
SESS="$TMP/sessions"
|
||||||
|
mkdir -p "$SESS"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
tmux -L "$SOCK" kill-server 2>/dev/null || true
|
||||||
|
rm -rf "$TMP"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
tmux -L "$SOCK" new-session -d -s t -x 120 -y 30 'cat'
|
||||||
|
sleep 0.4
|
||||||
|
|
||||||
|
PANE_PID="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid}' | head -n1)"
|
||||||
|
PANE_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||||
|
WIN_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||||
|
echo "INFO: socket=$SOCK pane_pid=$PANE_PID pane_id=$PANE_ID0 window_id=$WIN_ID0"
|
||||||
|
|
||||||
|
# self_pid forzado a un PID que nunca sera target en los tests golden.
|
||||||
|
export FN_FLEET_SELF_PID=1
|
||||||
|
export FN_FLEET_SESSIONS_DIR="$SESS"
|
||||||
|
|
||||||
|
# --- Test 1 (golden): enviar por PID, verificar via capture-pane ---
|
||||||
|
set +e
|
||||||
|
out=$(fleet_send_text "$PANE_PID" "HOLA_FLEET_123" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "golden: envio por PID sale 0" 0 "$rc"
|
||||||
|
assert_contains "golden: reporta status=ok" "status=ok" "$out"
|
||||||
|
assert_contains "golden: reporta el pane_id estable" "pane=$PANE_ID0" "$out"
|
||||||
|
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID0")"
|
||||||
|
assert_contains "golden: el texto llego al pane (capture-pane)" "HOLA_FLEET_123" "$cap"
|
||||||
|
|
||||||
|
# limpiar input del cat
|
||||||
|
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-u; sleep 0.2
|
||||||
|
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-l 2>/dev/null || true; sleep 0.2
|
||||||
|
|
||||||
|
# --- Test 2 (edge focus-swap): mover el pane a otra window, pane_id NO migra ---
|
||||||
|
# Anadimos un segundo pane para poder break-pane el nuestro a una window nueva.
|
||||||
|
tmux -L "$SOCK" split-window -t "$WIN_ID0" -d 'cat'; sleep 0.3
|
||||||
|
tmux -L "$SOCK" break-pane -d -s "$PANE_ID0"; sleep 0.3
|
||||||
|
WIN_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||||
|
PANE_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||||
|
echo "INFO: tras break-pane: pane_id=$PANE_ID1 (era $PANE_ID0) window_id=$WIN_ID1 (era $WIN_ID0)"
|
||||||
|
assert_contains "edge: pane_id NO cambia tras mover de window" "$PANE_ID0" "$PANE_ID1"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
out=$(fleet_send_text "$PANE_PID" "TRAS_MOVER_456" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "edge: reenvio tras focus-swap sale 0" 0 "$rc"
|
||||||
|
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||||
|
assert_contains "edge: el texto sigue llegando tras mover de window" "TRAS_MOVER_456" "$cap"
|
||||||
|
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
|
||||||
|
|
||||||
|
# --- Test 3 (edge): resolver por sessionId (sessions/<pid>.json fake) ---
|
||||||
|
echo "{\"sessionId\":\"test-sid-aaa-111\",\"cwd\":\"/tmp/x\"}" > "$SESS/$PANE_PID.json"
|
||||||
|
set +e
|
||||||
|
out=$(fleet_send_text "test-sid-aaa" "VIA_SID_789" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "edge: resolucion por prefijo de sessionId sale 0" 0 "$rc"
|
||||||
|
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||||
|
assert_contains "edge: texto llego resolviendo por sessionId" "VIA_SID_789" "$cap"
|
||||||
|
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
|
||||||
|
|
||||||
|
# --- Test 4 (edge): --dry-run no envia nada ---
|
||||||
|
set +e
|
||||||
|
out=$(fleet_send_text "$PANE_PID" "NO_DEBE_APARECER_000" --socket "$SOCK" --no-enter --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "edge: dry-run sale 0" 0 "$rc"
|
||||||
|
assert_contains "edge: dry-run reporta status=dry-run" "status=dry-run" "$out"
|
||||||
|
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||||
|
assert_not_contains "edge: dry-run NO inyecto texto" "NO_DEBE_APARECER_000" "$cap"
|
||||||
|
|
||||||
|
# --- Test 5 (error): sessionId que no resuelve a PID -> rc 2 ---
|
||||||
|
set +e
|
||||||
|
out=$(fleet_send_text "sid-inexistente-zzz" "x" --socket "$SOCK" 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "error: sessionId no resuelto sale 2" 2 "$rc"
|
||||||
|
assert_contains "error: mensaje de target no resuelto" "no se pudo resolver" "$out"
|
||||||
|
|
||||||
|
# --- Test 6 (error): falta el texto -> rc 2 ---
|
||||||
|
set +e
|
||||||
|
out=$(fleet_send_text "$PANE_PID" --socket "$SOCK" 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "error: falta texto sale 2" 2 "$rc"
|
||||||
|
|
||||||
|
# --- Test 7 (guard anti-self): target == self_pid -> rc 3 ---
|
||||||
|
set +e
|
||||||
|
out=$(FN_FLEET_SELF_PID="$PANE_PID" fleet_send_text "$PANE_PID" "x" --socket "$SOCK" 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "guard: enviar a la sesion actual sale 3" 3 "$rc"
|
||||||
|
assert_contains "guard: mensaje anti-self" "No me autoenvio" "$out"
|
||||||
|
|
||||||
|
# --- Test 8 (error): PID sin pane vivo -> rc 4 ---
|
||||||
|
set +e
|
||||||
|
out=$(fleet_send_text 999999 "x" --socket "$SOCK" 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "error: PID sin pane vivo sale 4" 4 "$rc"
|
||||||
|
assert_contains "error: mensaje no pane vivo" "no se encontro un pane vivo" "$out"
|
||||||
|
|
||||||
|
# --- Resumen ---
|
||||||
|
echo ""
|
||||||
|
echo "================================"
|
||||||
|
echo "PASS: $PASS FAIL: $FAIL"
|
||||||
|
echo "================================"
|
||||||
|
[[ "$FAIL" -eq 0 ]]
|
||||||
@@ -3,10 +3,10 @@ name: kill_fleet_agent
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "kill_fleet_agent <sessionId|PID> [--socket <s>] [--dry-run]"
|
signature: "kill_fleet_agent <sessionId|PID> [--socket <s>] [--dry-run]"
|
||||||
description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux (kill-window) en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json) ni a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc). Por defecto EJECUTA; --dry-run imprime el plan sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota."
|
description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Tres guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json); NUNCA a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc); y NUNCA cierra la window que aloja la TUI fleetview o la window 'console' con kill-window (eso se llevaria el panel de control por delante) — en ese caso cierra SOLO el pane del target con kill-pane y preserva la TUI. Por defecto EJECUTA; --dry-run imprime el plan (incluida la accion kill-pane vs kill-window) sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota."
|
||||||
tags: [fleet, claude-fleet, orchestration, tmux, kill, infra]
|
tags: [fleet, claude-fleet, orchestration, tmux, kill, infra]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -17,6 +17,7 @@ tests:
|
|||||||
- "golden: ejecutor por sessionId, PID y prefijo se resuelve y dry-run imprime el plan"
|
- "golden: ejecutor por sessionId, PID y prefijo se resuelve y dry-run imprime el plan"
|
||||||
- "guard: matar un role=orchestrator devuelve rc=3 y se niega"
|
- "guard: matar un role=orchestrator devuelve rc=3 y se niega"
|
||||||
- "guard: matar la sesion actual (self) devuelve rc=3 y se niega"
|
- "guard: matar la sesion actual (self) devuelve rc=3 y se niega"
|
||||||
|
- "guard3: predicado _fleet_window_hosts_tui detecta window 'console' o pane fleetview"
|
||||||
- "error: target no resuelto rc=2; sin target rc=2"
|
- "error: target no resuelto rc=2; sin target rc=2"
|
||||||
test_file_path: "bash/functions/infra/kill_fleet_agent_test.sh"
|
test_file_path: "bash/functions/infra/kill_fleet_agent_test.sh"
|
||||||
params:
|
params:
|
||||||
@@ -55,11 +56,13 @@ Cierra de forma dirigida UN ejecutor de la flota tmux: SIGTERM al proceso `claud
|
|||||||
- **Impura y destructiva**: manda SIGTERM y cierra una window tmux. Por defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado `met`); usa `--dry-run` para inspeccionar antes.
|
- **Impura y destructiva**: manda SIGTERM y cierra una window tmux. Por defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado `met`); usa `--dry-run` para inspeccionar antes.
|
||||||
- **Guard anti-orquestador**: si el goal.json del target tiene `role=orchestrator`, rehúsa con exit 3. Evita decapitar la flota por error. El `role` se lee de `~/.claude/goals/<sessionId>.json` (lo escribe `mark_claude_role`).
|
- **Guard anti-orquestador**: si el goal.json del target tiene `role=orchestrator`, rehúsa con exit 3. Evita decapitar la flota por error. El `role` se lee de `~/.claude/goals/<sessionId>.json` (lo escribe `mark_claude_role`).
|
||||||
- **Guard anti-self**: resuelve el PID de `claude` de la sesión actual subiendo por los ancestros de `/proc`; si el target coincide, rehúsa con exit 3 ("No me suicido"). Es el equivalente dirigido de la regla "nunca `pkill claude`".
|
- **Guard anti-self**: resuelve el PID de `claude` de la sesión actual subiendo por los ancestros de `/proc`; si el target coincide, rehúsa con exit 3 ("No me suicido"). Es el equivalente dirigido de la regla "nunca `pkill claude`".
|
||||||
- **Resolución de la window**: usa `tmux -L <socket> list-panes -a` y casa `pane_pid == PID`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla).
|
- **Guard 3 — anti-TUI/console (no decapitar el panel)**: antes de cerrar nada, comprueba si la window del target **aloja la TUI fleetview** (algún pane corre el binario `fleetview`) o se llama **`console`**. El layout FleetView mete la TUI y un Claude en la misma window `console`, y los focus-swaps (`join-pane`) pueden meter al ejecutor target en esa window; un `kill-window` ahí se llevaría la TUI por delante (causa del fallo descrito en `fleetview` v0.4.3). En ese caso la función NO usa `kill-window`: manda el SIGTERM al claude y cierra **solo su pane** con `kill-pane`, preservando el pane de la TUI. El plan (y el `--dry-run`) lo refleja como `accion: kill-pane … (aloja la TUI/console)` vs `accion: kill-window …`. El predicado es la función interna `_fleet_window_hosts_tui` (testeada). Se mantiene inline (no función propia del registry) por estar acoplada a este flujo y para no dejar una capacidad huérfana (KISS).
|
||||||
|
- **Resolución de la window y el pane**: usa `tmux -L <socket> list-panes -a` y casa `pane_pid == PID`, capturando `window_id`, `pane_id` y `window_name`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla).
|
||||||
- **SIGTERM, no SIGKILL**: cierre limpio para que Claude Code persista su sesión; el trabajo se puede retomar con `claude --resume <sessionId>`.
|
- **SIGTERM, no SIGKILL**: cierre limpio para que Claude Code persista su sesión; el trabajo se puede retomar con `claude --resume <sessionId>`.
|
||||||
- **Requiere `jq`** para leer los JSON de sessions/goals.
|
- **Requiere `jq`** para leer los JSON de sessions/goals.
|
||||||
- **Overrides de entorno solo para tests**: `FN_FLEET_SESSIONS_DIR`, `FN_FLEET_GOALS_DIR` y `FN_FLEET_SELF_PID` redirigen los directorios y fuerzan el PID propio; no usarlos en operación normal.
|
- **Overrides de entorno solo para tests**: `FN_FLEET_SESSIONS_DIR`, `FN_FLEET_GOALS_DIR` y `FN_FLEET_SELF_PID` redirigen los directorios y fuerzan el PID propio; no usarlos en operación normal.
|
||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
(v1.0.0 — sin cambios todavía.)
|
- v1.1.0 (2026-06-24) — **Guard 3 anti-TUI/console** (elimina un gotcha conocido). Antes, si un focus-swap metía al ejecutor target en la window `console` (la que aloja la TUI fleetview), `kill-window` cerraba la TUI por error. Ahora, cuando la window del target aloja la TUI (pane `fleetview`) o se llama `console`, se cierra solo el pane del target con `kill-pane` y la TUI sobrevive; el resto de windows siguen cerrándose con `kill-window`. Predicado interno `_fleet_window_hosts_tui` con tests. Es la causa raíz que complementa el auto-respawn de la TUI (`supervise_fleetview_tui`).
|
||||||
|
- v1.0.0 — versión inicial.
|
||||||
|
|||||||
@@ -26,6 +26,25 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
IFS=$' \t\n'
|
IFS=$' \t\n'
|
||||||
|
|
||||||
|
# Predicado (puro respecto a tmux): dada una window — su nombre y el texto de sus
|
||||||
|
# panes en formato "<pane_pid> <pane_current_command>" (una linea por pane) —
|
||||||
|
# decide si esa window ALOJA la TUI fleetview o es la window 'console' del perfil.
|
||||||
|
# Si es asi, cerrar la window entera con kill-window se llevaria la TUI por
|
||||||
|
# delante; el caller debe cerrar solo el pane del target con kill-pane.
|
||||||
|
# - Nombre de window 'console' = la window del panel FleetView por convencion
|
||||||
|
# del launcher (y a donde el focus-swap ancla la TUI, ver fleetview v0.4.3).
|
||||||
|
# - Algun pane corre el binario 'fleetview' (pane_current_command) = la TUI
|
||||||
|
# vive ahi aunque la window se haya renombrado.
|
||||||
|
# Devuelve 0 si aloja la TUI/console, 1 si no.
|
||||||
|
_fleet_window_hosts_tui() {
|
||||||
|
local window_name="${1:-}" panes_text="${2:-}"
|
||||||
|
[[ "$window_name" == "console" ]] && return 0
|
||||||
|
if printf '%s\n' "$panes_text" | awk '{print $2}' | grep -qx 'fleetview'; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
kill_fleet_agent() {
|
kill_fleet_agent() {
|
||||||
local target="" socket="" dry=0
|
local target="" socket="" dry=0
|
||||||
|
|
||||||
@@ -155,27 +174,65 @@ USAGE
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Resolver la window tmux del PID en el socket (pane_pid == claude por el
|
# Resolver la window tmux Y el pane del PID en el socket (pane_pid == claude
|
||||||
# `exec claude` de spawn_fleet_agent). Best-effort: vacio si no hay socket.
|
# por el `exec claude` de spawn_fleet_agent). Capturamos window_id, pane_id y
|
||||||
|
# window_name juntos. Best-effort: vacio si no hay socket.
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
local window=""
|
local window="" pane="" wname=""
|
||||||
if command -v tmux >/dev/null 2>&1; then
|
if command -v tmux >/dev/null 2>&1; then
|
||||||
window="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id}' 2>/dev/null \
|
local line
|
||||||
| awk -v p="$pid" '$1==p {print $2; exit}' || true)"
|
line="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id} #{pane_id} #{window_name}' 2>/dev/null \
|
||||||
|
| awk -v p="$pid" '$1==p {print $2, $3, $4; exit}' || true)"
|
||||||
|
if [[ -n "$line" ]]; then
|
||||||
|
window="$(awk '{print $1}' <<<"$line")"
|
||||||
|
pane="$(awk '{print $2}' <<<"$line")"
|
||||||
|
wname="$(awk '{print $3}' <<<"$line")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Guard 3 — anti-TUI/console: si la window del target aloja la TUI fleetview
|
||||||
|
# o es la window 'console' del perfil, NO cerramos la window entera (eso se
|
||||||
|
# llevaria la TUI), sino solo el pane del target con kill-pane. El layout
|
||||||
|
# FleetView mete la TUI y un Claude en la misma window 'console', y los
|
||||||
|
# focus-swaps (join-pane) pueden meter al ejecutor target en esa window.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local hosts_tui=0
|
||||||
|
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
|
||||||
|
local panes_text
|
||||||
|
panes_text="$(tmux -L "$socket" list-panes -t "$window" -F '#{pane_pid} #{pane_current_command}' 2>/dev/null || true)"
|
||||||
|
if _fleet_window_hosts_tui "$wname" "$panes_text"; then
|
||||||
|
hosts_tui=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Accion sobre la window/pane segun lo resuelto y el Guard 3.
|
||||||
|
local action
|
||||||
|
if [[ -z "$window" ]]; then
|
||||||
|
action="solo SIGTERM (window no resuelta)"
|
||||||
|
elif [[ "$hosts_tui" -eq 1 ]]; then
|
||||||
|
if [[ -n "$pane" ]]; then
|
||||||
|
action="kill-pane $pane (window '${wname:-$window}' aloja la TUI/console; se preserva la TUI)"
|
||||||
|
else
|
||||||
|
action="solo SIGTERM (window '${wname:-$window}' aloja la TUI y no se resolvio el pane; window preservada)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
action="kill-window $window"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Plan (se imprime siempre).
|
# Plan (se imprime siempre).
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)}"
|
echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)} pane: ${pane:-?} accion: $action"
|
||||||
|
|
||||||
if [[ "$dry" -eq 1 ]]; then
|
if [[ "$dry" -eq 1 ]]; then
|
||||||
echo "DRY-RUN: no se ha matado el proceso ni cerrado la window."
|
echo "DRY-RUN: no se ha matado el proceso ni cerrado nada."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Ejecutar: SIGTERM al claude (cierre limpio) + kill-window (idempotente).
|
# Ejecutar: SIGTERM al claude (cierre limpio) + cierre de pane/window segun
|
||||||
|
# el Guard 3 (idempotente).
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
if kill -0 "$pid" 2>/dev/null; then
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
@@ -185,9 +242,18 @@ USAGE
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
|
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
|
||||||
|
if [[ "$hosts_tui" -eq 1 ]]; then
|
||||||
|
if [[ -n "$pane" ]]; then
|
||||||
|
tmux -L "$socket" kill-pane -t "$pane" 2>/dev/null || true
|
||||||
|
echo "kill_fleet_agent: pane $pane cerrado (window '${wname:-$window}' aloja la TUI; window preservada)."
|
||||||
|
else
|
||||||
|
echo "kill_fleet_agent: window '${wname:-$window}' aloja la TUI pero no se resolvio el pane; solo SIGTERM (window preservada)."
|
||||||
|
fi
|
||||||
|
else
|
||||||
tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true
|
tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true
|
||||||
echo "kill_fleet_agent: window $window cerrada en el socket $socket."
|
echo "kill_fleet_agent: window $window cerrada en el socket $socket."
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,24 @@ set -e
|
|||||||
assert_rc "error: sin target devuelve rc=2" 2 "$rc"
|
assert_rc "error: sin target devuelve rc=2" 2 "$rc"
|
||||||
assert_contains "error: mensaje falta target" "falta el target" "$out"
|
assert_contains "error: mensaje falta target" "falta el target" "$out"
|
||||||
|
|
||||||
|
# --- Test 7 (Guard 3 predicado): _fleet_window_hosts_tui ---
|
||||||
|
# La window 'console' SIEMPRE se considera que aloja la TUI (no se cierra entera).
|
||||||
|
assert_predicate() {
|
||||||
|
local test_name="$1" expected="$2"; shift 2
|
||||||
|
set +e
|
||||||
|
_fleet_window_hosts_tui "$@"; local rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "$test_name" "$expected" "$rc"
|
||||||
|
}
|
||||||
|
# Nombre de window 'console' -> aloja TUI (rc 0), aunque ningun pane sea fleetview.
|
||||||
|
assert_predicate "guard3: window 'console' aloja la TUI" 0 "console" $'1234 claude\n5678 bash'
|
||||||
|
# Algun pane corre 'fleetview' -> aloja TUI (rc 0), aunque la window no sea console.
|
||||||
|
assert_predicate "guard3: pane fleetview aloja la TUI" 0 "claude" $'1111 bash\n2222 fleetview'
|
||||||
|
# Ni console ni fleetview -> NO aloja la TUI (rc 1): kill-window normal.
|
||||||
|
assert_predicate "guard3: window normal no aloja la TUI" 1 "claude" $'3333 claude\n4444 bash'
|
||||||
|
# Substring que contiene 'fleetview' pero no es el comando exacto -> NO matchea (grep -qx).
|
||||||
|
assert_predicate "guard3: comando 'fleetviewer' no falsea positivo" 1 "work" $'7777 fleetviewer'
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ name: launch_fleetclaude
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.4.0"
|
version: "1.5.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
|
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
|
||||||
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
||||||
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
||||||
params:
|
params:
|
||||||
- name: --cwd
|
- name: --cwd
|
||||||
@@ -20,7 +20,8 @@ params:
|
|||||||
- name: --cols
|
- name: --cols
|
||||||
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
||||||
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
||||||
uses_functions: []
|
uses_functions:
|
||||||
|
- supervise_fleetview_tui_bash_infra
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
@@ -83,10 +84,20 @@ al retomar el trabajo en el repo `fn_registry`.
|
|||||||
TTY, reutiliza la terminal actual con `exec tmux attach`.
|
TTY, reutiliza la terminal actual con `exec tmux attach`.
|
||||||
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
||||||
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
||||||
- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi
|
- **TUI bajo supervisor (auto-respawn)**: el pane izquierdo NO corre un
|
||||||
que al terminar el proceso el pane se cierra en vez de dejar una shell zombie
|
`exec fleetview` de una sola vida, sino `supervise_fleetview_tui` (bucle que
|
||||||
colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una
|
relanza la TUI si muere por crash/panic/kill). Asi el panel de control nunca se
|
||||||
shell interactiva a proposito (para que veas el mensaje y puedas compilar).
|
pierde por un fallo puntual. El supervisor para limpio con su sentinel
|
||||||
|
(`touch ~/.claude/fleet/tui_stop_<perfil>` y deja salir la TUI) o se rinde si la
|
||||||
|
TUI entra en crash-loop; en ambos casos el pane cae a una shell viva (no se
|
||||||
|
cierra solo) para inspeccionar. Es la mitad "auto-recuperacion" del par de
|
||||||
|
fixes que blindan FleetView; la otra es el Guard 3 anti-TUI/console de
|
||||||
|
`kill_fleet_agent` (la causa raiz del cierre accidental). Si el script del
|
||||||
|
supervisor no estuviera en disco, cae al `exec fleetview` clasico.
|
||||||
|
- **`exec` en los demas panes**: `claude` (orquestador e idle) se lanza con
|
||||||
|
`exec`, asi que al terminar el proceso el pane se cierra en vez de dejar una
|
||||||
|
shell zombie. Excepcion: el fallback cuando `fleetview` no esta compilado deja
|
||||||
|
una shell interactiva a proposito (para que veas el mensaje y puedas compilar).
|
||||||
- **Requiere fleetview compilado**: el default `--bin` apunta a
|
- **Requiere fleetview compilado**: el default `--bin` apunta a
|
||||||
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
||||||
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
||||||
@@ -113,6 +124,13 @@ al retomar el trabajo en el repo `fn_registry`.
|
|||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.5.0 (2026-06-24) — **auto-respawn de la TUI**. El pane izquierdo ya no corre
|
||||||
|
`exec fleetview` (una sola vida), sino el bucle supervisor
|
||||||
|
`supervise_fleetview_tui`, que relanza la TUI si muere (crash/panic/kill de su
|
||||||
|
proceso o pane). Asi el panel de control NUNCA se pierde por un fallo puntual.
|
||||||
|
Parada voluntaria via sentinel; crash-loop guard para no relanzar en bucle
|
||||||
|
cerrado. Complementa el Guard 3 anti-TUI/console de `kill_fleet_agent` (causa
|
||||||
|
raiz del cierre accidental). Nueva dependencia: `supervise_fleetview_tui_bash_infra`.
|
||||||
- v1.4.0 (2026-06-18) — **perfiles multiples**. Socket+sesion tmux ya no son el
|
- v1.4.0 (2026-06-18) — **perfiles multiples**. Socket+sesion tmux ya no son el
|
||||||
fijo `fleet`: cada perfil tiene los suyos (mismo nombre). Sin `--session`/
|
fijo `fleet`: cada perfil tiene los suyos (mismo nombre). Sin `--session`/
|
||||||
`--reuse`, cada invocacion abre el primer perfil libre (`fleet`, `fleet2`, ...),
|
`--reuse`, cada invocacion abre el primer perfil libre (`fleet`, `fleet2`, ...),
|
||||||
|
|||||||
@@ -170,7 +170,22 @@ USAGE
|
|||||||
envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")"
|
envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")"
|
||||||
local left_cmd
|
local left_cmd
|
||||||
if [[ -x "$bin" ]]; then
|
if [[ -x "$bin" ]]; then
|
||||||
|
# NO un `exec fleetview` de una sola vida: lo envolvemos en el bucle
|
||||||
|
# supervisor supervise_fleetview_tui, que relanza la TUI si muere (crash,
|
||||||
|
# panic, kill de su proceso o de su pane). Asi el panel de control de la
|
||||||
|
# flota NUNCA se pierde por un fallo puntual. El supervisor para limpio
|
||||||
|
# con su sentinel (touch ~/.claude/fleet/tui_stop_<perfil>) o se rinde si
|
||||||
|
# la TUI entra en crash-loop; en ambos casos cae a una shell viva.
|
||||||
|
local sup="$repo_root/bash/functions/infra/supervise_fleetview_tui.sh"
|
||||||
|
if [[ -f "$sup" ]]; then
|
||||||
|
# bash <sup> (no exec): al volver el supervisor (sentinel o crash-loop)
|
||||||
|
# caemos a una shell viva para que el mensaje siga visible y se pueda
|
||||||
|
# inspeccionar/relanzar. El env aplica al supervisor y a su hijo TUI.
|
||||||
|
left_cmd="$envpfx bash $(printf '%q' "$sup") --bin $(printf '%q' "$bin") --socket $(printf '%q' "$session"); exec \"\$SHELL\""
|
||||||
|
else
|
||||||
|
# Fallback si falta el supervisor en disco: comportamiento clasico.
|
||||||
left_cmd="$envpfx exec $(printf '%q' "$bin")"
|
left_cmd="$envpfx exec $(printf '%q' "$bin")"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
|
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
|
||||||
left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\""
|
left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\""
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: supervise_fleetview_tui
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "supervise_fleetview_tui --bin <path> [--socket <s>] [--sentinel <path>] [--backoff <s>] [--min-uptime <s>] [--max-fast-exits <n>]"
|
||||||
|
description: "Bucle supervisor que mantiene viva la TUI fleetview: lanza el binario y, si sale (crash, panic, kill de su proceso o pane), lo relanza tras un backoff, para que el panel de control de la flota NUNCA se pierda por un fallo puntual. Es la pieza que hace resiliente al pane izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude). Dos valvulas de escape evitan el respawn infinito: un fichero centinela (touch <sentinel> => parada voluntaria al siguiente ciclo) y un crash-loop guard (si la TUI sale demasiado rapido muchas veces seguidas, el supervisor se rinde con rc=3 en vez de quemar CPU relanzando un binario roto)."
|
||||||
|
tags: [fleet, claude-fleet, orchestration, fleetview, tui, supervisor, resilience, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
error_type: error_go_core
|
||||||
|
file_path: "bash/functions/infra/supervise_fleetview_tui.sh"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "golden: tras salir el binario, el supervisor lo relanza (respawn observable)"
|
||||||
|
- "sentinel: tocar el fichero centinela para el bucle limpio (rc=0) y lo consume"
|
||||||
|
- "crash-loop: salidas rapidas seguidas >= max_fast_exits hacen que se rinda (rc=3)"
|
||||||
|
- "error: sin --bin rc=1; binario no ejecutable rc=1"
|
||||||
|
test_file_path: "bash/functions/infra/supervise_fleetview_tui_test.sh"
|
||||||
|
params:
|
||||||
|
- name: --bin
|
||||||
|
desc: "Ruta al binario fleetview a supervisar. Obligatorio. Si no es ejecutable, sale con rc=1 con instruccion de compilado."
|
||||||
|
- name: --socket
|
||||||
|
desc: "Socket del perfil FleetView. Solo fija el nombre del sentinel por defecto. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
|
||||||
|
- name: --sentinel
|
||||||
|
desc: "Ruta del fichero centinela de parada voluntaria. Si existe tras una salida de la TUI, se borra y el bucle termina. Default: $HOME/.claude/fleet/tui_stop_<socket>."
|
||||||
|
- name: --backoff
|
||||||
|
desc: "Segundos de espera antes de relanzar la TUI tras una salida. Default: 1."
|
||||||
|
- name: --min-uptime
|
||||||
|
desc: "Umbral en segundos para considerar una salida 'rapida' (sospecha de crash-loop). Un arranque que dura >= este valor resetea el contador. Default: 2."
|
||||||
|
- name: --max-fast-exits
|
||||||
|
desc: "Numero de salidas rapidas seguidas tras las que el supervisor se rinde (crash-loop guard) en vez de seguir relanzando. Default: 5."
|
||||||
|
output: "No retorna valor; corre indefinidamente relanzando la TUI. Sale 0 ante parada voluntaria (sentinel), 1 ante uso incorrecto / binario no ejecutable, 3 cuando el crash-loop guard se rinde. Imprime una linea por cada relanzamiento o parada."
|
||||||
|
---
|
||||||
|
|
||||||
|
# supervise_fleetview_tui
|
||||||
|
|
||||||
|
Bucle supervisor de la TUI `fleetview`. Corre el binario y, cada vez que sale (crash, panic, `kill` de su proceso, cierre de su pane), lo **relanza** tras un pequeño backoff. Hace que el panel de control de la flota — el pane izquierdo de la sesión tmux FleetView — **nunca se pierda** por un fallo puntual. `launch_fleetclaude` lo usa como comando del pane izquierdo en vez de un `exec fleetview` de una sola vida.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Como lo invoca el launcher en el pane izquierdo (relanza la TUI si muere):
|
||||||
|
FLEET_SOCKET=fleet bash bash/functions/infra/supervise_fleetview_tui.sh \
|
||||||
|
--bin apps/fleetview/fleetview --socket fleet
|
||||||
|
|
||||||
|
# Pararlo voluntariamente desde otra terminal: tocar el sentinel y dejar salir la TUI.
|
||||||
|
touch ~/.claude/fleet/tui_stop_fleet
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala como wrapper del binario `fleetview` siempre que quieras que la TUI sobreviva a un crash o a un `kill` accidental de su proceso/pane (p. ej. un `kill_fleet_agent` que cierre la window que la aloja). Es la mitad "auto-recuperación" del par de fixes que blindan FleetView; la otra mitad es el Guard 3 anti-TUI/console de `kill_fleet_agent` (la causa raíz). No la uses para supervisar Claudes (esos se relanzan con `claude --resume`, no en bucle ciego).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura y de larga duración**: corre indefinidamente. Está pensada para vivir en un pane tmux con TTY, no como systemd service (la TUI necesita PTY; el watcher de fleetview sí es systemd `Restart=always`).
|
||||||
|
- **Crash-loop guard**: si la TUI sale en menos de `--min-uptime` segundos, `--max-fast-exits` veces seguidas, el supervisor se **rinde** (rc=3) en vez de relanzar para siempre un binario roto. Ajusta los umbrales si tu arranque es legítimamente lento.
|
||||||
|
- **Sentinel = única parada voluntaria limpia**: `touch <sentinel>` y deja que la TUI salga; al siguiente ciclo el supervisor ve el fichero, lo borra y termina. Sin sentinel, **relanza siempre** (es el objetivo: que no se pierda). Un sentinel huérfano de una sesión previa se limpia al arrancar para no parar de inmediato.
|
||||||
|
- **El sentinel por defecto depende del socket**: `~/.claude/fleet/tui_stop_<socket>`. Dos perfiles (`fleet`, `fleet2`) tienen sentinels distintos, así parar uno no para el otro.
|
||||||
|
- **No supervisa Claudes**: su contrato es solo la TUI. Relanzar un Claude en bucle ciego perdería su sesión; los Claudes se recuperan con `claude --resume`.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
(v1.0.0 — sin cambios todavía.)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# supervise_fleetview_tui — bucle supervisor que mantiene viva la TUI fleetview.
|
||||||
|
#
|
||||||
|
# Lanza el binario fleetview y, si sale (crash, panic, kill de su proceso o de su
|
||||||
|
# pane), lo relanza tras un pequeno backoff. Asi el panel de control de la flota
|
||||||
|
# NUNCA se pierde por un fallo puntual: es la pieza que hace resiliente al pane
|
||||||
|
# izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude).
|
||||||
|
#
|
||||||
|
# Dos valvulas de escape para no hacer respawn infinito:
|
||||||
|
# - Sentinel file: si tras una salida existe el fichero centinela, se borra y
|
||||||
|
# el bucle termina (parada voluntaria solicitada por el usuario). El default
|
||||||
|
# es $HOME/.claude/fleet/tui_stop_<socket>; pararla a mano: `touch <sentinel>`
|
||||||
|
# y dejar que la TUI salga (o matar su proceso).
|
||||||
|
# - Crash-loop guard: si la TUI sale demasiado rapido (uptime < min_uptime
|
||||||
|
# segundos) muchas veces seguidas (>= max_fast_exits), el supervisor se rinde
|
||||||
|
# y devuelve != 0, para no quemar CPU relanzando un binario roto en caliente.
|
||||||
|
# Un arranque que dura >= min_uptime resetea el contador.
|
||||||
|
#
|
||||||
|
# Funcion IMPURA: lanza un proceso en bucle y lee/escribe un fichero centinela.
|
||||||
|
#
|
||||||
|
# Overrides de entorno (testabilidad, no para uso normal):
|
||||||
|
# FLEET_SOCKET socket del perfil; fija el nombre del sentinel por defecto.
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$' \t\n'
|
||||||
|
|
||||||
|
supervise_fleetview_tui() {
|
||||||
|
local bin="" socket="" sentinel="" backoff=1 min_uptime=2 max_fast_exits=5
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--bin) shift; bin="${1:-}" ;;
|
||||||
|
--socket) shift; socket="${1:-}" ;;
|
||||||
|
--sentinel) shift; sentinel="${1:-}" ;;
|
||||||
|
--backoff) shift; backoff="${1:-1}" ;;
|
||||||
|
--min-uptime) shift; min_uptime="${1:-2}" ;;
|
||||||
|
--max-fast-exits) shift; max_fast_exits="${1:-5}" ;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<'USAGE'
|
||||||
|
Uso: supervise_fleetview_tui --bin <path> [opciones]
|
||||||
|
|
||||||
|
Bucle supervisor: corre el binario fleetview y lo relanza si sale, para que el
|
||||||
|
panel de la flota nunca se pierda por un crash/kill puntual.
|
||||||
|
|
||||||
|
Opciones:
|
||||||
|
--bin <path> Ruta al binario fleetview (obligatorio).
|
||||||
|
--socket <s> Socket del perfil FleetView. Default: $FLEET_SOCKET o "fleet".
|
||||||
|
--sentinel <path> Fichero centinela de parada voluntaria.
|
||||||
|
Default: $HOME/.claude/fleet/tui_stop_<socket>.
|
||||||
|
--backoff <s> Segundos de espera antes de relanzar. Default: 1.
|
||||||
|
--min-uptime <s> Umbral (s) para considerar una salida "rapida". Default: 2.
|
||||||
|
--max-fast-exits <n> Salidas rapidas seguidas tras las que el supervisor se
|
||||||
|
rinde (crash-loop guard). Default: 5.
|
||||||
|
-h, --help Esta ayuda.
|
||||||
|
|
||||||
|
Parar el bucle a mano: `touch <sentinel>` y dejar que la TUI salga (o matar su
|
||||||
|
proceso); en el siguiente ciclo el supervisor ve el sentinel, lo borra y termina.
|
||||||
|
|
||||||
|
Salida: 0 parada voluntaria (sentinel); 1 binario no ejecutable / uso incorrecto;
|
||||||
|
3 el supervisor se rindio por crash-loop (demasiadas salidas rapidas seguidas).
|
||||||
|
USAGE
|
||||||
|
return 0 ;;
|
||||||
|
--*)
|
||||||
|
echo "supervise_fleetview_tui: opcion desconocida '$1' (usa -h)" >&2
|
||||||
|
return 1 ;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$bin" ]]; then
|
||||||
|
bin="$1"
|
||||||
|
else
|
||||||
|
echo "supervise_fleetview_tui: argumento extra '$1' (bin ya es '$bin')" >&2
|
||||||
|
return 1
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$bin" ]] && {
|
||||||
|
echo "supervise_fleetview_tui: falta --bin <path> al binario fleetview. Usa -h." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
|
||||||
|
[[ -z "$sentinel" ]] && sentinel="$HOME/.claude/fleet/tui_stop_${socket}"
|
||||||
|
mkdir -p "$(dirname "$sentinel")" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [[ ! -x "$bin" ]]; then
|
||||||
|
echo "supervise_fleetview_tui: binario '$bin' no es ejecutable. Compila la TUI: cd apps/fleetview && go build -o fleetview ." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Limpiar un sentinel huerfano de una sesion anterior, para no parar al arrancar.
|
||||||
|
[[ -f "$sentinel" ]] && rm -f "$sentinel" 2>/dev/null || true
|
||||||
|
|
||||||
|
local fast_exits=0
|
||||||
|
while true; do
|
||||||
|
local start end uptime code
|
||||||
|
start=$(date +%s)
|
||||||
|
set +e
|
||||||
|
"$bin"
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
end=$(date +%s)
|
||||||
|
uptime=$(( end - start ))
|
||||||
|
|
||||||
|
# Valvula 1 — parada voluntaria por sentinel.
|
||||||
|
if [[ -f "$sentinel" ]]; then
|
||||||
|
rm -f "$sentinel" 2>/dev/null || true
|
||||||
|
echo "[fleetview: parada solicitada via sentinel ($sentinel) — fin del supervisor]"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Valvula 2 — crash-loop guard.
|
||||||
|
if [[ "$uptime" -lt "$min_uptime" ]]; then
|
||||||
|
fast_exits=$(( fast_exits + 1 ))
|
||||||
|
else
|
||||||
|
fast_exits=0
|
||||||
|
fi
|
||||||
|
if [[ "$fast_exits" -ge "$max_fast_exits" ]]; then
|
||||||
|
echo "[fleetview: $fast_exits salidas rapidas seguidas (ultimo code=$code) — el supervisor se rinde para no hacer respawn infinito. Inspecciona el binario y relanza.]" >&2
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[fleetview salio (code=$code, uptime=${uptime}s) — relanzando en ${backoff}s. Para parar: touch $sentinel, o Ctrl-C.]"
|
||||||
|
sleep "$backoff"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
supervise_fleetview_tui "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests para supervise_fleetview_tui. Usa un binario falso (un script) que cuenta
|
||||||
|
# sus invocaciones, para verificar respawn, crash-loop guard y sentinel sin correr
|
||||||
|
# la TUI real.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/supervise_fleetview_tui.sh"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local test_name="$1" needle="$2" haystack="$3"
|
||||||
|
if echo "$haystack" | grep -qF "$needle"; then
|
||||||
|
echo "PASS: $test_name"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected to contain '$needle'"
|
||||||
|
echo " got: $haystack"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq() {
|
||||||
|
local test_name="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$actual" == "$expected" ]]; then
|
||||||
|
echo "PASS: $test_name ($actual)"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected '$expected', got '$actual'"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
COUNTER="$TMP/runs"
|
||||||
|
SENTINEL="$TMP/sentinel"
|
||||||
|
|
||||||
|
# --- Test 1 (crash-loop guard): binario que sale rapido siempre se rinde a las N ---
|
||||||
|
# Fake bin: registra una linea por invocacion y sale 1 inmediato.
|
||||||
|
FAKE_FAST="$TMP/fake_fast.sh"
|
||||||
|
cat > "$FAKE_FAST" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo run >> "$COUNTER"
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
chmod +x "$FAKE_FAST"
|
||||||
|
|
||||||
|
: > "$COUNTER"
|
||||||
|
set +e
|
||||||
|
out=$(supervise_fleetview_tui --bin "$FAKE_FAST" --backoff 0 --min-uptime 100 \
|
||||||
|
--max-fast-exits 3 --sentinel "$SENTINEL" 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
runs=$(wc -l < "$COUNTER" | tr -d ' ')
|
||||||
|
assert_eq "crash-loop: se rinde con rc=3" 3 "$rc"
|
||||||
|
assert_eq "crash-loop: corrio exactamente 3 veces" 3 "$runs"
|
||||||
|
assert_contains "crash-loop: mensaje de rendicion" "el supervisor se rinde" "$out"
|
||||||
|
|
||||||
|
# --- Test 2 (golden respawn + sentinel): relanza tras salir, para via sentinel ---
|
||||||
|
# Fake bin: en la 2a invocacion crea el sentinel, luego sale. Prueba que:
|
||||||
|
# (a) tras la 1a salida RELANZA (respawn) -> hay 2a invocacion (golden).
|
||||||
|
# (b) al ver el sentinel, PARA (no hay 3a invocacion).
|
||||||
|
FAKE_SENT="$TMP/fake_sent.sh"
|
||||||
|
cat > "$FAKE_SENT" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo run >> "$COUNTER"
|
||||||
|
n=\$(wc -l < "$COUNTER" | tr -d ' ')
|
||||||
|
if [[ "\$n" -ge 2 ]]; then
|
||||||
|
touch "$SENTINEL"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
chmod +x "$FAKE_SENT"
|
||||||
|
|
||||||
|
: > "$COUNTER"
|
||||||
|
rm -f "$SENTINEL"
|
||||||
|
set +e
|
||||||
|
out=$(supervise_fleetview_tui --bin "$FAKE_SENT" --backoff 0 --min-uptime 0 \
|
||||||
|
--max-fast-exits 99 --sentinel "$SENTINEL" 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
runs=$(wc -l < "$COUNTER" | tr -d ' ')
|
||||||
|
assert_eq "golden: relanzo tras morir (2 invocaciones)" 2 "$runs"
|
||||||
|
assert_eq "sentinel: para limpio con rc=0" 0 "$rc"
|
||||||
|
assert_contains "sentinel: mensaje de parada voluntaria" "parada solicitada via sentinel" "$out"
|
||||||
|
assert_eq "sentinel: el fichero se consume (borrado)" "no" "$([[ -f "$SENTINEL" ]] && echo si || echo no)"
|
||||||
|
assert_contains "golden: registra el respawn" "relanzando" "$out"
|
||||||
|
|
||||||
|
# --- Test 3 (error): falta --bin ---
|
||||||
|
set +e
|
||||||
|
out=$(supervise_fleetview_tui --backoff 0 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_eq "error: sin --bin devuelve rc=1" 1 "$rc"
|
||||||
|
assert_contains "error: mensaje falta --bin" "falta --bin" "$out"
|
||||||
|
|
||||||
|
# --- Test 4 (error): binario no ejecutable ---
|
||||||
|
set +e
|
||||||
|
out=$(supervise_fleetview_tui --bin "$TMP/no_existe" 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_eq "error: binario no ejecutable rc=1" 1 "$rc"
|
||||||
|
assert_contains "error: mensaje no ejecutable" "no es ejecutable" "$out"
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
Submodule cpp/apps/chart_demo deleted from 026f514bb7
Submodule cpp/apps/shaders_lab deleted from ab38127ac0
@@ -114,6 +114,24 @@ static LRESULT CALLBACK fn_subclass_wndproc(HWND hwnd, UINT msg, WPARAM wp, LPAR
|
|||||||
case WM_EXITSIZEMOVE:
|
case WM_EXITSIZEMOVE:
|
||||||
g_in_sizemove.store(false, std::memory_order_release);
|
g_in_sizemove.store(false, std::memory_order_release);
|
||||||
break;
|
break;
|
||||||
|
case WM_SYSKEYDOWN:
|
||||||
|
// Alt+Enter would otherwise toggle a borderless-fullscreen mode
|
||||||
|
// (driven by some GPU drivers' OpenGL/Vulkan hotkey, or by
|
||||||
|
// DefWindowProc on certain window styles). We never want that:
|
||||||
|
// these are docked tool windows, not games. Consume the keystroke
|
||||||
|
// so the window stays in its normal decorated state. Every other
|
||||||
|
// Alt+key combo chains through to GLFW/DefWindowProc untouched.
|
||||||
|
if (wp == VK_RETURN) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case WM_SYSCHAR:
|
||||||
|
// Swallow the system "ding" beep that the suppressed Alt+Enter
|
||||||
|
// above would otherwise trigger via the default char handler.
|
||||||
|
if (wp == VK_RETURN) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case WM_LBUTTONDOWN:
|
case WM_LBUTTONDOWN:
|
||||||
// Alt + LMB anywhere on the window initiates a native modal MOVE
|
// Alt + LMB anywhere on the window initiates a native modal MOVE
|
||||||
// via WM_SYSCOMMAND, SC_MOVE | HTCAPTION. Same pattern as our
|
// via WM_SYSCOMMAND, SC_MOVE | HTCAPTION. Same pattern as our
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)"
|
signature: "engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)"
|
||||||
description: "Lifecycle del engine de audio basado en miniaudio (single-header, public domain). Inicializa device default, expone master volume, y libera recursos. Cross-platform: Windows/Linux/macOS via WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten. Issue 0072b — runtime gamedev nucleo. Esta TU es la unica del proyecto que define MINIAUDIO_IMPLEMENTATION."
|
description: "Lifecycle del engine de audio basado en miniaudio (single-header, public domain). Inicializa device default, expone master volume, y libera recursos. Cross-platform: Windows/Linux/macOS via WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten. Issue 0072b — runtime gamedev nucleo. Esta TU es la unica del proyecto que define MINIAUDIO_IMPLEMENTATION."
|
||||||
tags: [gamedev, audio, miniaudio]
|
tags: [gamedev-engine, audio, miniaudio]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)"
|
signature: "sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)"
|
||||||
description: "Reproduccion de audio sobre fn::audio::Engine: carga sonidos con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. Cross-platform via miniaudio. Issue 0072b — runtime gamedev nucleo."
|
description: "Reproduccion de audio sobre fn::audio::Engine: carga sonidos con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. Cross-platform via miniaudio. Issue 0072b — runtime gamedev nucleo."
|
||||||
tags: [gamedev, audio, miniaudio]
|
tags: [gamedev-engine, audio, miniaudio]
|
||||||
uses_functions: ["audio_engine_cpp_gamedev"]
|
uses_functions: ["audio_engine_cpp_gamedev"]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: pure
|
purity: pure
|
||||||
signature: "world_to_screen(Camera2D, Vec2) -> Vec2; screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])"
|
signature: "world_to_screen(Camera2D, Vec2) -> Vec2; screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])"
|
||||||
description: "Camara ortografica 2D pura: pos (centro), zoom, rotacion (rad) y viewport en pixeles. Conversiones world<->screen, AABB visible y matriz view-projection 4x4 column-major lista para cualquier renderer (sokol_gfx, OpenGL, WebGPU). Fast-path sin trig si rotation==0. Issue 0072b."
|
description: "Camara ortografica 2D pura: pos (centro), zoom, rotacion (rad) y viewport en pixeles. Conversiones world<->screen, AABB visible y matriz view-projection 4x4 column-major lista para cualquier renderer (sokol_gfx, OpenGL, WebGPU). Fast-path sin trig si rotation==0. Issue 0072b."
|
||||||
tags: [gamedev, camera, 2d, math, pure]
|
tags: [gamedev-engine, camera, 2d, math, pure]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: ["Vec2_cpp_core", "Rect_cpp_core"]
|
uses_types: ["Vec2_cpp_core", "Rect_cpp_core"]
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "loop_run(SDL_Window*, const LoopCfg&) -> void"
|
signature: "loop_run(SDL_Window*, const LoopCfg&) -> void"
|
||||||
description: "Game loop fixed-timestep estilo Glenn Fiedler ('Fix Your Timestep'). Desacopla simulacion (on_fixed_update con dt fijo) de renderizado (on_render con factor de interpolacion). Acumulador con cap anti spiral-of-death. Branch automatico desktop (while loop bloqueante) vs __EMSCRIPTEN__ (emscripten_set_main_loop). Issue 0072b."
|
description: "Game loop fixed-timestep estilo Glenn Fiedler ('Fix Your Timestep'). Desacopla simulacion (on_fixed_update con dt fijo) de renderizado (on_render con factor de interpolacion). Acumulador con cap anti spiral-of-death. Branch automatico desktop (while loop bloqueante) vs __EMSCRIPTEN__ (emscripten_set_main_loop). Issue 0072b."
|
||||||
tags: [gamedev, game-loop, sdl3, wasm, fixed-timestep]
|
tags: [gamedev-engine, game-loop, sdl3, wasm, fixed-timestep]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)"
|
signature: "input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)"
|
||||||
description: "Snapshot unificado de input por frame para SDL3. Mapea keyboard (WASD+arrows), mouse, gamepad (SDL_Gamepad) y touch a botones logicos (left/right/up/down/action_a..y/start/back) y ejes analogicos. Expone flags *_pressed con rising edge limpio cada frame. Issue 0072b — runtime gamedev PC + WASM."
|
description: "Snapshot unificado de input por frame para SDL3. Mapea keyboard (WASD+arrows), mouse, gamepad (SDL_Gamepad) y touch a botones logicos (left/right/up/down/action_a..y/start/back) y ejes analogicos. Expone flags *_pressed con rising edge limpio cada frame. Issue 0072b — runtime gamedev PC + WASM."
|
||||||
tags: [gamedev, input, sdl3, touch, gamepad]
|
tags: [gamedev-engine, input, sdl3, touch, gamepad]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: pure
|
purity: pure
|
||||||
signature: "make_environment() -> sg_environment; make_swapchain(int w, int h) -> sg_swapchain"
|
signature: "make_environment() -> sg_environment; make_swapchain(int w, int h) -> sg_swapchain"
|
||||||
description: "Builders puros para inicializar sokol_gfx encima de un GL context creado por SDL3 (no por sokol_app). Construye sg_environment con defaults RGBA8 + depth/stencil y sg_swapchain con el default framebuffer del contexto activo. Issue 0072b — base del runtime gamedev en PC + WASM."
|
description: "Builders puros para inicializar sokol_gfx encima de un GL context creado por SDL3 (no por sokol_app). Construye sg_environment con defaults RGBA8 + depth/stencil y sg_swapchain con el default framebuffer del contexto activo. Issue 0072b — base del runtime gamedev en PC + WASM."
|
||||||
tags: [gamedev, sokol, gfx, sdl3, wasm]
|
tags: [gamedev-engine, sokol, gfx, sdl3, wasm]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "sprite_batch_create(int cap=4096) -> SpriteBatch; sprite_batch_begin/draw/end"
|
signature: "sprite_batch_create(int cap=4096) -> SpriteBatch; sprite_batch_begin/draw/end"
|
||||||
description: "Batched textured quad renderer sobre sokol_gfx. Begin/draw/end con auto-flush por atlas change o capacity full. Vertex layout pos+uv+color, alpha blending estandar, GLSL 330 / GLES 300. Issue 0072b runtime gamedev — base de plataformeros, top-down, UI sprites."
|
description: "Batched textured quad renderer sobre sokol_gfx. Begin/draw/end con auto-flush por atlas change o capacity full. Vertex layout pos+uv+color, alpha blending estandar, GLSL 330 / GLES 300. Issue 0072b runtime gamedev — base de plataformeros, top-down, UI sprites."
|
||||||
tags: [gamedev, gfx, sokol, sprite, batch, 2d]
|
tags: [gamedev-engine, gfx, sokol, sprite, batch, 2d]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- sokol_setup_cpp_gfx
|
- sokol_setup_cpp_gfx
|
||||||
uses_types:
|
uses_types:
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
id: "0173"
|
||||||
|
title: "EDA: bugs críticos de correctitud estadística (outlier_pct ×100, distribution_type por-skew)"
|
||||||
|
status: resuelto
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: alta
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0174", "0175", "0176", "0177", "0068"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, profile_table, render_eda_markdown, describe_numeric, benchmark]
|
||||||
|
---
|
||||||
|
# 0173 — EDA: bugs críticos de correctitud estadística
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Un benchmark adversarial del workflow `/eda` sobre 12 datasets reales (29/06/2026,
|
||||||
|
`temp/eda_benchmark/EVALUATION.md`) detectó que los estadísticos descriptivos base son
|
||||||
|
correctos, pero el **porcentaje de outliers que el report markdown muestra es imposible**
|
||||||
|
(supera el 100%, hasta 336%), engañando a un lector no experto con apariencia de autoridad.
|
||||||
|
|
||||||
|
Hallazgos cubiertos por este issue:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H1 — `outlier_pct` por-columna >100% en el report markdown | crítico | wine-red `chlorides` 193.87%, `density` 112.57% (skew 0.07); titanic `SibSp` 336.70%, `Fare` 224.47%; seattle `precipitation` 253.25% |
|
||||||
|
| H11 — `distribution_type` por-skew etiqueta mal discretas/ordinales/multimodales | bajo | wine `quality` (6 valores) → "normal-ish"; precios BTC multimodales → "normal-ish" (skew 0.45) |
|
||||||
|
|
||||||
|
### Causa raíz de H1 (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
`EVALUATION.md` propuso "corregir la fórmula en `describe_numeric`". **Eso es incorrecto.** Al
|
||||||
|
leer el código:
|
||||||
|
|
||||||
|
- `python/functions/datascience/describe_numeric.py:113` calcula
|
||||||
|
`outlier_pct = 100.0 * n_outliers / n` — ya en escala 0-100 y acotado a [0,100]. **Está bien.**
|
||||||
|
- `python/functions/datascience/render_eda_markdown.py:203-204` renderiza ese valor con
|
||||||
|
`_fmt_pct(val)`, y `_fmt_pct` (líneas 31-44) hace `num * 100` porque **asume que su input es
|
||||||
|
una fracción 0-1**. Resultado: **doble ×100** (un 1.94 real se muestra como 193.87%).
|
||||||
|
- El PDF (`render_eda_pdf.py:296`) usa `_fmt_num(outlier_pct, 1) + "%"` sin multiplicar — por eso
|
||||||
|
el PDF muestra el outlier_pct correcto y el markdown no. El bug es **exclusivo del renderer
|
||||||
|
markdown**.
|
||||||
|
|
||||||
|
El factor "19-40×" que observó el evaluador se debe a que comparaba contra outliers IQR (3-10%),
|
||||||
|
mientras `describe_numeric` usa z-score (umbral 3.0, da menos outliers); pero el mecanismo del bug
|
||||||
|
es el doble ×100, no la fórmula.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H1 (fix de 1 línea):** en `python/functions/datascience/render_eda_markdown.py:203-204`,
|
||||||
|
sustituir `_fmt_pct(val)` por un formateo que NO multiplique (p.ej. `f"{_fmt_num(val, 2)}%"`),
|
||||||
|
porque `numeric.outlier_pct` ya viene en escala 0-100. **No tocar** `describe_numeric.py` (su
|
||||||
|
fórmula es correcta).
|
||||||
|
2. Auditar el resto de `render_eda_markdown.py` por si otro campo en escala 0-100 pasa por
|
||||||
|
`_fmt_pct` (los `*_pct` del perfil base sí son fracciones 0-1 y deben seguir con `_fmt_pct`;
|
||||||
|
solo `numeric.outlier_pct` está en escala 0-100). Documentar en el docstring de `describe_numeric`
|
||||||
|
que `outlier_pct` está en 0-100 para evitar la confusión a futuro.
|
||||||
|
3. **H11:** en `python/functions/datascience/detect_distribution_type.py`, no etiquetar por skew
|
||||||
|
solamente: usar también nº de modos / cardinalidad y, cuando esté disponible, el test de
|
||||||
|
normalidad Jarque-Bera (`normality_tests.py`, ya expuesto en `models.normality` vía
|
||||||
|
`run_eda_models`). Una variable discreta/ordinal/multimodal no debe salir "normal-ish".
|
||||||
|
4. Añadir/extender tests unitarios: `describe_numeric_test.py` (outlier_pct en [0,100]),
|
||||||
|
`render_eda_markdown_test.py` (un perfil con `outlier_pct=7.0` renderiza `"7.00%"`, no `"700%"`),
|
||||||
|
y un test de `detect_distribution_type` (discreta de 6 valores no se etiqueta "normal-ish"). Nota:
|
||||||
|
hoy NO existe `detect_distribution_type_test.py` en `python/functions/datascience/` — hay que
|
||||||
|
crearlo (a confirmar el nombre canónico al implementar; el resto de tests citados sí existen).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: outlier_pct en rango | e2e | re-correr `profile_table` sobre `temp/eda_benchmark/datasets/.../wine-red` y leer el `.md` | `chlorides`/`density` muestran `outlier_pct` en [0,100]% (no 193.87% / 112.57%) |
|
||||||
|
| Edge: skew alto real | unit | `describe_numeric_test.py` con datos de cola fuerte | `outlier_pct` ≤ 100 y coherente con n_outliers/n |
|
||||||
|
| Edge: discreta ordinal | unit | `detect_distribution_type_test.py` con 6 valores discretos | NO etiqueta "normal-ish" |
|
||||||
|
| Error: input vacío/no numérico | unit | `describe_numeric([])` | claves None, sin crash (contrato actual preservado) |
|
||||||
|
| Mecánica | — | `./fn run describe_numeric_py_datascience`, `./fn run render_eda_markdown_py_datascience` | tests verdes; `fn index` limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre wine-red y titanic y confirmar que ningún `outlier_pct` supera 100%.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md` (consolidación del benchmark). H1 es el fix de
|
||||||
|
mayor ratio impacto/esfuerzo del lote (una línea elimina los números imposibles que más minan la
|
||||||
|
confianza del report). Hermanos: 0174 (series), 0175 (relational), 0176 (render), 0177 (tipos).
|
||||||
|
|
||||||
|
|
||||||
|
## Resolucion (2026-06-29, sesion /ausente)
|
||||||
|
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: caf8c25d. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
id: "0174"
|
||||||
|
title: "EDA series temporales: período estacional roto + correlación de niveles + to_returns ciego"
|
||||||
|
status: resuelto
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: alta
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0175", "0176", "0177"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, stl_decompose, profile_table, to_returns, series, benchmark]
|
||||||
|
---
|
||||||
|
# 0174 — EDA series temporales: período estacional + correlación de niveles
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la
|
||||||
|
estacionariedad (ADF+KPSS), la autocorrelación (Ljung-Box) y el aviso de espuriedad
|
||||||
|
Granger-Newbold están **bien** (verificados a mano con `statsmodels`). Pero el **detector de
|
||||||
|
período estacional está roto**, lo que produce falsos negativos de estacionalidad, y la
|
||||||
|
correlación de precios se calcula sobre niveles (espuria para uso financiero).
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H2 — período estacional sale `2` casi siempre → `seasonal_strength=0` | crítico | seattle `temp_max` reporta "sin estacionalidad" (`period=2`); STL real con `period=365` da fuerza estacional **0.843**. UNRATE (mensual) debería usar 12, no 2 |
|
||||||
|
| H8 — correlación de precios sobre niveles marcada `sig=sí` | medio-alto | aapl/btc `Close–Open=0.998 sig=sí`: espuria por construcción (niveles autocorrelados no estacionarios) |
|
||||||
|
| H13 — `to_returns` sugerido ciegamente a temperatura (sin sentido físico) | bajo | seattle `temp_max`: "convertir a retornos"; debería ser "diferencias" |
|
||||||
|
|
||||||
|
### Causa raíz H2 (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
`python/functions/datascience/stl_decompose.py:34-58` (`_infer_period`) busca el lag entre 2 y
|
||||||
|
`max_period` que maximiza la autocorrelación **cruda** de la serie. En cualquier serie con
|
||||||
|
tendencia (precios, temperatura), la autocorrelación decae monótonamente desde el lag mínimo, así
|
||||||
|
que **el lag 2 casi siempre gana** → `period=2` espurio y un STL con componente estacional que es
|
||||||
|
ruido (`seasonal_strength≈0`). Además, `python/functions/pipelines/profile_table.py:175`
|
||||||
|
(`_build_series_block`) llama `stl_decompose(series_vals)` **sin pasar el período**, pese a que el
|
||||||
|
pipeline ya conoce la columna de orden temporal (`order_col`) y podría derivar la frecuencia.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H2 — arreglar la inferencia de período** en `stl_decompose.py:34-58`. Opciones (preferir la
|
||||||
|
robusta): (a) detrend antes de autocorrelar; (b) buscar picos en el periodograma/FFT en vez del
|
||||||
|
primer lag; (c) **derivar el período de la frecuencia del índice datetime** (mensual→12,
|
||||||
|
diario→7 y/o 365) — la señal más fiable.
|
||||||
|
2. **H2 — pasar el período desde el pipeline:** en `profile_table.py:_build_series_block`, cuando
|
||||||
|
exista `order_col` datetime, inferir la frecuencia del índice y pasar `period=` explícito a
|
||||||
|
`stl_decompose`. Si no se puede determinar un período fiable, que `stl_decompose` **no reporte
|
||||||
|
`seasonal_strength=0`** como conclusión: devolver `note` "período no determinado" (ya hay una
|
||||||
|
rama así en `:139-145`; extenderla a los casos que hoy caen en `period=2`).
|
||||||
|
3. **H8 — correlación sobre retornos para series no estacionarias:** en la sección de correlaciones
|
||||||
|
de `profile_table.py:346-384`, cuando una columna sea una serie no estacionaria de niveles
|
||||||
|
(verdict `non_stationary`/`inconclusive`, ya detectado), correlacionar sobre retornos/diferencias
|
||||||
|
(`to_returns`, ya importado) o marcar esos pares de niveles como "posible espuria" junto a la
|
||||||
|
tabla. El aviso global existe pero está lejos de los números.
|
||||||
|
4. **H13 — retornos vs diferencias por semántica:** en `profile_table.py:189` / `to_returns.py`,
|
||||||
|
elegir "retornos" (financiero, estrictamente positivo multiplicativo) vs "diferencias" (físico,
|
||||||
|
aditivo) según la naturaleza, o usar "diferencias" por defecto cuando no haya señal financiera.
|
||||||
|
5. Tests: `stl_decompose_test.py` (serie sintética mensual con estacionalidad anual → período
|
||||||
|
correcto y `seasonal_strength` alta; serie con tendencia sin estacionalidad → nota, no
|
||||||
|
`period=2`); cobertura de `_build_series_block` con `order_col` datetime.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: estacionalidad anual | e2e | re-correr `profile_table` con `run_series=True` sobre seattle `temp_max` | `seasonal_strength ≈ 0.84` con período ≈ 365 (NO "sin estacionalidad", NO `period=2`) |
|
||||||
|
| Edge: serie mensual | unit | `stl_decompose_test.py` serie mensual sintética con ciclo 12 | período inferido 12 y fuerza estacional alta |
|
||||||
|
| Edge: sin estacionalidad | unit | `stl_decompose_test.py` serie con solo tendencia | `note` "período no determinado", NO `seasonal_strength=0` como conclusión |
|
||||||
|
| Error: serie corta | unit | `stl_decompose([...]<2*period)` | nota "serie corta", sin crash (contrato actual) |
|
||||||
|
| H8 | e2e | re-correr `profile_table` sobre aapl/btc | pares de niveles no estacionarios marcados como posible espuria o correlación sobre retornos |
|
||||||
|
| Mecánica | — | `./fn run stl_decompose_py_datascience`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre seattle, fred-unrate, aapl y btc y confirmar que la estacionalidad se
|
||||||
|
detecta donde existe y no se inventa donde no.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. H2 es el segundo bloqueante de fiabilidad: un
|
||||||
|
"sin estacionalidad" donde la hay es un falso negativo que un decisor creería. La estacionariedad ya
|
||||||
|
funciona — no tocarla. Hermanos: 0173, 0175, 0176, 0177.
|
||||||
|
|
||||||
|
|
||||||
|
## Resolucion (2026-06-29, sesion /ausente)
|
||||||
|
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: e142ef02. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
id: "0175"
|
||||||
|
title: "EDA relational: precisión de FK inference (falsos positivos) + filtrar VIEWs + test ATTACH"
|
||||||
|
status: resuelto
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: alta
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0174", "0176", "0177"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, infer_fk_containment_duckdb, build_join_graph, profile_database, duckdb, benchmark]
|
||||||
|
---
|
||||||
|
# 0175 — EDA relational: precisión de FK inference + filtrar VIEWs
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la inferencia de
|
||||||
|
claves foráneas a nivel de base es **inútil por falsos positivos masivos** y que las VISTAS se
|
||||||
|
perfilan como tablas base. El join graph resultante necesita filtrado manual para ser legible.
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H3 — FK inference por contención: 10-20× falsos positivos | crítico | chinook 111 candidatas vs ~11 reales; sakila 565 vs ~30. Casos absurdos: `InvoiceLine.Quantity→Album.AlbumId`, `Genre.GenreId→{Album,Artist,Customer,…}` |
|
||||||
|
| H5 — VIEWs perfiladas como tablas base | alto | sakila `n_tables=21` incluye 5 VISTAS (`customer_list`, `film_list` 5462 filas, `staff_list`, `sales_by_store`, `sales_by_film_category`) + `film_text` (FTS, 0 filas) |
|
||||||
|
| H10 — coste relacional gastado en computar FK falsas | medio | sakila 31.82s: la mayoría en INTERSECT de los 565 pares candidatos, casi todos falsos |
|
||||||
|
| H14 — bug `sqlite_master does not exist` tras ATTACH (ya parcheado, falta test) | bajo (resuelto) | `_run.log`: `profile_database` falló con `Catalog Error: src.sqlite_master`; re-run posterior `ok` |
|
||||||
|
|
||||||
|
### Causa raíz (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
- `python/functions/datascience/infer_fk_containment_duckdb.py:217-285` emite una FK candidata si
|
||||||
|
`inclusion(A⊆B) ≥ min_inclusion` **y** B "parece clave" (unicidad ≥0.95). **No usa el nombre de
|
||||||
|
la columna**, que es la señal más fuerte de FK (`AlbumId→Album.AlbumId`), ni excluye columnas
|
||||||
|
no-clave (cantidades, importes) como ORIGEN. Enteros pequeños (`GenreId` 1..25) están contenidos
|
||||||
|
en casi todo → ruido.
|
||||||
|
- `python/functions/pipelines/profile_database.py:155-159` lista tablas con `duckdb_list_tables`
|
||||||
|
sin filtrar `table_type` → perfila VIEWs y tablas FTS como base (H5), lo que infla el universo de
|
||||||
|
pares y multiplica las FK falsas (relaciona H10).
|
||||||
|
- H10 es el **mismo cambio** que H3: filtrar candidatos por nombre **antes** del INTERSECT reduce
|
||||||
|
pares (más rápido) y falsos positivos (más preciso) a la vez.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H3+H10 — señal de nombre en `infer_fk_containment_duckdb.py:217-285`:** antes de lanzar el
|
||||||
|
INTERSECT, exigir coincidencia/patrón de nombre entre origen y destino (`from_col` casa con
|
||||||
|
`to_table`/`to_col`, patrón `<X>Id → <X>.<X>Id`; case-insensitive). Excluir como ORIGEN columnas
|
||||||
|
claramente no-clave (cantidades, importes, flags) por heurística de nombre/tipo. Esto poda el
|
||||||
|
O(tablas²×columnas²) y elimina la mayoría de los falsos positivos. Validar mejor la cardinalidad
|
||||||
|
(los `1:1` imposibles del benchmark).
|
||||||
|
2. **H5 — filtrar VIEWs** antes de perfilar e inferir FK: filtrar `table_type='BASE TABLE'` vía
|
||||||
|
`information_schema.tables` / `duckdb_tables()`. Decidir (a confirmar al implementar) si el filtro
|
||||||
|
va como flag nuevo en `duckdb_list_tables` (infra, reutilizable) o en `profile_database.py` tras
|
||||||
|
listar. Preferir el flag en `duckdb_list_tables` si no rompe consumidores.
|
||||||
|
3. **H3 — propagar al join graph:** verificar que `build_join_graph.py` recibe la lista ya filtrada
|
||||||
|
y que el diagrama Mermaid resultante es legible (sin nodos VIEW ni aristas espurias).
|
||||||
|
4. **H14 — test de regresión:** añadir test (en `profile_database_test.py` o
|
||||||
|
`infer_fk_containment_duckdb_test.py`) que haga `ATTACH` de una base SQLite pequeña en DuckDB y
|
||||||
|
perfile, confirmando que se usa `information_schema`/`duckdb_tables()` y nunca `sqlite_master`.
|
||||||
|
(A confirmar: localizar la función que hace el ATTACH —probablemente `summarize_table_duckdb.py`
|
||||||
|
o una primitiva infra `duckdb_*`— para cubrirla.)
|
||||||
|
5. Tests: casos sintéticos con tablas que tengan columnas tipo `XId` (FK real) y columnas de
|
||||||
|
cantidad contenidas en claves (falso positivo) → confirmar que solo emite las reales.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: FK reales sin ruido | e2e | re-correr `profile_database` sobre chinook | ~11 FK candidatas (no 111); incluyen `Album.ArtistId→Artist.ArtistId`, `Invoice.CustomerId→Customer.CustomerId`; NO incluyen `InvoiceLine.Quantity→Album.AlbumId` |
|
||||||
|
| Edge: VIEWs excluidas | e2e | re-correr `profile_database` sobre sakila | `n_tables` cuenta solo BASE TABLE (sin `customer_list`/`film_list`/…); FK candidatas ≪ 565 |
|
||||||
|
| Edge: cantidad vs clave | unit | `infer_fk_containment_duckdb_test.py` con columna `Quantity` contenida en una clave | NO emite FK desde `Quantity` |
|
||||||
|
| Error: ATTACH SQLite | unit | test de regresión ATTACH SQLite→DuckDB | perfila sin `sqlite_master does not exist`; usa information_schema |
|
||||||
|
| Rendimiento (H10) | e2e | medir duración de `profile_database` sobre sakila | menor que el baseline 31.82s (menos INTERSECT) |
|
||||||
|
| Mecánica | — | `./fn run infer_fk_containment_duckdb_py_datascience`, `./fn run profile_database_py_pipelines`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre chinook y sakila y confirmar que las FK reales son distinguibles del
|
||||||
|
ruido y que las VIEWs no se cuentan como tablas.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. Tres síntomas (H3/H5/H10) con un núcleo común:
|
||||||
|
la capa de inferencia de relaciones inter-tabla. Atacarlos juntos en una rama; filtrar VIEWs reduce
|
||||||
|
el universo de pares y filtrar candidatos por nombre arregla precisión y velocidad a la vez. H14 ya
|
||||||
|
está parcheado en producción; este issue solo añade el test de regresión que faltaba.
|
||||||
|
Hermanos: 0173, 0174, 0176, 0177.
|
||||||
|
|
||||||
|
|
||||||
|
## Resolucion (2026-06-29, sesion /ausente)
|
||||||
|
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: e142ef02. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
id: "0176"
|
||||||
|
title: "EDA render: models/series/caveats en markdown+PDF + PDF para profile_database"
|
||||||
|
status: resuelto
|
||||||
|
type: feature
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: media
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0174", "0175", "0177"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, render_eda_markdown, render_eda_pdf, profile_database, pdf, benchmark]
|
||||||
|
---
|
||||||
|
# 0176 — EDA render: models/series/caveats en markdown+PDF + PDF para profile_database
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la información de
|
||||||
|
modelos (PCA/KMeans) está completa en el JSON pero **no llega legible a ningún formato**, y que el
|
||||||
|
análisis relacional no tiene salida móvil (PDF). El tercio final del PDF queda ilegible.
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H4 — `models` omitido en Markdown; `models`/`series`/`caveats` como dict crudo truncado en PDF | alto | wine-red `.md` (12 numéricas, PCA valioso) → cero menciones de models. PDF aapl: `- pca: {'n_components': 2, …` cortado a media línea |
|
||||||
|
| H9 — `profile_database` no genera PDF | medio | chinook y sakila con `pdf=null`; análisis relacional solo en Markdown |
|
||||||
|
|
||||||
|
### Causa raíz (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
- `python/functions/datascience/render_eda_markdown.py`: tiene formatters para `series` (`:337`) y
|
||||||
|
`caveats` (`:407`), pero **no para `models`** → el bloque PCA/KMeans nunca se renderiza en MD.
|
||||||
|
- `python/functions/datascience/render_eda_pdf.py:50-55`: `_KNOWN_TOP_KEYS` **no incluye** `models`,
|
||||||
|
`series` ni `caveats`, así que caen en `_generic_pages` (`:479-495`) → `_wrap_value` →
|
||||||
|
`str(dict)` truncado a 60-64 chars. Por eso esas tres secciones salen como dict crudo en el PDF.
|
||||||
|
- `python/functions/pipelines/profile_database.py:205-218`: solo escribe MD+JSON, nunca invoca
|
||||||
|
`render_eda_pdf`; no tiene param `emit_pdf`.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H4 — markdown:** añadir una sección `## Modelos` (PCA/KMeans/outliers/normalidad) a
|
||||||
|
`render_eda_markdown.py`, formateando `models.pca` (varianza explicada, top loadings, acumulada),
|
||||||
|
`models.kmeans` (best_k, silhouette, tamaños de cluster) y `models.outliers` como tablas legibles.
|
||||||
|
2. **H4 — PDF:** en `render_eda_pdf.py`, añadir builders dedicados para `models`, `series` y
|
||||||
|
`caveats` (tablas/listas, no `str(dict)`) y registrarlos en `_KNOWN_TOP_KEYS` + en la lista
|
||||||
|
`builders` (`:595-604`) para sacarlos del volcado genérico. Mantener el contrato dict-no-throw
|
||||||
|
(una sección que falle no aborta el PDF).
|
||||||
|
3. **Unificar renderers:** asegurar que MD y PDF cubren el mismo conjunto de secciones (`models`,
|
||||||
|
`series`, `caveats`) para que no diverjan otra vez.
|
||||||
|
4. **H9 — PDF relational:** añadir un renderer PDF DB-level (puede ser una variante en
|
||||||
|
`render_eda_pdf.py` o una función nueva) con: portada de la base, resumen de tablas, join graph
|
||||||
|
filtrado (tras 0175), y FK candidatas. Añadir param `emit_pdf` a `profile_database.py` que lo
|
||||||
|
invoque y devuelva `pdf_path`.
|
||||||
|
5. Tests: `render_eda_markdown_test.py` (perfil con `models` → aparece sección Modelos);
|
||||||
|
`render_eda_pdf_test.py` (perfil con `models`/`series`/`caveats` → NO aparecen como `str(dict)`;
|
||||||
|
`n_pages` incrementa); test de `profile_database(emit_pdf=True)` → `pdf_path` no nulo, PDF válido.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: models en MD | e2e | re-correr `profile_table(run_models=True)` sobre wine-red y leer el `.md` | sección `## Modelos` con PCA (varianza explicada) y KMeans (silhouette) legibles |
|
||||||
|
| Golden: PDF legible | e2e | re-correr sobre aapl y `pdftotext` del PDF | `models`/`series`/`caveats` como tablas, sin `{'n_components': 2, …` truncado |
|
||||||
|
| Edge: perfil sin models | unit | `render_eda_markdown_test.py`/`render_eda_pdf_test.py` con `models=None` | sección omitida limpiamente, sin crash |
|
||||||
|
| Edge: PDF relational | e2e | `profile_database(emit_pdf=True)` sobre chinook | `pdf_path` no nulo; PDF con resumen de tablas + join graph |
|
||||||
|
| Error: sección corrupta | unit | `render_eda_pdf` con una sección con tipo inesperado | esa sección se omite con nota; PDF sigue válido (≥1 página) |
|
||||||
|
| Mecánica | — | `./fn run render_eda_markdown_py_datascience`, `./fn run render_eda_pdf_py_datascience`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre un single-table con modelos (wine-red) y sobre un relational (chinook)
|
||||||
|
y confirmar que models llega al MD y al PDF, y que `profile_database` emite PDF.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. Tipo `feature` porque, además de arreglar el
|
||||||
|
volcado crudo (H4, fix), añade un renderer PDF relational nuevo (H9). La información ya existe en el
|
||||||
|
JSON; este issue solo la hace legible en las dos salidas pensadas para humanos. Hermanos: 0173, 0174,
|
||||||
|
0175, 0177.
|
||||||
|
|
||||||
|
|
||||||
|
## Resolucion (2026-06-29, sesion /ausente)
|
||||||
|
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: c4cff5ed. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
id: "0177"
|
||||||
|
title: "EDA tipos: id secuencial fuera de correlación/PCA + η² espurio por cardinalidad + re-expresión no-continuas"
|
||||||
|
status: resuelto
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: media
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0174", "0175", "0176"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, profile_table, association_matrix, correlation_ratio, run_eda_models, suggest_reexpression, benchmark]
|
||||||
|
---
|
||||||
|
# 0177 — EDA tipos: id secuencial fuera de correlación/PCA + η² espurio
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) **refutó** el riesgo temido
|
||||||
|
(que el EDA excluyera columnas financieras `Open/Close/High/Low/Volume` por marcarlas id-like: NO
|
||||||
|
ocurre, aparecen en todo). Pero detectó el **problema inverso**: el flag `possible_id` es cosmético
|
||||||
|
y no excluye lo que sí debería (índices secuenciales), y la razón de correlación η² da artefactos
|
||||||
|
≈1 por cardinalidad.
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H7 — `possible_id` no excluye id secuencial (`PassengerId`) de correlación ni de PCA/KMeans | medio-alto | titanic `PassengerId–Cabin` η²=0.897 `sig=sí`; `models.pca.n_features=7` incluye `PassengerId`, `Survived`, `Pclass` |
|
||||||
|
| H6 — `correlation_ratio` (η²) ≈1 espurio cuando la categórica tiene cardinalidad ≈ n | alto | titanic `Ticket–Fare=1 sig=sí` (`Ticket` 681 distintos/891); aapl/btc/seattle/fred `Date–* =1` |
|
||||||
|
| H12 — `suggest_reexpression` sugiere fila para binarias/ordinales/ids (aunque sea `none`) | bajo | titanic `Survived` (0/1), `Pclass` (ordinal), `PassengerId` (id) listadas |
|
||||||
|
|
||||||
|
### Causa raíz (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
- `python/functions/pipelines/profile_table.py:356-361` (`_skip_for_assoc`) excluye de la matriz de
|
||||||
|
asociación las columnas id-like **categóricas/text** (`possible_id`/`high_cardinality`), pero **no**
|
||||||
|
excluye numéricas secuenciales (`PassengerId` es numérica con `possible_id`) ni columnas datetime.
|
||||||
|
El `assoc_input` resultante se pasa tal cual a `run_eda_models` (`:391`), así que el id secuencial,
|
||||||
|
el target binario y el ordinal entran como features de PCA/KMeans.
|
||||||
|
- H6: `correlation_ratio.py` calcula η² sin guard de cardinalidad; cuando cada grupo tiene ~1
|
||||||
|
observación (categórica de cardinalidad ≈ n), la varianza intra-grupo ≈0 → η²≈1 trivialmente. El
|
||||||
|
FDR no protege (artefacto determinista, no azar).
|
||||||
|
- H12: `suggest_reexpression` (llamado en `profile_table.py:300` para toda numérica) no salta
|
||||||
|
binarias/ordinales/ids.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H7 — distinguir id secuencial de float continuo:** en la detección de tipos
|
||||||
|
(`summarize_table_duckdb.py` / lógica de `possible_id`) o en `profile_table.py`, marcar
|
||||||
|
"índice entero secuencial/monótono" distinto de "float continuo de alta cardinalidad". El primero
|
||||||
|
se excluye de correlación y de PCA/KMeans; el segundo se mantiene (precios). **Nunca** excluir
|
||||||
|
floats continuos.
|
||||||
|
2. **H7 — excluir no-features de los modelos:** en `_skip_for_assoc` (y/o en `run_eda_models.py`)
|
||||||
|
excluir de PCA/KMeans los ids secuenciales, binarias, ordinales y el target evidente, además de
|
||||||
|
las categóricas id-like que ya se excluyen.
|
||||||
|
3. **H6 — guard de cardinalidad en η²:** en `correlation_ratio.py` (y/o al construir los pares en
|
||||||
|
`association_matrix.py`/`profile_table.py`), no computar η² si la categórica tiene cardinalidad
|
||||||
|
cercana a `n` o tamaño de grupo medio ≈1; excluir columnas datetime/id de los pares categóricos.
|
||||||
|
4. **H12 — saltar no-continuas en re-expresión:** en `suggest_reexpression.py` (o en la llamada de
|
||||||
|
`profile_table.py:300`), no emitir fila de re-expresión para binarias/ordinales/ids.
|
||||||
|
5. Tests: `correlation_ratio_test.py` (categórica cardinalidad≈n → no η²≈1 espurio);
|
||||||
|
`run_eda_models_test.py` (id secuencial/target/ordinal no entran como features);
|
||||||
|
`suggest_reexpression_test.py` (binaria/ordinal/id → sin sugerencia).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: id secuencial fuera | e2e | re-correr `profile_table(run_models=True)` sobre titanic | `PassengerId` NO aparece en correlaciones ni en `models.pca.features`; floats continuos (precios en aapl/btc) SÍ se conservan |
|
||||||
|
| Golden: η² sin artefacto | e2e | re-correr sobre titanic | `Ticket–Fare` y `Date–*` NO aparecen como par fuerte η²=1 |
|
||||||
|
| Edge: float continuo | unit | `correlation_ratio_test.py` / detección de tipos | columna float de alta cardinalidad (precio) se mantiene en correlación |
|
||||||
|
| Edge: re-expresión | unit | `suggest_reexpression_test.py` con binaria/ordinal/id | sin fila de re-expresión |
|
||||||
|
| Error: solo numéricas | unit | `run_eda_models` con assoc_input vacío tras filtrar | sin crash; bloque models coherente |
|
||||||
|
| Mecánica | — | `./fn run correlation_ratio_py_datascience`, `./fn run run_eda_models_py_datascience`, `./fn run suggest_reexpression_py_datascience`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre titanic (id secuencial + η² espurio) y sobre aapl/btc (confirmar que
|
||||||
|
los floats financieros NO se excluyen) y verificar ambos comportamientos.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. El warning "grave" del benchmark (excluir
|
||||||
|
columnas financieras) quedó **refutado**: este issue arregla el problema inverso real (no excluir
|
||||||
|
ids secuenciales) sin tocar el tratamiento correcto de los floats continuos. Hermanos: 0173, 0174,
|
||||||
|
0175, 0176.
|
||||||
|
|
||||||
|
|
||||||
|
## Resolucion (2026-06-29, sesion /ausente)
|
||||||
|
Resuelto y verificado con re-corrida del benchmark EDA. Commit principal: e142ef02. Detalle en reports/ausente-eda-benchmark-2026-06-29.md y temp/eda_benchmark/EVALUATION.md.
|
||||||
@@ -14,6 +14,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
|
|
||||||
| Grupo | N | Que cubre |
|
| Grupo | N | Que cubre |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [gamedev-2d](gamedev-2d.md) | 47 | Assets 2D para Godot via ComfyUI: 36 builders de workflow (31 de generación desde texto: pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX... + 5 de transformación desde imagen: asset_variant/sprite_from_sketch/inpaint_asset/outpaint_asset/directional_sprite) + 11 de apoyo: post-proceso (pixelize, luma->alpha, flatten_alpha), puente de assets a Godot 4 (.import + reimport headless), style presets (get/apply_gamedev_style_preset) y pipelines one-shot (asset_pack/character_set/styled_asset). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
||||||
|
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
|
||||||
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
||||||
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
||||||
| [ssh](ssh.md) | 19 | Operar hosts remotos via SSH: config, conn, ejecutar comandos, port-forward, deploys con SCP/rsync |
|
| [ssh](ssh.md) | 19 | Operar hosts remotos via SSH: config, conn, ejecutar comandos, port-forward, deploys con SCP/rsync |
|
||||||
@@ -42,7 +44,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||||
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
||||||
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
||||||
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
||||||
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
||||||
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||||
@@ -57,6 +59,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz |
|
| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz |
|
||||||
| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos |
|
| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos |
|
||||||
| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) |
|
| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) |
|
||||||
|
| [sql-connect](sql-connect.md) | 3 | Conexion directa y consulta a Microsoft SQL Server (Navision) via pymssql: abrir conexion (login_timeout), SELECT parametrizada con binding seguro -> {columns, rows, row_count}, y pipeline one-shot run_mssql_query (CLI JSON/CSV). Elimina el copia-pega manual de CSV de Navision. Credenciales desde pass, host = IP LAN de Windows desde WSL2 |
|
||||||
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
||||||
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
||||||
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
||||||
@@ -68,6 +71,11 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
|
| [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
|
||||||
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
||||||
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
||||||
|
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
|
||||||
|
| [comfyui](comfyui.md) | 126 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Cubre txt2img/img2img/inpaint/controlnet/sdxl-refiner/flux, upscale + hires-fix + facedetailer, vídeo (LTX/Wan/SVD), audio (ACE-Step), imagen→3D nativo (Hunyuan3D-2) + post-proceso de malla, templates oficiales, civitai harvest y control de cola. N = funciones con tag `comfyui` (incluye los sub-grupos `comfyui-skill`/`comfyui-styles` y 45 de `gamedev-2d`); las páginas madre de cada sub-grupo desglosan su parte |
|
||||||
|
| [comfyui-skill](comfyui-skill.md) | 17 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
||||||
|
| [comfyui-styles](comfyui-styles.md) | 5 | Capa de estilo reutilizable sobre los builders ComfyUI. Catálogo WAS (tag `comfyui-styles`): `curated_styles_catalog` (~190 estilos), `generate_styles_llm` (genera estilos por LLM via ask_llm), `append_styles` (merge+dedup+backup sobre el styles.json del selector WAS). Style presets gamedev (tag `gamedev-2d`): `get_gamedev_style_preset` (gameboy/ghibli/pixel-art-retro como datos puros) + `apply_style_preset` (preset+subject → kwargs de un builder gamedev-2d). El estilo se trata como dato curado, no como prompt repetido |
|
||||||
|
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
|
||||||
|
|
||||||
## Como anadir grupo
|
## Como anadir grupo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# comfyui-judge — panel multi-juez de calidad de imagen
|
||||||
|
|
||||||
|
El "modelo adversario" del pipeline ComfyUI: el sistema que distingue **producto bueno de
|
||||||
|
malo** de forma objetiva. Tres jueces independientes puntúan/critican una imagen y un
|
||||||
|
agregador vota por mayoría. Es el **gate objetivo** que consumen los tests, los contratos
|
||||||
|
DoD y el bucle de mejora de skills (grupo `comfyui-skill`): un skill no está "hecho" hasta
|
||||||
|
que la imagen que produce pasa el panel (`verdict == 'good'`).
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `comfyui_score_aesthetic_py_ml` | `score_aesthetic(image_path) -> {ok, score_0_10}` | Calidad estética LAION-V2 (head MLP sobre CLIP ViT-L/14). Subproceso al venv ComfyUI. Barato, determinista, sin API. |
|
||||||
|
| `comfyui_score_clip_alignment_py_ml` | `score_clip_alignment(image_path, prompt) -> {ok, score_0_1}` | Fidelidad prompt↔imagen (similitud coseno CLIP). Subproceso al venv ComfyUI. |
|
||||||
|
| `comfyui_critique_image_llm_py_ml` | `critique_image_llm(image_path, prompt) -> {ok, verdict, score_0_10, reasons}` | Crítica de un LLM-vision (artefactos, anatomía, watermarks). Compone `ask_llm_vision` (claude-direct). Cuesta API. |
|
||||||
|
| `comfyui_judge_image_py_ml` | `judge_image(image_path, prompt) -> {ok, verdict, score, votes, reasons}` | **Agregadora.** Llama a los 3, vota good/bad por mayoría, agrega razones. Degrada si un juez cae. |
|
||||||
|
|
||||||
|
Los tres jueces son ortogonales a propósito: el estético mide *belleza*, el de fidelidad mide
|
||||||
|
*que sea lo pedido*, y el crítico LLM ve *defectos finos* que un score global no penaliza. Un
|
||||||
|
único score se engaña fácil; tres votos independientes, no.
|
||||||
|
|
||||||
|
## Ejemplo canónico (end-to-end)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_judge_image import comfyui_judge_image
|
||||||
|
|
||||||
|
img = os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png")
|
||||||
|
prompt = "a majestic lion standing on rocks at sunset, photorealistic"
|
||||||
|
|
||||||
|
res = comfyui_judge_image(img, prompt)
|
||||||
|
print(res["verdict"], round(res["score"], 2), res["votes"])
|
||||||
|
# good 7.1 {'aesthetic': 'good', 'clip': 'good', 'llm': 'good'}
|
||||||
|
|
||||||
|
if res["verdict"] != "good":
|
||||||
|
for r in res["reasons"]:
|
||||||
|
print(" -", r) # feedback accionable para mejorar el skill
|
||||||
|
```
|
||||||
|
|
||||||
|
Jueces sueltos (cuando solo quieres una dimensión):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# estético (sin API, rápido)
|
||||||
|
python/.venv/bin/python3 python/functions/ml/comfyui_score_aesthetic.py ~/ComfyUI/output/comfy_sdxl_00001_.png
|
||||||
|
# fidelidad
|
||||||
|
python/.venv/bin/python3 python/functions/ml/comfyui_score_clip_alignment.py ~/ComfyUI/output/comfy_sdxl_00001_.png "a lion at sunset"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cómo se enchufa a tests / DoD como gate objetivo
|
||||||
|
|
||||||
|
- **e2e_check de un skill ComfyUI**: declarar en el `app.md` (o en el contrato del skill) un
|
||||||
|
check que genere la imagen y exija `comfyui_judge_image(img, prompt)["verdict"] == "good"`.
|
||||||
|
Es la cláusula golden: producto = imagen que el panel aprueba, no "el workflow no petó".
|
||||||
|
- **Bucle de mejora** (grupo `comfyui-skill`): tras generar, juzgar; si `verdict == 'bad'`,
|
||||||
|
las `reasons` del juez crítico indican qué corregir (más steps, otro sampler, fix de prompt)
|
||||||
|
y se re-genera. Convergencia = el panel aprueba.
|
||||||
|
- **Selección de la mejor de N**: ejecutar el panel sobre cada candidata y rankear por `score`
|
||||||
|
(o filtrar por `verdict == 'good'`).
|
||||||
|
|
||||||
|
Umbrales por defecto (ajustables): estético `>= 6.0` → good; fidelidad `>= 0.24` → good; el
|
||||||
|
crítico da su propio verdict. Veredicto final = **mayoría** de los votos vivos; empate → `bad`.
|
||||||
|
|
||||||
|
## Fronteras (qué NO cubre)
|
||||||
|
|
||||||
|
- **No genera imágenes.** Eso es el grupo `comfyui` (build_*_workflow + submit + wait + fetch).
|
||||||
|
Este grupo solo *juzga* una imagen ya producida.
|
||||||
|
- **No es un detector forense de IA** ni un clasificador NSFW/seguridad: juzga *calidad de
|
||||||
|
producto*, no procedencia ni políticas de contenido.
|
||||||
|
- **No corre en el venv del registry.** Los jueces estético/fidelidad necesitan torch +
|
||||||
|
open_clip, que viven en `~/ComfyUI/.venv`; se invocan por subproceso. El crítico necesita la
|
||||||
|
API Anthropic (claude-direct).
|
||||||
|
- **No persiste resultados.** Devuelve dicts en memoria; persistir veredictos (operations.db,
|
||||||
|
e2e_runs) es responsabilidad del consumidor.
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- venv de ComfyUI con torch + open_clip 3.x: `~/ComfyUI/.venv/bin/python3`.
|
||||||
|
- Modelo estético LAION en `/mnt/2tb/comfyui_models/aesthetic/sac+logos+ava1-l14-linearMSE.pth`.
|
||||||
|
- CLIP ViT-L-14-quickgelu (pretrained openai) cacheado (se descarga la 1ª vez, ~900 MB).
|
||||||
|
- Token OAuth de Claude (claude-direct) para el juez crítico — lo resuelve `ask_llm_vision`.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- **QuickGELU** es obligatorio en CLIP (`ViT-L-14-quickgelu`/`openai`): sin él los embeddings
|
||||||
|
se degradan en silencio y tanto el score estético como el ranking de fidelidad se desvirtúan.
|
||||||
|
- El panel **degrada con gracia**: si un juez cae (p.ej. el LLM en HTTP 429), vota con los
|
||||||
|
demás y lo anota; solo falla si caen los tres.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# ComfyUI — mapa de capacidades de generación
|
||||||
|
|
||||||
|
Vista de pájaro de **qué sabemos generar con ComfyUI**, organizada por capacidad. Es el índice de
|
||||||
|
entrada al stack ComfyUI: cruza cada capacidad con las funciones del registry que la implementan,
|
||||||
|
los grafos UI cargables y las skills (recetas) que la materializan.
|
||||||
|
|
||||||
|
Las tres páginas madre detalladas siguen siendo la fuente de verdad por grupo:
|
||||||
|
|
||||||
|
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
|
||||||
|
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
|
||||||
|
- [comfyui-styles.md](comfyui-styles.md) — grupo `comfyui-styles`: presets + catálogo de estilo (selector WAS).
|
||||||
|
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
||||||
|
- [gamedev-2d.md](gamedev-2d.md) — grupo `gamedev-2d`: 47 builders de assets 2D para Godot (45 también `comfyui`).
|
||||||
|
|
||||||
|
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
|
||||||
|
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
|
||||||
|
su contraparte versionable: el mapa capacidad → grupo de funciones que sí pertenece a `fn_registry`.
|
||||||
|
|
||||||
|
Filtros MCP: `mcp__registry__fn_search query="" tag="comfyui"` (y `tag="comfyui-skill"`,
|
||||||
|
`tag="comfyui-judge"`).
|
||||||
|
|
||||||
|
## Las capacidades de un vistazo
|
||||||
|
|
||||||
|
| # | Capacidad | Qué resuelve | Builders / pipelines clave | Grafo UI | Skills |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 01 | **txt2img** | prompt → imagen (SD1.5/SDXL/Flux) | `build_txt2img`, `build_flux`, `build_sdxl_refiner`, `txt2img_oneshot` | ✅ ×2 | — |
|
||||||
|
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
|
||||||
|
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
|
||||||
|
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
|
||||||
|
| 04b | **styles (presets/catálogo)** | estilo reutilizable: catálogo WAS + presets gamedev | `curated_styles_catalog`, `generate_styles_llm`, `append_styles`, `get_gamedev_style_preset`, `apply_style_preset` | — | — |
|
||||||
|
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
||||||
|
| 05b | **audio** | texto → música/SFX/voz (ACE-Step) | `build_audio_workflow`, `fetch_output_audio` | — | — |
|
||||||
|
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
|
||||||
|
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
|
||||||
|
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
|
||||||
|
| 09 | **operación / infra** | server, modelos, cola, encolar/esperar, I/O de workflows | `ensure_server`, `submit_workflow`, `wait_result`, `validate_workflow`, `download_model`, `run_foreign_workflow_oneshot` | — | — |
|
||||||
|
| 10 | **ipadapter / referencia** _(en construcción)_ | guiar por imagen de referencia (estilo/sujeto) sin LoRA + encadenar varios LoRAs en una llamada | `build_ipadapter_workflow`, `inject_multi_lora` _(añadiéndose ahora; no indexadas todavía)_ | — | — |
|
||||||
|
|
||||||
|
Las capacidades 01-05 ya tienen grafo UI cargable; 06-08 están cubiertas por funciones del registry
|
||||||
|
pero sin grafo UI todavía (se añade su subcarpeta cuando aparezca el primero). 09 es la maquinaria
|
||||||
|
transversal que habilita el resto, no una capacidad de generación. **10 está en construcción** por
|
||||||
|
el flujo de funciones+server (al 24/06/2026 vi `comfyui_build_ipadapter_workflow` e
|
||||||
|
`comfyui_inject_multi_lora` en `python/functions/ml/` sin indexar aún): se completará este mapa con
|
||||||
|
sus IDs reales cuando se ejecute `fn index`.
|
||||||
|
|
||||||
|
## Mapa capacidad → funciones del registry
|
||||||
|
|
||||||
|
### 01 · txt2img
|
||||||
|
|
||||||
|
- `comfyui_build_txt2img_workflow_py_ml` (pura) — SD1.5/SDXL: CheckpointLoader → CLIPTextEncode×2 → KSampler → VAEDecode → SaveImage.
|
||||||
|
- `comfyui_build_flux_workflow_py_ml` (pura) — Flux: UNETLoader + DualCLIPLoader + VAELoader, guía por FluxGuidance.
|
||||||
|
- `comfyui_build_sdxl_refiner_workflow_py_ml` (pura) — SDXL base+refiner (2 KSamplerAdvanced encadenados).
|
||||||
|
- `comfyui_txt2img_oneshot_py_pipelines` — prompt → PNG en disco (build + submit + wait + fetch).
|
||||||
|
|
||||||
|
### 02 · img2img / inpaint
|
||||||
|
|
||||||
|
- `comfyui_build_img2img_workflow_py_ml` (pura) — LoadImage → VAEEncode → KSampler (denoise<1).
|
||||||
|
- `comfyui_build_inpaint_workflow_py_ml` (pura) — LoadImage + LoadImageMask → VAEEncodeForInpaint.
|
||||||
|
|
||||||
|
### 03 · controlnet
|
||||||
|
|
||||||
|
- `comfyui_build_controlnet_workflow_py_ml` (pura) — ControlNetLoader → ControlNetApply sobre el condicionamiento positivo.
|
||||||
|
|
||||||
|
### 04 · skills (multiestilo / LoRA)
|
||||||
|
|
||||||
|
- `comfyui_build_skill_workflow_py_ml` (pura) — compila una receta (`recipe.json`) a workflow, sustituye `{subject}`, encadena LoRAs + post-proceso.
|
||||||
|
- `comfyui_inject_lora_py_ml` (pura) — inserta `LoraLoader` en un workflow ya construido (encadenable).
|
||||||
|
- `comfyui_generate_with_skill_oneshot_py_pipelines` — skill + subject → PNG juzgado.
|
||||||
|
- `comfyui_harvest_civitai_skill_oneshot_py_pipelines` — Civitai → skill candidata.
|
||||||
|
- `comfyui_export_skill_template_py_ml` — skill → template API + grafo UI cargable.
|
||||||
|
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
|
||||||
|
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
|
||||||
|
|
||||||
|
### 04b · styles (presets / catálogo)
|
||||||
|
|
||||||
|
Página madre: [comfyui-styles.md](comfyui-styles.md). Estilo reutilizable como dato, no como prompt repetido.
|
||||||
|
|
||||||
|
- `comfyui_curated_styles_catalog_py_ml` (pura) — catálogo curado (~190 estilos) para el selector WAS.
|
||||||
|
- `comfyui_generate_styles_llm_py_ml` (impura) — genera N estilos de una categoría vía `ask_llm`.
|
||||||
|
- `comfyui_append_styles_py_ml` (impura) — fusiona estilos sobre el `styles.json` WAS (merge+dedup+backup).
|
||||||
|
- `comfyui_get_gamedev_style_preset_py_ml` (pura) — receta de *style preset* gamedev (gameboy/ghibli/pixel-art-retro).
|
||||||
|
- `comfyui_apply_style_preset_py_ml` (pura) — traduce un preset + subject a los kwargs de un builder gamedev-2d.
|
||||||
|
|
||||||
|
### 05 · video
|
||||||
|
|
||||||
|
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
|
||||||
|
- `comfyui_build_video_workflow_py_ml` (pura) — txt2video LTX-Video 2B o Wan2.1 1.3B.
|
||||||
|
|
||||||
|
### 05b · audio
|
||||||
|
|
||||||
|
- `comfyui_build_audio_workflow_py_ml` (pura) — txt2audio ACE-Step: TextEncodeAceStepAudio (tags + lyrics) → EmptyAceStepLatentAudio → KSampler → VAEDecodeAudio → SaveAudio(.flac).
|
||||||
|
|
||||||
|
### 06 · upscale / detail
|
||||||
|
|
||||||
|
- `comfyui_build_upscale_workflow_py_ml` (pura) — ESRGAN (`model`) o reescalado pixel (`latent`).
|
||||||
|
- `comfyui_build_hires_fix_workflow_py_ml` (pura) — hires-fix 2 pasadas (UltimateSDUpscale por tiles + Remacri).
|
||||||
|
- `comfyui_inject_hires_fix_py_ml` (pura) — inyecta la 2ª pasada en un workflow ya construido.
|
||||||
|
- `comfyui_build_facedetailer_workflow_py_ml` (pura) — FaceDetailer (detecta caras YOLO y las regenera).
|
||||||
|
|
||||||
|
### 07 · 3D
|
||||||
|
|
||||||
|
- `comfyui_build_image_to_3d_workflow_py_ml` (pura) — imagen → GLB (Hunyuan3D-2 nativo, 9 nodos).
|
||||||
|
- `comfyui_build_textured_3d_multiview_workflow_py_ml` (pura) — malla texturizada PBR multi-vista (Hunyuan3DWrapper).
|
||||||
|
- `comfyui_build_view_3d_workflow_py_ml` (pura) — visor 3D nativo (Load3D) para orbitar un GLB existente.
|
||||||
|
- `comfyui_generate_views_from_image_py_ml` — sintetiza vistas novel-view (StableZero123/SV3D).
|
||||||
|
- Pipelines: `comfyui_image_to_3d_oneshot_py_pipelines`, `comfyui_text_to_3d_oneshot_py_pipelines`, `comfyui_mesh_cleanup_oneshot_py_pipelines`.
|
||||||
|
- Mallas: `comfyui_simplify_mesh_py_ml`, `comfyui_make_watertight_py_ml`, `comfyui_install_3d_model_py_ml`.
|
||||||
|
- Relación: el grupo [img-to-3d](img-to-3d.md) es la vía ligera (relieve por profundidad) que produce el GLB que [mesh-3d](mesh-3d.md) renderiza; ComfyUI/Hunyuan3D es la vía pesada de malla volumétrica real.
|
||||||
|
|
||||||
|
### 08 · juez / calidad
|
||||||
|
|
||||||
|
- `comfyui_judge_image_py_ml` — panel agregador (estético + CLIP + LLM-vision), veredicto por mayoría.
|
||||||
|
- `comfyui_score_aesthetic_py_ml` — score estético LAION-V2 (0-10).
|
||||||
|
- `comfyui_score_clip_alignment_py_ml` — fidelidad prompt↔imagen vía CLIP (0-1).
|
||||||
|
- `comfyui_critique_image_llm_py_ml` — crítica LLM-vision (artefactos, anatomía, texto, watermarks).
|
||||||
|
|
||||||
|
### 09 · operación / infra (transversal)
|
||||||
|
|
||||||
|
- Server: `comfyui_ensure_server_py_infra`.
|
||||||
|
- Modelos: `comfyui_download_model_py_ml`, `comfyui_list_installed_models_py_ml`, `comfyui_install_custom_node_py_ml`.
|
||||||
|
- Ejecución: `comfyui_submit_workflow_py_ml`, `comfyui_wait_result_py_ml`, `comfyui_stream_progress_py_ml`, `comfyui_validate_workflow_py_ml`, `comfyui_object_info_py_ml`.
|
||||||
|
- Cola: `comfyui_queue_manage_py_ml`, `comfyui_interrupt_queue_py_ml`.
|
||||||
|
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`, `comfyui_fetch_output_audio_py_ml`.
|
||||||
|
- Barridos: `comfyui_batch_generate_py_ml`, `comfyui_build_grid_py_ml`.
|
||||||
|
- Workflows I/O: `comfyui_import_workflow_json_py_ml`, `comfyui_import_workflow_png_py_ml`, `comfyui_read_png_metadata_py_ml`, `comfyui_download_workflow_py_ml`, `comfyui_run_foreign_workflow_oneshot_py_pipelines`.
|
||||||
|
- Templates oficiales (paquete `comfyui-workflow-templates`): `comfyui_list_templates_py_ml`, `comfyui_extract_template_py_ml`.
|
||||||
|
- UI vía CDP: `comfyui_load_workflow_ui_py_browser`, `comfyui_export_workflow_ui_py_browser`, `comfyui_queue_prompt_ui_py_browser`, `comfyui_clear_node_outputs_ui_py_browser`.
|
||||||
|
|
||||||
|
## Librería de grafos en disco
|
||||||
|
|
||||||
|
Los grafos UI cargables viven en `~/ComfyUI/user/default/workflows/`, agrupados en subcarpetas
|
||||||
|
numeradas por capacidad (`01_txt2img/`, `02_img2img/`, `03_controlnet/`, `04_skills/`, `05_video/`).
|
||||||
|
ComfyUI las lista recursivamente (`GET /api/userdata?dir=workflows&recurse=true`) y el menú
|
||||||
|
**Workflows** del navegador las muestra como árbol anidado, así que las capacidades quedan visibles
|
||||||
|
en la propia UI. La regla de clasificación de un grafo nuevo (por su nodo terminal) y el detalle de
|
||||||
|
cada grafo concreto están en `~/ComfyUI/CAPABILITIES.md`.
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- Este doc es un **índice cross-grupo**: no documenta firmas completas ni gotchas por función — eso
|
||||||
|
vive en las páginas madre (`comfyui.md`, `comfyui-skill.md`, `comfyui-judge.md`) y en cada `.md`
|
||||||
|
de función. Aquí solo está el mapa capacidad → función.
|
||||||
|
- No cubre la **generación** en sí (ejecutar workflows contra la GPU); cubre el catálogo de qué
|
||||||
|
capacidades existen y con qué piezas se componen.
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# ComfyUI Skill — Recetas versionadas de generación reutilizables
|
||||||
|
|
||||||
|
Tag: `comfyui-skill`. Grupo para tratar una configuración de generación de ComfyUI como una
|
||||||
|
**skill**: una receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt +
|
||||||
|
bloques de post-proceso) que se guarda una vez y se reproduce a un workflow concreto cambiando
|
||||||
|
solo el *subject*. Es la doctrina del issue 0087 aplicada a la generación de imágenes: el registry
|
||||||
|
crece **promoviendo configuraciones que funcionan a recetas reutilizables**, no reescribiendo el
|
||||||
|
grafo de nodos cada vez.
|
||||||
|
|
||||||
|
Construye sobre el grupo [`comfyui`](comfyui.md) (los builders puros de workflow y el ciclo
|
||||||
|
submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
|
||||||
|
|
||||||
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
|
||||||
|
|
||||||
|
> **Tamaño del grupo (al 28/06/2026):** 17 funciones con tag `comfyui-skill` — CRUD de recetas
|
||||||
|
> (save/load/list), compilación a workflow (`build_skill_workflow`), inyectores encadenables
|
||||||
|
> (`inject_hires_fix`/`inject_multi_lora`, `build_ipadapter_workflow`), bucle de mejora
|
||||||
|
> genera→juzga→bump (`generate_with_skill_oneshot` + `update_skill_score` + `bump_skill_version`),
|
||||||
|
> export a grafo (`export_skill_template`), mixer de capacidades (`compose_capabilities` +
|
||||||
|
> `generate_mixed_oneshot`) y cosecha de Civitai (`extract_recipe_from_png` + `harvest_civitai_skill_oneshot`).
|
||||||
|
|
||||||
|
## Qué es una skill
|
||||||
|
|
||||||
|
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/ComfyUI/skills_library/
|
||||||
|
INDEX.md # índice regenerado de todas las skills
|
||||||
|
<slug>/
|
||||||
|
recipe.json # la receta actual
|
||||||
|
versions/vN.json # snapshot inmutable de cada save (N incremental)
|
||||||
|
growth_log.jsonl # bitácora append-only de cada save
|
||||||
|
exports/ # plantillas de workflow exportadas
|
||||||
|
samples/ # imágenes de muestra
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema de `recipe.json` (canónico)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"slug": "portrait_cinematic_sdxl",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Retrato cinematográfico SDXL",
|
||||||
|
"base_workflow": "txt2img",
|
||||||
|
"checkpoint": "juggernaut_xl_v11.safetensors",
|
||||||
|
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
|
||||||
|
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
|
||||||
|
"scheduler": "karras", "width": 832, "height": 1216, "denoise": 1.0},
|
||||||
|
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
|
||||||
|
"negative": "blurry, lowres", "trigger_words": []},
|
||||||
|
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}},
|
||||||
|
{"type": "hires_fix", "params": {"upscale_by": 1.5, "denoise": 0.4}}],
|
||||||
|
"score_mean": 0.0, "score_n": 0,
|
||||||
|
"provenance": {"source": "manual", "nsfw": false},
|
||||||
|
"export_template_path": "exports/portrait_cinematic_sdxl.template.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`} (las bases que se generan desde un *subject*
|
||||||
|
de texto). `blocks[].type` ∈ {`facedetailer`, `hires_fix`}.
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace | Purity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [comfyui_build_skill_workflow_py_ml](../../python/functions/ml/comfyui_build_skill_workflow.md) | `build_skill_workflow(recipe, subject, *, seed=0) -> dict` | Compila una receta a un workflow en API format: despacha al builder base, sustituye `{subject}` + trigger_words, encadena LoRAs y aplica los blocks en orden. `SkillWorkflowError` si la base es desconocida o requiere imagen. | **pura** |
|
||||||
|
| [comfyui_export_skill_template_py_ml](../../python/functions/ml/comfyui_export_skill_template.md) | `export_skill_template(slug, *, ui_graph=False, port=9222, ...) -> dict` | Exporta una skill a artefactos cargables como GRAFO: template API en `exports/<slug>.template.json` y, con `ui_graph=True`, el UI graph posicionado (vía `load_workflow_ui`+`export_workflow_ui` por CDP) en la carpeta nativa `~/ComfyUI/user/default/workflows/<slug>.json` (menú Workflows del navegador). Sin navegador, deja el template API y reporta el fallback. | impura |
|
||||||
|
| [comfyui_inject_hires_fix_py_ml](../../python/functions/ml/comfyui_inject_hires_fix.md) | `comfyui_inject_hires_fix(workflow, *, upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Inyecta una 2ª pasada hires-fix (UpscaleModelLoader + UltimateSDUpscale) sobre un workflow ya construido, repuntando el SaveImage. Versión encadenable-sobre-dict del builder hermano. | **pura** |
|
||||||
|
| [comfyui_inject_multi_lora_py_ml](../../python/functions/ml/comfyui_inject_multi_lora.md) | `comfyui_inject_multi_lora(workflow, loras) -> dict` | Encadena N `LoraLoader` sobre un workflow ya construido reusando `comfyui_inject_lora` por LoRA. Cada lora = `{name, strength_model, strength_clip}`; respeta el orden (primero cerca del checkpoint, último cerca del KSampler). Apila estilo + detalle en una sola llamada. | **pura** |
|
||||||
|
| [comfyui_build_ipadapter_workflow_py_ml](../../python/functions/ml/comfyui_build_ipadapter_workflow.md) | `comfyui_build_ipadapter_workflow(prompt, ref_image, *, base_checkpoint, mode='style'\|'faceid', weight=0.8, ...) -> dict` | txt2img + IPAdapter (custom node cubiq). `mode='style'` transfiere estilo/composición de una imagen de referencia (IPAdapterUnifiedLoader+IPAdapter); `mode='faceid'` impone un rostro consistente vía insightface + .bin FaceID + su LoRA (IPAdapterUnifiedLoaderFaceID+IPAdapterFaceID). Repunta el KSampler a la rama IPAdapter. | **pura** |
|
||||||
|
| [comfyui_save_skill_py_ml](../../python/functions/ml/comfyui_save_skill.md) | `comfyui_save_skill(recipe, *, library_dir=None) -> dict` | Valida el schema mínimo y escribe `recipe.json` + snapshot `versions/vN.json` + growth_log + INDEX.md. No muta la receta (round-trip con load). | impura |
|
||||||
|
| [comfyui_load_skill_py_ml](../../python/functions/ml/comfyui_load_skill.md) | `comfyui_load_skill(slug, *, version=None, library_dir=None) -> dict` | Lee `recipe.json` (actual) o un snapshot `versions/vN.json`. Slug/versión inexistente → `{ok:False}` sin lanzar. | impura |
|
||||||
|
| [comfyui_list_skills_py_ml](../../python/functions/ml/comfyui_list_skills.md) | `comfyui_list_skills(*, library_dir=None, include_nsfw=False) -> dict` | Lista las skills con slug/title/base_workflow/version/score/nsfw/n_versions. Oculta NSFW por defecto. | impura |
|
||||||
|
| [ask_llm_vision_py_core](../../python/functions/core/ask_llm_vision.md) | `ask_llm_vision(prompt, image_path='', *, image_b64='', media_type='', model='claude-opus-4-8', ...) -> dict` | Pregunta multimodal (imagen + texto) al modelo via API directa de Anthropic (grupo `claude-direct`). Útil para **puntuar** el PNG de una skill y alimentar `score_mean`. | impura |
|
||||||
|
| [comfyui_generate_with_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_generate_with_skill_oneshot.md) | `generate_with_skill_oneshot(slug, subject, *, server='127.0.0.1:8188', dest=None, seed=0, judge=True, recipe_patch=None, ...) -> dict` | One-shot del bucle: carga la skill, la compila para el `subject`, encola, espera, descarga el PNG y (si `judge`) lo puntúa con el panel `comfyui-judge`, acumulando el score en la media. `recipe_patch` prueba una variante en memoria sin guardar. | pipeline (impura) |
|
||||||
|
| [comfyui_update_skill_score_py_ml](../../python/functions/ml/comfyui_update_skill_score.md) | `comfyui_update_skill_score(slug, new_score, *, library_dir=None) -> dict` | Acumula el score de un juicio en `score_mean`/`score_n` por media incremental, reescribiendo `recipe.json` en sitio (sin snapshot ni growth_log). | impura |
|
||||||
|
| [comfyui_bump_skill_version_py_ml](../../python/functions/ml/comfyui_bump_skill_version.md) | `comfyui_bump_skill_version(slug, change, *, score_before, score_after, judge_run_id=None, recipe_patch=None, force=False, ...) -> dict` | Promueve una versión nueva **solo si el score sube** (gate objetivo): snapshot `versions/vN.json` + aplica `recipe_patch` + sube el semver + línea en `growth_log`. Gate bloquea si no mejora. | impura |
|
||||||
|
| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `comfyui_extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado de Civitai en una receta de skill **candidata** (`score_n=0`, `provenance.source='civitai'`). Compone `comfyui_import_workflow_png` (workflow API embebido) + `comfyui_read_png_metadata` (params del KSampler); fallback a la `meta` de Civitai. Degradación honesta: `ok=False` sin inventar si no hay ni workflow embebido ni meta utilizable. | impura |
|
||||||
|
| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `comfyui_harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', ...) -> dict` | One-shot Civitai → skill candidata: `search` → `fetch` (segrega NSFW) → `extract_recipe` → `save_skill`. Itera los items hasta hallar uno con receta destilable (preferentemente workflow embebido), descartando los PNG sin receta; 2º pase al feed global si filtró por modelo. **No baja modelos a ciegas**: los ausentes van a `missing_models`. | pipeline (impura) |
|
||||||
|
|
||||||
|
`build_skill_workflow` compone los builders del grupo [`comfyui`](comfyui.md):
|
||||||
|
`comfyui_build_txt2img_workflow`, `comfyui_build_flux_workflow`,
|
||||||
|
`comfyui_build_sdxl_refiner_workflow`, `comfyui_inject_lora`,
|
||||||
|
`comfyui_build_facedetailer_workflow` y `comfyui_inject_hires_fix`.
|
||||||
|
|
||||||
|
## Ejemplo canónico end-to-end (receta → workflow → PNG → score)
|
||||||
|
|
||||||
|
Guardar una skill, cargarla, compilarla a un workflow para un sujeto, encolarla y puntuar el
|
||||||
|
resultado con visión. Requiere el server ComfyUI en `127.0.0.1:8188` y los modelos de la receta
|
||||||
|
instalados.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_save_skill import comfyui_save_skill
|
||||||
|
from ml.comfyui_load_skill import comfyui_load_skill
|
||||||
|
from ml.comfyui_build_skill_workflow import build_skill_workflow
|
||||||
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||||
|
from core.ask_llm_vision import ask_llm_vision
|
||||||
|
|
||||||
|
# 1. Definir y guardar la skill (una vez).
|
||||||
|
recipe = {
|
||||||
|
"schema_version": 1, "slug": "portrait_cinematic_sdxl", "version": "1.0.0",
|
||||||
|
"title": "Retrato cinematográfico SDXL", "base_workflow": "txt2img",
|
||||||
|
"checkpoint": "dreamshaper_8.safetensors",
|
||||||
|
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
|
||||||
|
"params": {"steps": 28, "cfg": 6.0, "sampler_name": "dpmpp_2m", "scheduler": "karras"},
|
||||||
|
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
|
||||||
|
"negative": "blurry, lowres", "trigger_words": []},
|
||||||
|
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
|
||||||
|
"score_mean": 0.0, "score_n": 0, "provenance": {"source": "manual", "nsfw": False},
|
||||||
|
}
|
||||||
|
comfyui_save_skill(recipe) # ~/ComfyUI/skills_library/portrait_cinematic_sdxl/
|
||||||
|
|
||||||
|
# 2. Cargar + compilar a un workflow para un sujeto concreto.
|
||||||
|
recipe = comfyui_load_skill("portrait_cinematic_sdxl")["recipe"]
|
||||||
|
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
|
||||||
|
|
||||||
|
# 3. Encolar y esperar el PNG (camino headless del grupo comfyui).
|
||||||
|
pid = comfyui_submit_workflow(wf)["prompt_id"]
|
||||||
|
outputs = comfyui_wait_result(pid)["outputs"]
|
||||||
|
img = comfyui_fetch_output_image(outputs[0]["filename"], dest_dir="/tmp")["path"]
|
||||||
|
|
||||||
|
# 4. Puntuar el resultado con visión (alimenta el bucle de scoring de la skill).
|
||||||
|
verdict = ask_llm_vision(
|
||||||
|
"Puntúa de 0 a 10 el realismo de este retrato. Responde solo el número.",
|
||||||
|
image_path=img, model="claude-opus-4-8",
|
||||||
|
)
|
||||||
|
print(verdict["text"])
|
||||||
|
```
|
||||||
|
|
||||||
|
El paso "guardar la receta" se hace una sola vez; a partir de ahí cada generación es
|
||||||
|
`load → build → submit`, cambiando solo el `subject` y la `seed`.
|
||||||
|
|
||||||
|
## Bucle de mejora (skill → genera → juzga → bump)
|
||||||
|
|
||||||
|
La doctrina del issue 0087 cerrada en un lazo: una skill **no crece inflando la receta a ciegas,
|
||||||
|
crece registrando mejoras medibles**. El juez (no el humano) decide qué se promueve.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ generate_with_skill_oneshot(slug, subject, judge=True) │
|
||||||
|
│ load → build → submit → wait → fetch → judge → score_mean │ ← canónica
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│ score_before = score de la receta vigente
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ generate_with_skill_oneshot(..., recipe_patch={params:{...}}) │ ← variante (no guarda score)
|
||||||
|
│ misma seed, un cambio plausible → judge.score = score_after │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼ GATE objetivo
|
||||||
|
comfyui_bump_skill_version(slug, change, score_before, score_after, judge_run_id=...)
|
||||||
|
│
|
||||||
|
score_after > score_before ?
|
||||||
|
├── sí → promueve: versions/vN.json (snapshot) + recipe_patch + semver↑ + growth_log
|
||||||
|
└── no → {ok:False} — NO se promueve (la variante se descarta)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pasos concretos:
|
||||||
|
|
||||||
|
1. **Genera la canónica** con `judge=True`. El panel `comfyui-judge` emite un `score` y el pipeline
|
||||||
|
lo acumula en `score_mean`/`score_n` de la skill (vía `comfyui_update_skill_score`). Ese score es
|
||||||
|
el `score_before`.
|
||||||
|
2. **Genera una variante** con `recipe_patch` (p.ej. `{"params": {"steps": 32}}`) y la **misma seed**.
|
||||||
|
El patch se aplica en memoria, NO se guarda, y su score NO contamina la media. Su `judge.score` es
|
||||||
|
el `score_after`, y su `judge_run_id` es la evidencia.
|
||||||
|
3. **Promueve con el gate**: `comfyui_bump_skill_version` aplica el patch a `recipe.json`, sube el
|
||||||
|
semver y deja una línea en `growth_log.jsonl` **solo si `score_after > score_before`**. Si no
|
||||||
|
mejora, devuelve `{ok:False}` y la receta se queda como estaba. El gate es objetivo: lo decide el
|
||||||
|
número del juez, no quien lanza la generación.
|
||||||
|
|
||||||
|
Así `versions/` y `growth_log` reflejan **versiones de receta con mejora demostrada**, mientras
|
||||||
|
`score_mean` es la telemetría de calidad media de la versión vigente.
|
||||||
|
|
||||||
|
## Skills como grafos en el navegador
|
||||||
|
|
||||||
|
Una skill no vive solo como receta JSON: se exporta a un **grafo de ComfyUI cargable como tal en el
|
||||||
|
navegador**. `comfyui_export_skill_template` cierra ese hueco (receta → grafo):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ml.comfyui_export_skill_template import export_skill_template
|
||||||
|
|
||||||
|
# Headless (sin navegador): congela el template API junto a la skill.
|
||||||
|
export_skill_template("portrait_cinematic_sd15")
|
||||||
|
# -> exports/portrait_cinematic_sd15.template.json (API format, node-template reproducible)
|
||||||
|
|
||||||
|
# Con navegador (pestaña ComfyUI abierta en CDP 9222): además el grafo visual posicionado.
|
||||||
|
out = export_skill_template("portrait_cinematic_sd15", ui_graph=True, port=9222)
|
||||||
|
# -> ~/ComfyUI/user/default/workflows/portrait_cinematic_sd15.json (aparece en el menú Workflows)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dos formatos, dos usos:
|
||||||
|
|
||||||
|
- **API format** (`exports/<slug>.template.json`) — el dict `{node_id:{class_type,inputs}}`. Se
|
||||||
|
carga con `comfyui_load_workflow_ui` (`app.loadApiJson`, litegraph lo auto-posiciona) o va directo
|
||||||
|
a `comfyui_submit_workflow`. Es el node-template versionable de la skill.
|
||||||
|
- **UI graph** (`~/ComfyUI/user/default/workflows/<slug>.json` + copia en `exports/<slug>.ui.json`)
|
||||||
|
— `nodes`/`links`/`pos` (`app.graph.serialize()`). La carpeta nativa de la UI **solo** acepta este
|
||||||
|
formato; por eso solo se escribe con `ui_graph=True` (se genera vía CDP cargando el API en la UI y
|
||||||
|
serializando el grafo posicionado). Es el que se abre como grafo visual desde el menú Workflows.
|
||||||
|
|
||||||
|
**Fotos ↔ grafo.** Cada PNG de ComfyUI lleva su workflow embebido (chunk `prompt`, API format).
|
||||||
|
`comfyui_import_workflow_png` lo recupera, de modo que toda muestra de una skill queda asociada a su
|
||||||
|
grafo reproducible 1:1 (ver `INDEX.md` de la librería: `samples/<base>.png` + `samples/<base>.graph.json`).
|
||||||
|
|
||||||
|
**No destructivo en el navegador**: `ui_graph=True` reemplaza el grafo in-memory de la pestaña. Si
|
||||||
|
hay trabajo sin guardar (título con `*`), respalda antes con
|
||||||
|
`comfyui_export_workflow_ui(api_format=True, save_path=...)` y restáuralo después con
|
||||||
|
`comfyui_load_workflow_ui`.
|
||||||
|
|
||||||
|
## Cosecha Civitai → skill candidata
|
||||||
|
|
||||||
|
El registry crece también captando recetas que **ya existen en internet**, no solo escribiéndolas a
|
||||||
|
mano: doctrina del issue 0087 aplicada a la captación de assets. Cada imagen publicada en Civitai
|
||||||
|
suele llevar su workflow de ComfyUI embebido en el PNG (chunk `prompt`, API format); cosecharla
|
||||||
|
destila la receta entera (checkpoint + LoRAs + params + prompt) en una **skill candidata** lista para
|
||||||
|
juzgar.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions", "pipelines"))
|
||||||
|
from comfyui_harvest_civitai_skill_oneshot import comfyui_harvest_civitai_skill_oneshot
|
||||||
|
|
||||||
|
res = comfyui_harvest_civitai_skill_oneshot(
|
||||||
|
query="cinematic portrait", nsfw="None",
|
||||||
|
dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"),
|
||||||
|
)
|
||||||
|
print(res["ok"], res["slug"], "workflow_embebido=", res["has_workflow"])
|
||||||
|
print("guardada en:", res["skill_path"]) # ~/ComfyUI/skills_library/<slug>/recipe.json
|
||||||
|
print("modelos ausentes:", res["missing_models"]) # checkpoints/LoRAs a bajar (NO bajados)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dos piezas, una composición:
|
||||||
|
|
||||||
|
- **`comfyui_extract_recipe_from_png`** (paso puro de destilación, impura solo por leer disco) —
|
||||||
|
toma un PNG ya descargado y produce la receta candidata. Compone
|
||||||
|
`comfyui_import_workflow_png` (workflow embebido) + `comfyui_read_png_metadata` (params del
|
||||||
|
KSampler), con fallback a la `meta` de generación de Civitai y, para samplers no-KSampler
|
||||||
|
(flux/`SamplerCustomAdvanced`), heurística sobre los nodos `CLIPTextEncode`. **Degradación honesta**:
|
||||||
|
si no hay ni workflow embebido ni meta utilizable devuelve `ok=False` sin inventar la receta.
|
||||||
|
- **`comfyui_harvest_civitai_skill_oneshot`** (pipeline) — encadena
|
||||||
|
`search_civitai_images → fetch_civitai_image → extract_recipe_from_png → save_skill` en una sola
|
||||||
|
llamada. Itera los resultados del search hasta encontrar el primero con receta destilable
|
||||||
|
(descartando los PNG sin workflow), y si filtró por un modelo concreto y ninguno trae grafo, hace
|
||||||
|
un 2º pase al feed global "Most Reactions" (donde abundan los workflows ComfyUI de usuarios flux).
|
||||||
|
|
||||||
|
Notas de uso:
|
||||||
|
|
||||||
|
- **La skill nace CANDIDATA** (`score_n=0`, `provenance.source='civitai'`): no está validada. El
|
||||||
|
prompt cosechado es **concreto**, no un scaffold con `{subject}` — sustitúyelo a mano si quieres
|
||||||
|
reutilizar la skill para otros sujetos. La validación la da el bucle
|
||||||
|
`generate_with_skill_oneshot` (juzga) + `comfyui_bump_skill_version` (promueve si mejora).
|
||||||
|
- **No baja modelos a ciegas**: si la receta referencia un checkpoint o LoRA que no está en
|
||||||
|
`<comfyui_dir>/models/`, lo lista en `missing_models` y no descarga nada. Bajarlos
|
||||||
|
(`comfyui_search_civitai_models` + `comfyui_download_model`) es una decisión aparte del caller.
|
||||||
|
- **NSFW segregado**: el PNG se descarga a `<dest_dir>/nsfw/` si el item es NSFW (permitido pero
|
||||||
|
siempre separado). El `dest_dir` vive fuera del repo (`~/ComfyUI/`) y se trata como datos: no se
|
||||||
|
commitea ni se indexa.
|
||||||
|
- El token Civitai es secreto: viene de `pass civitai/api-token`, nunca hardcodeado.
|
||||||
|
|
||||||
|
## Mezclar capacidades (mixer)
|
||||||
|
|
||||||
|
Una skill fija *una* receta. El **mixer** resuelve el otro eje: combinar **a la carta** todas las
|
||||||
|
capacidades de generación sobre un mismo workflow base y activar/desactivar cada una para iterar.
|
||||||
|
Misma doctrina del issue 0087 (componer piezas probadas, no reescribir el grafo), pero aplicada a
|
||||||
|
mezclar capacidades en vez de a guardar una receta.
|
||||||
|
|
||||||
|
Dos funciones:
|
||||||
|
|
||||||
|
| ID | firma corta | qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `comfyui_compose_capabilities_py_ml` | `compose_capabilities(base, *, loras, controlnet, ipadapter, hires, facedetailer) -> dict` | **PURA.** Aplica EN ORDEN las capacidades activadas (cada arg `None` = desactivada) sobre un dict base, componiendo los inyectores/builders encadenables. Reconecta MODEL/CLIP/positive/IMAGE. Sin ninguna = base intacto. |
|
||||||
|
| `comfyui_generate_mixed_oneshot_py_pipelines` | `generate_mixed_oneshot(base, subject, *, capabilities, server, judge, ...) -> dict` | **Pipeline.** base (skill slug / `'txt2img'` / dict) → compose → submit → wait → fetch → (si `judge`) juzga. Devuelve `{ok, prompt_id, image_path, capabilities_active, judge, error}`. |
|
||||||
|
|
||||||
|
El mixer se apoya en los **inyectores encadenables-sobre-dict** (cada uno la versión componible de
|
||||||
|
su builder-desde-cero hermano):
|
||||||
|
|
||||||
|
| Capacidad | Inyector | Reconecta |
|
||||||
|
|---|---|---|
|
||||||
|
| LoRAs (N) | `comfyui_inject_multi_lora_py_ml` | cadena MODEL/CLIP tras el checkpoint |
|
||||||
|
| ControlNet | `comfyui_inject_controlnet_py_ml` | `KSampler.positive` ← `ControlNetApply` |
|
||||||
|
| IPAdapter (style/faceid) | `comfyui_inject_ipadapter_py_ml` | `KSampler.model` ← IPAdapter (tras las LoRAs) |
|
||||||
|
| hires/upscale | `comfyui_inject_hires_fix_py_ml` | `UltimateSDUpscale` tras el `VAEDecode` |
|
||||||
|
| FaceDetailer | `comfyui_build_facedetailer_workflow_py_ml` | regenera caras del `VAEDecode` |
|
||||||
|
|
||||||
|
Orden fijo: `loras → controlnet → ipadapter → facedetailer → hires`. El IPAdapter se aplica sobre
|
||||||
|
el MODEL ya modificado por los LoRAs (orden correcto). Tras FaceDetailer el mixer deja un único
|
||||||
|
`SaveImage` (el del detailer).
|
||||||
|
|
||||||
|
### Ejemplo canónico (≥3 capacidades, juzgado)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from pipelines.comfyui_generate_mixed_oneshot import comfyui_generate_mixed_oneshot
|
||||||
|
|
||||||
|
# txt2img dreamshaper + 2 LoRAs + FaceDetailer (3 capacidades). Activar/desactivar = cambiar args.
|
||||||
|
res = comfyui_generate_mixed_oneshot(
|
||||||
|
"txt2img",
|
||||||
|
"a heroic knight portrait, 3d render style, dramatic lighting, detailed face",
|
||||||
|
checkpoint="dreamshaper_8.safetensors",
|
||||||
|
capabilities={
|
||||||
|
"loras": [
|
||||||
|
{"name": "SD15_3d_render_redmond.safetensors", "strength_model": 0.9},
|
||||||
|
{"name": "SD15_detail_tweaker.safetensors", "strength_model": 0.5, "strength_clip": 0.5},
|
||||||
|
],
|
||||||
|
"facedetailer": {"denoise": 0.45},
|
||||||
|
# "ipadapter": {"ref_image": "face.png", "mode": "faceid"}, # se activa con solo añadirla
|
||||||
|
# "hires": {"upscale_by": 1.5},
|
||||||
|
},
|
||||||
|
dest="/tmp/comfy_mixed", seed=42, judge=True,
|
||||||
|
)
|
||||||
|
print(res["ok"], res["prompt_id"], res["capabilities_active"], res["judge"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Límite conocido (8GB / piezas actuales)
|
||||||
|
|
||||||
|
- **hires + facedetailer no encadenan**: ambos toman su imagen del `VAEDecode` del render base, así
|
||||||
|
que combinarlos deja a uno sin efecto sobre la salida final (con los dos activos, hires "gana" y
|
||||||
|
facedetailer queda sin consumidor). Usa uno U otro por workflow. El resto de combinaciones
|
||||||
|
(LoRAs + ControlNet + IPAdapter + uno de los dos post-procesos) encadenan limpio.
|
||||||
|
- **VRAM**: en 8GB lowvram con SD1.5 entran ~2-3 capacidades modestas (p.ej. 2 LoRAs + FaceDetailer
|
||||||
|
a 512px). Apilar IPAdapter FaceID + ControlNet + hires + facedetailer a la vez puede dar OOM —
|
||||||
|
baja resolución o reduce capacidades. `mixer` no valida VRAM; el OOM aflora en `wait`.
|
||||||
|
- **Incompatibilidad explícita, no silenciosa**: ControlNet sin `control_image` o IPAdapter sin
|
||||||
|
`ref_image` lanzan `ValueError` del inyector (no petan a medias). Las imágenes de control/referencia
|
||||||
|
deben estar en el `input/` del servidor antes de encolar.
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **No genera ni descarga modelos**: una skill referencia checkpoints/LoRAs por nombre; deben
|
||||||
|
estar ya instalados en ComfyUI (`comfyui_download_model`, otro flujo). `build_skill_workflow` es
|
||||||
|
puro y no valida contra el servidor — usa `comfyui_validate_workflow` antes de encolar si dudas.
|
||||||
|
- **`base_workflow` solo de texto**: `txt2img`, `flux`, `sdxl_refiner`. Las bases que parten de una
|
||||||
|
imagen (`img2img`, `inpaint`, `controlnet`) lanzan `SkillWorkflowError`; para esas, monta el
|
||||||
|
workflow con los builders del grupo `comfyui` directamente.
|
||||||
|
- **`blocks` soportados**: `facedetailer` y `hires_fix`. Otros post-procesos (IPAdapter,
|
||||||
|
multi-ControlNet) se añaden creando su función-inyector hermana y registrándola en el dispatcher
|
||||||
|
de `build_skill_workflow`.
|
||||||
|
- **El juicio (`comfyui-judge`) vive en su grupo**: este grupo lo *consume* (vía
|
||||||
|
`generate_with_skill_oneshot` con `judge=True`), pero el panel multi-juez —estético + CLIP +
|
||||||
|
LLM-vision— se documenta en [`comfyui-judge`](comfyui-judge.md). Aquí solo se acumula su `score`
|
||||||
|
en `score_mean` (`comfyui_update_skill_score`) y se usa como gate del bump.
|
||||||
|
- **El bump solo sube versiones, no genera ni juzga**: `comfyui_bump_skill_version` aplica el patch
|
||||||
|
y registra la mejora; generar la imagen y puntuarla es trabajo del pipeline + el panel-juez. Una
|
||||||
|
variante que no supera a la vigente se descarta sola (el gate la rechaza).
|
||||||
|
- **La librería es metadata local**: vive bajo `~/ComfyUI/skills_library` (no toca el venv ni los
|
||||||
|
modelos en disco). No tiene repo propio ni se indexa — es estado vivo, como un `operations.db`.
|
||||||
|
- **Las funciones impuras del grupo** (save/load/list, ask_llm_vision) no llevan unit tests por
|
||||||
|
diseño (I/O de disco / red); `build_skill_workflow` e `inject_hires_fix` son puras y sí tienen
|
||||||
|
tests de estructura offline (`python/functions/ml/tests/test_comfyui_build_skill_workflow.py`,
|
||||||
|
`test_comfyui_inject_hires_fix.py`).
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# ComfyUI Styles — presets y catálogo de estilo
|
||||||
|
|
||||||
|
Tag: `comfyui-styles` (+ `gamedev-2d` para los dos presets gamedev). Sub-grupo de
|
||||||
|
[`comfyui`](comfyui.md) que añade una **capa de estilo reutilizable** sobre los builders de
|
||||||
|
workflow: en vez de repetir a mano los mismos modificadores de cámara/iluminación/render en cada
|
||||||
|
prompt, el estilo se trata como un dato curado y reusable.
|
||||||
|
|
||||||
|
Dos vertientes complementarias:
|
||||||
|
|
||||||
|
- **Catálogo WAS** (`comfyui-styles`): ~190 estilos curados en el formato exacto del selector WAS de
|
||||||
|
ComfyUI (*Prompt Styles Selector* / *Prompt Multiple Styles Selector*), generación de estilos
|
||||||
|
nuevos por LLM, y fusión segura sobre el `styles.json` del usuario.
|
||||||
|
- **Style presets gamedev** (`gamedev-2d`): recetas que empaquetan como datos puros el *look* de un
|
||||||
|
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño, post-proceso) y se
|
||||||
|
traducen a los kwargs que consume un builder de sujeto del grupo [`gamedev-2d`](gamedev-2d.md).
|
||||||
|
|
||||||
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-styles"` (catálogo WAS) y
|
||||||
|
`mcp__registry__fn_search query="style preset" tag="gamedev-2d"` (presets gamedev).
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
### Catálogo WAS — dominio `ml` (tag `comfyui-styles`)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en el formato exacto `{nombre: {prompt, negative_prompt}}` que consume el selector WAS. Cada `prompt` son modificadores de estilo potentes (cámara, lente, iluminación, render engine, medio artístico, paleta, mood), no descripciones de escena. Filtra por `category`. **Pura**. |
|
||||||
|
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-20251001') -> dict` | Genera N estilos de una categoría temática usando `ask_llm` (grupo claude-direct, API directa, arranque 0), en el mismo formato `{nombre: {prompt, negative_prompt}}`. `avoid` evita duplicar nombres ya existentes. **Impura** (LLM). |
|
||||||
|
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=DEFAULT_STYLES_PATH, overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup por nombre) un dict de estilos sobre el `styles.json` del selector WAS de forma SEGURA y NO destructiva: preserva todos los existentes (ganan salvo `overwrite=True`), hace backup con timestamp antes de escribir. `dry_run=True` previsualiza sin tocar disco. **Impura** (I/O disco). |
|
||||||
|
|
||||||
|
### Style presets gamedev — dominio `ml` (tag `gamedev-2d`)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (`gameboy`, `ghibli`, `pixel-art-retro`) o el catálogo de nombres si `name=None`. Un preset empaqueta como DATOS puros el look de un juego entero: `subject_prefix`/`suffix`, `style`, `negative`, checkpoint recomendado, LoRA + strength, `size`, `transparent`, post-proceso. **Pura**. |
|
||||||
|
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev (de `get_gamedev_style_preset`) + un `subject` del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo y los kwargs comunes (`style`, `checkpoint`, `lora`, `lora_strength`, `negative`, resolución) listos para `**spread`. `style`/`negative` permiten override puntual. **Pura**. |
|
||||||
|
|
||||||
|
## Ejemplo canónico — generar un estilo, fusionarlo y aplicarlo
|
||||||
|
|
||||||
|
Dos flujos típicos: (1) ampliar el catálogo del selector WAS, y (2) usar un preset gamedev para
|
||||||
|
generar un asset con look consistente.
|
||||||
|
|
||||||
|
### A) Ampliar el catálogo WAS con estilos nuevos por LLM
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_generate_styles_llm import comfyui_generate_styles_llm
|
||||||
|
from ml.comfyui_append_styles import comfyui_append_styles
|
||||||
|
|
||||||
|
# 1. Pedir 6 estilos de una categoría. Devuelve el dict {nombre: {prompt, negative_prompt}}
|
||||||
|
# directo (best-effort: {} si el LLM falla).
|
||||||
|
nuevos = comfyui_generate_styles_llm("film noir cinematic", n=6, prefix="noir-")
|
||||||
|
|
||||||
|
# 2. Previsualizar la fusión (no escribe), luego aplicar con backup.
|
||||||
|
if nuevos:
|
||||||
|
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"]) # nº tras fusionar, sin tocar disco
|
||||||
|
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura
|
||||||
|
print(res["total_before"], "->", res["total_after"], "añadidos:", len(res["added"]))
|
||||||
|
```
|
||||||
|
|
||||||
|
### B) Aplicar un style preset gamedev a un sujeto
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||||
|
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||||
|
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||||
|
|
||||||
|
preset = comfyui_get_gamedev_style_preset("gameboy") # receta pura del look Game Boy
|
||||||
|
ap = comfyui_apply_style_preset(preset, "a wizard casting a spell")
|
||||||
|
# ap = {subject, builder_kwargs, size, transparent, post, ...} listo para un builder gamedev-2d:
|
||||||
|
wf = comfyui_build_enemy_creature_workflow(
|
||||||
|
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
El catálogo curado completo se consulta sin red (devuelve el dict plano directo):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
|
||||||
|
print(comfyui_curated_styles_catalog("__categories__")) # {'categories': {...}, 'total': 190}
|
||||||
|
todos = comfyui_curated_styles_catalog() # dict {nombre: {prompt, negative_prompt}}
|
||||||
|
print(len(todos), list(todos)[:5])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **No genera imágenes**: este sub-grupo produce y gestiona DATOS de estilo (dicts de prompt /
|
||||||
|
negative, presets). Generar el asset es trabajo de los builders del grupo [`comfyui`](comfyui.md)
|
||||||
|
y [`gamedev-2d`](gamedev-2d.md), o de los pipelines oneshot (p.ej.
|
||||||
|
`comfyui_generate_styled_asset_oneshot_py_pipelines`, que compone un preset + un builder + submit).
|
||||||
|
- **El catálogo WAS asume el custom node WAS instalado**: `append_styles` escribe sobre el
|
||||||
|
`styles.json` que lee el selector WAS en la UI. Sin ese node, el catálogo sigue siendo usable como
|
||||||
|
dict de modificadores, pero el selector no aparecerá en el grafo.
|
||||||
|
- **Los dos presets gamedev (`get`/`apply`) llevan tag `gamedev-2d`**, no `comfyui-styles`: son la
|
||||||
|
vía de estilo para los builders de assets de juego, no para el selector WAS genérico. Se listan
|
||||||
|
aquí por afinidad de capacidad (estilo reutilizable).
|
||||||
|
- **Formato exacto**: el dict de estilos es `{nombre: {prompt, negative_prompt}}`. Los prompts son
|
||||||
|
modificadores (cámara/lente/luz/render/medio/paleta/mood), no descripciones de escena — la escena
|
||||||
|
la pone el `subject` del usuario.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# ComfyUI — Generación de imágenes por API HTTP y por la UI (CDP)
|
||||||
|
|
||||||
|
Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
||||||
|
(motor de Stable Diffusion basado en grafos de nodos) de dos formas complementarias:
|
||||||
|
|
||||||
|
- **Por su API HTTP** (`/prompt`, `/history`, `/object_info`): construir un workflow en
|
||||||
|
"API format", encolarlo, esperar el resultado. Headless, scriptable, sin navegador.
|
||||||
|
- **Por su UI web vía CDP**: operar la pestaña de ComfyUI ya abierta en el navegador diario
|
||||||
|
(cargar un workflow en el grafo visual, editar widgets en vivo, encolar como si pulsaras
|
||||||
|
"Queue Prompt", exportar el grafo, refrescar combos). Lo que el usuario ve, el agente lo
|
||||||
|
toca. Todas las funciones de UI componen la primitiva de transport
|
||||||
|
[`cdp_eval_py_browser`](../../python/functions/browser/cdp_eval.md) — no reinventan CDP.
|
||||||
|
|
||||||
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
|
||||||
|
|
||||||
|
> **Tamaño del grupo (al 28/06/2026):** 126 funciones con tag `comfyui` (63 puras, 50 impuras,
|
||||||
|
> 13 pipelines). El grupo se reparte en sub-grupos con página madre propia:
|
||||||
|
> [`comfyui-skill`](comfyui-skill.md) (recetas de estilo versionadas),
|
||||||
|
> [`comfyui-styles`](comfyui-styles.md) (presets + catálogo de estilo para el selector WAS),
|
||||||
|
> [`comfyui-judge`](comfyui-judge.md) (panel de calidad) y
|
||||||
|
> [`gamedev-2d`](gamedev-2d.md) (assets 2D para Godot: 47 funciones, 45 de ellas también `comfyui`).
|
||||||
|
> Esta página documenta el **núcleo** (lifecycle del server, API HTTP, builders, I/O de workflows,
|
||||||
|
> imagen→3D, UI por CDP, audio, templates); los builders específicos de gamedev-2d viven en su
|
||||||
|
> propia página. El mapa cross-grupo de capacidades está en
|
||||||
|
> [comfyui-overview.md](comfyui-overview.md).
|
||||||
|
|
||||||
|
## Dos caminos, mismo motor
|
||||||
|
|
||||||
|
```
|
||||||
|
API HTTP (dominio ml) UI web vía CDP (dominio browser)
|
||||||
|
────────────────────── ───────────────────────────────
|
||||||
|
build_txt2img_workflow (dict API format) load_workflow_ui (dict -> grafo visual)
|
||||||
|
│ set_node_widget_ui (tuning en vivo)
|
||||||
|
▼ queue_prompt_ui (= botón Queue Prompt)
|
||||||
|
submit_workflow (POST /prompt -> id) export_workflow_ui (grafo -> dict API format)
|
||||||
|
▼ refresh_nodes_ui (recarga combos)
|
||||||
|
wait_result (poll /history -> PNG)
|
||||||
|
object_info (catálogo de nodos) download_model (dominio ml) -> baja checkpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` y consume
|
||||||
|
`submit_workflow`) es el puente entre ambos mundos: `load_workflow_ui` lo carga en la UI y
|
||||||
|
`export_workflow_ui` lo recupera de la UI, así que puedes mezclar libremente API y navegador.
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
### Lifecycle del server — dominio `infra`
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_ensure_server_py_infra](../../python/functions/infra/comfyui_ensure_server.md) | `ensure_server(*, port=8188, lowvram=None, health_timeout=60, comfyui_dir='~/ComfyUI', unit_name='comfyui', runner=None) -> dict` | Garantiza que ComfyUI corre como servicio **systemd-user resiliente y sano**: genera/instala el unit (`Restart=always`, `--lowvram` autodetectado en GPUs ≤ 8 GB), daemon-reload + enable + start, y verifica salud por GET `/system_stats`. Idempotente; migra limpio un ComfyUI lanzado a mano (SIGTERM, nunca SIGKILL). Solo stdlib, no lanza excepciones → dict de estado. Prerequisito de todas las funciones HTTP. Impura. |
|
||||||
|
|
||||||
|
### Por API HTTP — dominio `ml`
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
|
||||||
|
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, variant='schnell', width=1024, height=1024, steps=None, guidance=3.5, seed=0, unet_name=None, clip_l_name='clip_l.safetensors', t5xxl_name='t5xxl_fp8_e4m3fn_scaled.safetensors', vae_name='ae.safetensors', weight_dtype='default', sampler_name='euler', scheduler='simple', ...) -> dict` | Builder txt2img para **Flux** (`variant='schnell'` o `'dev'`): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → camino custom-advanced (RandomNoise + KSamplerSelect + BasicScheduler → BasicGuider → SamplerCustomAdvanced) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. `steps=None` autoselecciona por variante (~4 schnell); `unet_name=None` deduce el checkpoint de la variante; `weight_dtype='default'`. **Pura**. |
|
||||||
|
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
|
||||||
|
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
|
||||||
|
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
|
||||||
|
| [comfyui_download_model_py_ml](../../python/functions/ml/comfyui_download_model.md) | `download_model(url, dest_subdir='checkpoints', *, comfyui_dir, filename, token, overwrite, timeout_s) -> dict` | Descarga un checkpoint/LoRA/VAE a `models/<dest_subdir>/`. Soporta Civitai (token) y HuggingFace. Valida que no sea HTML de error ni `.safetensors` corrupto. Impura. |
|
||||||
|
| [comfyui_interrupt_queue_py_ml](../../python/functions/ml/comfyui_interrupt_queue.md) | `interrupt_queue(server='127.0.0.1:8188') -> dict` | Corta la generación en curso (POST `/interrupt`) y lee la cola (GET `/queue`) → `{ok, interrupted, queue_running, queue_pending, error}`. Freno de mano; degrada limpio en fallo de red. Impura. |
|
||||||
|
| [comfyui_batch_generate_py_ml](../../python/functions/ml/comfyui_batch_generate.md) | `batch_generate(workflow, *, seeds=None, server='127.0.0.1:8188') -> dict` | Encola N variantes (una por seed), parcheando el campo de semilla de los nodos sampler sin mutar el original → `{ok, prompt_ids, count, error}`. Re-roll en una llamada. Compone `submit_workflow`. Impura. |
|
||||||
|
| [comfyui_queue_manage_py_ml](../../python/functions/ml/comfyui_queue_manage.md) | `queue_manage(action, *, server='127.0.0.1:8188', prompt_id=None) -> dict` | API de cola completa que complementa a `interrupt_queue`: `action='status'` (GET `/queue`), `'clear'` (vacía pendientes), `'delete'` (borra un prompt, requiere `prompt_id`), `'history'` (cuenta `/history`) → `{ok, action, queue_running, queue_pending, history_count, error}`. Degrada limpio en fallo de red. Impura. |
|
||||||
|
| [comfyui_stream_progress_py_ml](../../python/functions/ml/comfyui_stream_progress.md) | `stream_progress(prompt_id, *, server='127.0.0.1:8188', client_id=None, timeout=300) -> dict` | Progreso en vivo por WebSocket `/ws` (alternativa a `wait_result`): cuenta pasos del sampler (`steps_seen`), último nodo, y detecta el fin → `{ok, completed, steps_seen, last_node, method, error}`. Para ver progreso comparte el `client_id` con el submit. Cae a polling si falta `websocket-client`. Impura. |
|
||||||
|
|
||||||
|
### Builders, validación e import — dominio `ml` (P0, issue 0064)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_img2img_workflow_py_ml](../../python/functions/ml/comfyui_build_img2img_workflow.md) | `build_img2img_workflow(ckpt_name, init_image, positive, negative='', *, denoise=0.6, steps, cfg, seed, ...) -> dict` | Builder img2img (Checkpoint + LoadImage → VAEEncode → KSampler con `denoise` → VAEDecode → SaveImage). **Pura**. |
|
||||||
|
| [comfyui_build_upscale_workflow_py_ml](../../python/functions/ml/comfyui_build_upscale_workflow.md) | `build_upscale_workflow(image, *, model_name='4x-UltraSharp.pth', method='model') -> dict` | Builder upscale: `method='model'` (ESRGAN: UpscaleModelLoader + ImageUpscaleWithModel) o `method='latent'` (ImageScaleBy x2 sin modelo). **Pura**. |
|
||||||
|
| [comfyui_inject_lora_py_ml](../../python/functions/ml/comfyui_inject_lora.md) | `inject_lora(workflow, lora_name, *, strength_model=1.0, strength_clip=1.0, model_node=None, clip_node=None) -> dict` | Inserta un LoraLoader en un workflow ya construido, reconectando model/clip de la fuente a sus consumidores. Encadenable. **Pura** (no muta la entrada). |
|
||||||
|
| [comfyui_validate_workflow_py_ml](../../python/functions/ml/comfyui_validate_workflow.md) | `validate_workflow(workflow, server='127.0.0.1:8188', timeout) -> dict` | Cruza class_type y nombres de modelo contra `/object_info`; devuelve `{valid, missing_nodes, missing_models}` ANTES de encolar. Compone `object_info`. Impura. |
|
||||||
|
| [comfyui_import_workflow_json_py_ml](../../python/functions/ml/comfyui_import_workflow_json.md) | `import_workflow_json(source, *, server, timeout) -> dict` | Lee un workflow JSON de URL o path local; normaliza UI graph → API format (widgets vía `object_info`); passthrough si ya es API. Impura. |
|
||||||
|
| [comfyui_import_workflow_png_py_ml](../../python/functions/ml/comfyui_import_workflow_png.md) | `import_workflow_png(png_path_or_url, *, timeout) -> dict` | Extrae el workflow embebido en los chunks `prompt` (API) / `workflow` (UI) de un PNG de ComfyUI (tEXt/zTXt/iTXt, stdlib). Path o URL. Impura. |
|
||||||
|
| [comfyui_download_workflow_py_ml](../../python/functions/ml/comfyui_download_workflow.md) | `download_workflow(source, dest=None, *, server, civitai_token, hf_token, timeout) -> dict` | **Dispatcher**: descarga un workflow de CUALQUIER fuente (Google Drive, GitHub, Civitai, HuggingFace, URL directa o path local) y lo normaliza a API format. Detecta el tipo por la URL y delega; tras bajar compone `import_workflow_json`/`import_workflow_png`. Catálogo de fuentes: `reports/0080`. Impura. |
|
||||||
|
| [comfyui_read_png_metadata_py_ml](../../python/functions/ml/comfyui_read_png_metadata.md) | `read_png_metadata(png_path) -> dict` | Lee los parámetros de generación (modelo, seed, steps, cfg, sampler, prompts) de un PNG generado por ComfyUI. Impura (I/O disco). |
|
||||||
|
| [comfyui_fetch_output_image_py_ml](../../python/functions/ml/comfyui_fetch_output_image.md) | `fetch_output_image(filename, *, subfolder='', type_='output', server, dest_dir='.', timeout) -> dict` | Descarga el PNG generado vía GET `/view` a disco local (`wait_result` solo da metadata). Impura. |
|
||||||
|
| [comfyui_fetch_output_video_py_ml](../../python/functions/ml/comfyui_fetch_output_video.md) | `fetch_output_video(prompt_id, *, server, dest=None, outputs=None, timeout) -> dict` | Localiza y descarga el output de **vídeo/animación** (`.mp4`/`.webp`/`.webm`/`.gif`) de `/history` vía GET `/view`. Cubre SaveAnimatedWEBP/SaveVideo (bajo `"images"`) y VHS_VideoCombine (bajo `"gifs"`). Hermana de `fetch_output_image`/`fetch_output_mesh`. Acepta `outputs=` de `wait_result` para evitar re-consultar `/history`. Impura. |
|
||||||
|
|
||||||
|
### Potencia y assets de internet — dominio `ml` (P1, issue 0064)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_inpaint_workflow_py_ml](../../python/functions/ml/comfyui_build_inpaint_workflow.md) | `build_inpaint_workflow(ckpt_name, image, mask, positive, negative='', *, denoise=1.0, steps, cfg, seed, ...) -> dict` | Builder inpaint: CheckpointLoaderSimple + LoadImage + LoadImageMask → VAEEncodeForInpaint → KSampler → VAEDecode → SaveImage. Regenera solo la zona enmascarada. **Pura**. |
|
||||||
|
| [comfyui_build_controlnet_workflow_py_ml](../../python/functions/ml/comfyui_build_controlnet_workflow.md) | `build_controlnet_workflow(ckpt_name, control_image, cn_name, positive, negative='', *, strength=1.0, steps, cfg, seed, width, height) -> dict` | Builder ControlNet: ControlNetLoader + ControlNetApply inyectan el mapa de control sobre el condicionamiento positivo. **Pura**. |
|
||||||
|
| [comfyui_build_sdxl_refiner_workflow_py_ml](../../python/functions/ml/comfyui_build_sdxl_refiner_workflow.md) | `build_sdxl_refiner_workflow(base_ckpt, refiner_ckpt, positive, negative='', *, base_steps=20, refiner_steps=5, cfg, seed, width=1024, height=1024) -> dict` | SDXL base+refiner: dos KSamplerAdvanced encadenados (base con `return_with_leftover_noise`, refiner termina). **Pura**. |
|
||||||
|
| [comfyui_search_civitai_models_py_ml](../../python/functions/ml/comfyui_search_civitai_models.md) | `search_civitai_models(query, *, types='Checkpoint', base_model=None, sort, limit=20, token=None) -> dict` | Busca modelos/LoRAs en la API pública de Civitai → `{ok, items:[{name, type, base_model, version_id, download_url, nsfw}], count, error}`. Sin token funciona. Impura. |
|
||||||
|
| [comfyui_install_custom_node_py_ml](../../python/functions/ml/comfyui_install_custom_node.md) | `install_custom_node(repo_url, *, comfyui_dir, pip_install=True, restart=False) -> dict` | git clone en `custom_nodes/` + pip/uv install de requirements en el venv de ComfyUI. NO reinicia el server (restart=False). Impura. |
|
||||||
|
| [comfyui_resolve_workflow_deps_py_ml](../../python/functions/ml/comfyui_resolve_workflow_deps.md) | `resolve_workflow_deps(workflow, server='127.0.0.1:8188') -> dict` | Para un workflow ajeno: valida y traduce lo que falta en acciones (`{missing_nodes, missing_models, suggestions}`). Compone `validate_workflow`. Impura. |
|
||||||
|
| [comfyui_run_foreign_workflow_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_run_foreign_workflow_oneshot.md) | `run_foreign_workflow_oneshot(source, *, server, dest=None, output_kind='auto', install_nodes=False, node_repos=None, wait_timeout, civitai_token, hf_token) -> dict` | **Pipeline** para ejecutar un workflow ComfyUI **ajeno** end-to-end en una llamada: import (cualquier fuente) → resolve deps → (instala solo nodos confiables opt-in) → validate → submit → wait → fetch (imagen/vídeo/malla). **Gate de seguridad**: si faltan deps NO encola y las reporta en `missing`; nunca descarga modelos a ciegas. Compone `download_workflow` + `resolve_workflow_deps` + `install_custom_node` + `submit`/`wait` + `fetch_output_image/video/mesh`. Promoción del roadmap 0064/0087. Impuro. |
|
||||||
|
| [comfyui_list_installed_models_py_ml](../../python/functions/ml/comfyui_list_installed_models.md) | `list_installed_models(folder=None, comfyui_dir='~/ComfyUI') -> dict` | Lista modelos por carpeta resolviendo la ruta real de `extra_model_paths.yaml` (`/mnt/2tb/comfyui_models/`) + la nativa. Escaneo de FS, no depende del server. Impura. |
|
||||||
|
|
||||||
|
### Cosecha de Civitai → skills candidatas — dominio `ml` + `pipelines` (issue 0087)
|
||||||
|
|
||||||
|
Cosechar de Civitai imágenes con su workflow+receta embebidos para clonar su calidad y alimentar
|
||||||
|
la librería de skills (grupo [`comfyui-skill`](comfyui-skill.md)). En vez de reconstruir a mano una
|
||||||
|
receta que ya existe en una imagen pública, se cosecha y se guarda como **candidata** (`score_n=0`,
|
||||||
|
`provenance.source='civitai'`) para que el bucle de juicio/bump la valide. Política: **NSFW
|
||||||
|
permitido pero SIEMPRE segregado** en carpeta marcada. **Gotcha clave**: la API de Civitai ya no
|
||||||
|
expone `meta` (viene `null`) — la receta real sale del **workflow ComfyUI embebido en el PNG**, no
|
||||||
|
de la meta inline.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_search_civitai_images_py_ml](../../python/functions/ml/comfyui_search_civitai_images.md) | `search_civitai_images(*, query=None, model_version_id=None, nsfw='None', sort='Most Reactions', limit=20, token=None) -> dict` | Busca imágenes en Civitai (GET /api/v1/images) → items con `url` (PNG con workflow embebido). El endpoint no admite query textual (HTTP 500): resuelve `query`→versión de modelo via `search_civitai_models`. Token de `pass civitai/api-token`. Reintenta 503. Impura. |
|
||||||
|
| [comfyui_fetch_civitai_image_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image.md) | `fetch_civitai_image(image_url, *, dest_dir, nsfw=False, nsfw_subdir='nsfw', token=None, prefer_original=True, timeout_s=120) -> dict` | Descarga el PNG original (reescribe `/width=N/`→`/original=true/` para conservar el workflow), **segregando NSFW** a `<dest_dir>/nsfw/`. Misma validación no-HTML que `download_model`; nombra por UUID. Impura. |
|
||||||
|
| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado en receta de skill candidata (schema `comfyui-skill`, `source='civitai'`, `score_n=0`). Compone `import_workflow_png` + `read_png_metadata` + fallback de prompts/ckpt para flux. Sin workflow → usa `civitai_meta` (degradación honesta). Impura. |
|
||||||
|
| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', comfyui_dir='~/ComfyUI', token=None, ...) -> dict` | **Pipeline** Civitai→skill candidata: search → fetch (segrega NSFW) → extract → save_skill. Itera items hasta uno con receta destilable (2º pase al feed global si filtró por modelo). **NO baja modelos a ciegas**: checkpoint/LoRA ausente → `missing_models`. Impuro. |
|
||||||
|
|
||||||
|
### Replicación desde un link de Civitai — dominio `ml` + `pipelines` (issue C5, report 0127)
|
||||||
|
|
||||||
|
"Te paso un link de Civitai: entra, observa cómo lo hicieron, y construye un workflow que lo
|
||||||
|
replique." Dado el id/URL de una imagen de Civitai → extrae la receta (prompt, modelo, sampler,
|
||||||
|
LoRAs) → reconstruye el workflow → lo genera y lo juzga. **Gotcha clave**: la API v1 `/images`
|
||||||
|
devuelve `meta=null`; la receta por id sale de los endpoints **tRPC** `image.getGenerationData` +
|
||||||
|
`image.get` (los que usa la web). Como casi nunca tendrás el checkpoint/LoRA exacto, se sustituye
|
||||||
|
por el más parecido **instalado** (misma familia) y lo ausente se reporta en `missing_models` (NUNCA
|
||||||
|
se descarga a ciegas). El parecido es aproximado cuando falta el modelo exacto — esperado. SFW
|
||||||
|
estricto: una imagen NSFW devuelve `ok=False` sin generar.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_fetch_civitai_image_meta_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image_meta.md) | `fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0) -> dict` | "Entra al link y observa": resuelve UNA imagen Civitai por id/URL vía tRPC `image.getGenerationData` + `image.get` → `{meta, resources, comfy_workflow, nsfw, ...}`. Donde `search_civitai_images` da `meta=null`, esta sí trae prompt/modelo/sampler. Impura. |
|
||||||
|
| [comfyui_map_a1111_params_py_ml](../../python/functions/ml/comfyui_map_a1111_params.md) | `map_a1111_params(meta, resources=None) -> dict` | **Pura**: traduce meta A1111/Civitai a params ComfyUI (sampler `DPM++ 2M Karras`→`dpmpp_2m`/`karras`, dims, seed), infiere familia (`sd15`/`sdxl`/`flux`) y extrae LoRAs (de resources y tags `<lora:..>` del prompt). |
|
||||||
|
| [comfyui_replicate_civitai_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_replicate_civitai_oneshot.md) | `replicate_civitai_oneshot(url_or_id, *, server, dest=None, judge=True, token=None, wait_timeout=600) -> dict` | **Pipeline** link Civitai→réplica: fetch_meta → map_a1111_params → workflow embebido tal cual O reconstruido (build_txt2img + inject_lora, **sustituye checkpoint ausente por el más parecido instalado**, omite LoRAs ausentes) → run_foreign_workflow_oneshot → judge_image. Acepta también `modelVersionId` o un workflow ajeno (PNG/.json/dict). Impuro. |
|
||||||
|
|
||||||
|
### Retoque pro y oneshot — dominio `ml` + `pipelines` (P0, lote report 0093)
|
||||||
|
|
||||||
|
Builders que envuelven custom-nodes "pro" ya instalados (Impact-Pack, UltimateSDUpscale) y la
|
||||||
|
promoción del flujo txt2img a una sola llamada. Los class_types se verificaron contra el
|
||||||
|
`/object_info` del server vivo (FaceDetailer, UltralyticsDetectorProvider, UltimateSDUpscale).
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_facedetailer_workflow_py_ml](../../python/functions/ml/comfyui_build_facedetailer_workflow.md) | `build_facedetailer_workflow(base_workflow_or_image, ckpt_name, positive, negative='', *, bbox_model='face_yolov8m.pt', denoise=0.5, ...) -> dict` | Builder **FaceDetailer** (Impact-Pack): detecta caras con `UltralyticsDetectorProvider` (YOLO bbox) y las regenera para recuperar detalle (el pain #1 de retratos). Acepta el nombre de una imagen en `input/` (str) o un workflow base (dict): toma la imagen del `VAEDecode` y reutiliza el `CheckpointLoaderSimple`. No usa SAM (no instalado). **Pura**. |
|
||||||
|
| [comfyui_build_hires_fix_workflow_py_ml](../../python/functions/ml/comfyui_build_hires_fix_workflow.md) | `build_hires_fix_workflow(ckpt_name, positive, negative='', *, first_pass=(768,768), upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Builder **hires fix** de 2 pasadas: genera base (KSampler) y la amplía re-difundiéndola por tiles con `UltimateSDUpscale` + Remacri (`denoise<1` = añade detalle real). Distinto de `build_upscale_workflow` (ESRGAN puro, sin re-difusión). **Pura**. |
|
||||||
|
| [comfyui_txt2img_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_txt2img_oneshot.md) | `txt2img_oneshot(prompt, *, ckpt='dreamshaper_8.safetensors', negative='', server, dest=None, wait_timeout, **gen) -> dict` | **Pipeline** texto → PNG en disco en una llamada: build_txt2img + submit + wait + fetch_output_image → `{ok, image_path, prompt_id, error}`. Promoción de la secuencia (issue 0087). Impuro. |
|
||||||
|
| [comfyui_build_grid_py_ml](../../python/functions/ml/comfyui_build_grid.md) | `build_grid(image_paths, *, cols=None, cell=512, out_path=None, labels=None) -> dict` | Monta un **grid / contact-sheet** PIL de N imágenes para comparar de un vistazo (p.ej. el output de `batch_generate` con varios seeds). Celdas que conservan aspect ratio, rejilla casi cuadrada por defecto, rótulos opcionales → `{ok, out_path, rows, cols, error}`. Post-proceso local de imagen (no toca el server). Impura (I/O disco, PIL). |
|
||||||
|
|
||||||
|
### Vídeo (txt2video) — dominio `ml` (tag `video-generation`)
|
||||||
|
|
||||||
|
ComfyUI ≥ 0.26.0 trae soporte nativo para **vídeo por difusión**. `build_video_workflow` cubre
|
||||||
|
los dos modelos que caben en 8 GB: **LTX-Video 2B v0.9.5** (`model='ltx'`, checkpoint todo-en-uno +
|
||||||
|
VAE temporal + scheduler propio — validado end-to-end en `reports/0084`, clip real de 65 frames,
|
||||||
|
pico ~7.7 GB) y **Wan2.1 T2V 1.3B** (`model='wan'`, diffusion + umt5 + vae aparte — plantilla nativa
|
||||||
|
canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. |
|
||||||
|
| [comfyui_build_img2vid_workflow_py_ml](../../python/functions/ml/comfyui_build_img2vid_workflow.md) | `build_img2vid_workflow(image, *, ckpt='svd.safetensors', width=1024, height=576, video_frames=14, motion_bucket_id=127, fps=6, augmentation_level=0.0, steps=20, cfg=2.5, min_cfg=1.0, seed=0, sampler_name='euler', scheduler='karras', filename_prefix='comfy_svd') -> dict` | Builder img2vid (Stable Video Diffusion): anima una imagen estática a clip corto. ImageOnlyCheckpointLoader(`svd.safetensors`, todo-en-uno) + LoadImage → SVD_img2vid_Conditioning → VideoLinearCFGGuidance → KSampler (denoise 1.0) → VAEDecode → SaveAnimatedWEBP. SVD no usa prompt de texto: condiciona por CLIP_VISION de la imagen; movimiento vía `motion_bucket_id`. **Pura**. |
|
||||||
|
|
||||||
|
### Audio (txt2audio, ACE-Step) — dominio `ml` (tag `audio-generation`)
|
||||||
|
|
||||||
|
ComfyUI ≥ 0.26.0 trae nodos de **audio nativos**. `build_audio_workflow` cubre **ACE-Step v1**
|
||||||
|
(`AUDIO_ace_step_v1_3.5b.safetensors`, Apache 2.0): música y SFX por texto, con `lyrics` opcional
|
||||||
|
para voz cantada. El resultado es un `.flac` vía `VAEDecodeAudio → SaveAudio`, que `fetch_output_audio`
|
||||||
|
localiza y baja a disco (los nodos de audio exponen su salida bajo la clave `"audio"` de `/history`,
|
||||||
|
no `"images"`).
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_audio_workflow_py_ml](../../python/functions/ml/comfyui_build_audio_workflow.md) | `build_audio_workflow(ckpt_name, prompt, *, lyrics='', seconds=10.0, seed=0, steps=50, cfg=5.0, sampler_name='euler', scheduler='simple', shift=5.0, lyrics_strength=1.0, filename_prefix='audio/comfy_audio') -> dict` | Builder **txt2audio (ACE-Step)** en API format: CheckpointLoaderSimple → TextEncodeAceStepAudio (tags=prompt + lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) → ModelSamplingSD3(shift) → KSampler → VAEDecodeAudio → SaveAudio(.flac). La guía va por `cfg`; `lyrics` opcional para voz cantada. **Pura**. |
|
||||||
|
| [comfyui_fetch_output_audio_py_ml](../../python/functions/ml/comfyui_fetch_output_audio.md) | `fetch_output_audio(prompt_id, *, server='127.0.0.1:8188', dest=None, outputs=None, timeout=120.0) -> dict` | Localiza y descarga el output de **audio** (`.flac`/`.wav`/`.mp3`/`.opus`/`.ogg`/`.m4a`) de `/history` vía GET `/view`. Cubre SaveAudio/SaveAudioMP3/Opus/Advanced (bajo la clave `"audio"`). Hermana de `fetch_output_image`/`video`/`mesh`. Acepta `outputs=` de `wait_result` para no re-consultar `/history`. Impura. |
|
||||||
|
|
||||||
|
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
|
||||||
|
|
||||||
|
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
|
||||||
|
reconstruye en una malla 3D GLB con un grafo de 9 nodos (`LoadImage → ImageOnlyCheckpointLoader
|
||||||
|
→ CLIPVisionEncode → Hunyuan3Dv2Conditioning → EmptyLatentHunyuan3Dv2 → KSampler →
|
||||||
|
VAEDecodeHunyuan3D → VoxelToMeshBasic → SaveGLB`). El checkpoint es self-contained (DiT de forma +
|
||||||
|
VAE 3D + encoder de imagen en un `.safetensors`). Salida **shape-only** (sin color/textura). Detalle
|
||||||
|
y benchmark en `reports/0069-2026-06-23-comfyui-img-to-3d.md`. Para mejorar la cara trasera/laterales,
|
||||||
|
genera vistas novel-view desde 1 imagen (`generate_views_from_image`: `zero123` azimuth o
|
||||||
|
`sv3d` orbit de 21 frames, ambos operativos en 8 GB — reports `0073`, `0128`); para VER el GLB
|
||||||
|
resultante interactivo dentro de un nodo de la UI, monta el visor `Load3D` (`build_view_3d_workflow`,
|
||||||
|
report `0079`).
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_image_to_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_image_to_3d_workflow.md) | `build_image_to_3d_workflow(image_name, ckpt_name='hunyuan3d-dit-v2-mini.safetensors', *, resolution, steps, cfg, seed, octree_resolution, num_chunks, threshold, ..., watertight=False) -> dict` | Builder del workflow imagen→3D de 9 nodos (Hunyuan3D-2 nativo) en API format. El SaveGLB produce un `.glb`. `watertight=True` usa `VoxelToMesh` (`algorithm='surface net'`) en vez de `VoxelToMeshBasic` → malla estanca de raíz (default conserva el comportamiento histórico). **Pura**. |
|
||||||
|
| [comfyui_generate_views_from_image_py_ml](../../python/functions/ml/comfyui_generate_views_from_image.md) | `generate_views_from_image(image_name, *, method='auto', server, azimuths=(90,180,270), elevation, video_frames=21, sv3d_width=576, sv3d_height=576, dest_dir, validate_only=False, ...) -> dict` | Sintetiza vistas novel-view desde 1 imagen con StableZero123/SV3D nativos, para alimentar el 3D multi-vista. **Ambos caminos operativos**: `method='zero123'` (azimuth → back/left/right) y `method='sv3d'` (`sv3d_p.safetensors`, orbit de N frames 360° → `frames` + cardinales mapeados; probado en 8 GB lowvram, 21f@576 ~75 s, peak ~5.7 GB, report 0128). **Honesta**: si el nodo+checkpoint no están, devuelve `ok=False` con la acción y NO encola. `validate_only=True` valida sin tocar GPU. Impura. |
|
||||||
|
| [comfyui_build_view_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_view_3d_workflow.md) | `build_view_3d_workflow(model_file, *, animation=False, width, height) -> dict` | Monta el visor 3D nativo `Load3D` (o `Load3DAdvanced` con `animation=True`) para VER un GLB/OBJ existente, orbitando con el ratón, sin ejecutar el grafo. `model_file` relativo a `input/3d/`. Cárgalo con `load_workflow_ui`. **Pura**. |
|
||||||
|
| [comfyui_fetch_output_mesh_py_ml](../../python/functions/ml/comfyui_fetch_output_mesh.md) | `fetch_output_mesh(prompt_id, *, server, dest=None, timeout) -> dict` | Localiza la malla en `/history/{prompt_id}` (el SaveGLB la expone bajo la clave `"3d"`, no `"images"`) y la baja via GET `/view` a disco. Hermana de `fetch_output_image`. Impura. |
|
||||||
|
| [comfyui_install_3d_model_py_ml](../../python/functions/ml/comfyui_install_3d_model.md) | `install_3d_model(variant='mini', *, hf_token=None, comfyui_dir) -> dict` | Instala el checkpoint Hunyuan3D-2 (mini/standard/mv) en `checkpoints/`. Cascada: ya-instalado → cache de HF → descarga. Resuelve la ruta real via `extra_model_paths.yaml`. Impura. |
|
||||||
|
| [comfyui_image_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_image_to_3d_oneshot.md) | `image_to_3d_oneshot(image_path, *, server, variant='mini', dest=None, wait_timeout, **gen) -> dict` | **Pipeline** imagen en disco → malla GLB en una llamada: upload + build + submit + wait + fetch. Promoción de la secuencia (issue 0087). Impuro. |
|
||||||
|
| [comfyui_text_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_text_to_3d_oneshot.md) | `text_to_3d_oneshot(prompt, *, server, ckpt_name='v1-5-pruned-emaonly.safetensors', negative='', textured=False, variant='mini', dest=None, ...) -> dict` | **Pipeline** prompt de texto → malla 3D GLB en una llamada: txt2img (SD) + fetch + upload + build 3D (nativo o `textured=True` multi-vista PBR) + submit + wait + fetch_mesh. Promoción de la secuencia texto→imagen→3D (issue 0087). Impuro. |
|
||||||
|
| [comfyui_build_textured_3d_multiview_workflow_py_ml](../../python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md) | `build_textured_3d_multiview_workflow(image_name, *, ckpt='hunyuan3d-dit-v2-mv.safetensors', views=6, octree=384, max_faces=50000, upscale_model='4x_foolhardy_Remacri.pth') -> dict` | Builder imagen→malla 3D **con textura PBR** vía el wrapper Hunyuan3DWrapper (kijai): 4/6 vistas + delight + sample multi-vista + upscale Remacri + bake sobre UV (19 nodos). Cobertura de atlas 32.93% (report 0082). **Pura**. En 8 GB ejecutar en 2 fases (shape→`/free`→paint). |
|
||||||
|
| [comfyui_simplify_mesh_py_ml](../../python/functions/ml/comfyui_simplify_mesh.md) | `simplify_mesh(in_path, *, target_faces=80000, weld=True, out_path=None) -> dict` | **Post-proceso**: decima un GLB/OBJ/PLY denso (suelda cube-soup + quadric edge collapse de pymeshlab), conservando vertex colors o textura+UV. 964k→80k caras, 34.7→1.43 MB medido (report 0090). `weld=True` es clave: sin él la cube-soup de `VoxelToMeshBasic` no decima. Impura (trimesh+pymeshlab+scipy). |
|
||||||
|
| [comfyui_make_watertight_py_ml](../../python/functions/ml/comfyui_make_watertight.md) | `make_watertight(in_path, *, method='voxel', pitch=None, out_path=None) -> dict` | **Post-proceso**: hace estanca una malla. `method='voxel'` (voxeliza+fill+marching cubes) garantiza `is_watertight=True` a costa de más caras y de descartar la apariencia; `method='repair'` (fill_holes+fix_normals) conserva detalle pero no garantiza estanqueidad. La vía de raíz es `VoxelToMesh surface net` (report 0088). Impura. |
|
||||||
|
| [comfyui_mesh_cleanup_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_mesh_cleanup_oneshot.md) | `mesh_cleanup_oneshot(in_path, *, target_faces=80000, watertight=True, method='repair', out_path=None) -> dict` | **Pipeline** de limpieza en una llamada: `simplify_mesh` → (si `watertight`) `make_watertight`. Capitaliza el "80k caras + estanco" del report 0088. `method='voxel'` garantiza estanqueidad; `method='repair'` conserva caras. Reporta `{in_faces, simplified_faces, final_faces, is_watertight}`. Impuro. |
|
||||||
|
|
||||||
|
### Por la UI web (CDP) — dominio `browser`
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_load_workflow_ui_py_browser](../../python/functions/browser/comfyui_load_workflow_ui.md) | `load_workflow_ui(workflow, *, port=9222, server_url_substr='8188', filename, timeout_s) -> dict` | Carga un workflow API format en el grafo visual (`app.loadApiJson`). Impura (CDP + muta UI). |
|
||||||
|
| [comfyui_set_node_widget_ui_py_browser](../../python/functions/browser/comfyui_set_node_widget_ui.md) | `set_node_widget_ui(node, widget_name, value, *, match='type', port, server_url_substr, timeout_s) -> dict` | Edita en vivo un widget de un nodo (texto del CLIPTextEncode, steps/seed/cfg del KSampler). Localiza por type/id/title. Impura. |
|
||||||
|
| [comfyui_queue_prompt_ui_py_browser](../../python/functions/browser/comfyui_queue_prompt_ui.md) | `queue_prompt_ui(*, port, server_url_substr, timeout_s) -> dict` | Encola el grafo actual (`app.queuePrompt(0)`), = botón "Queue Prompt". Impura (dispara GPU). |
|
||||||
|
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
|
||||||
|
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
|
||||||
|
|
||||||
|
### Templates oficiales — dominio `ml` (tag `templates`)
|
||||||
|
|
||||||
|
Los workflows del menú **"Browse Templates"** del frontend se distribuyen en el paquete pip
|
||||||
|
`comfyui-workflow-templates` (desde la 0.10.x un meta-paquete multi-bundle con API en
|
||||||
|
`comfyui_workflow_templates_core`). Estas dos funciones leen ese catálogo localizando el intérprete
|
||||||
|
de ComfyUI y usando su API oficial vía subprocess (el paquete vive en el venv de ComfyUI, no en el
|
||||||
|
del registry). Sirven para descubrir grafos oficiales y arrancar un workflow desde una plantilla
|
||||||
|
probada en vez de construirlo a mano. Si no hay un ComfyUI con el paquete, devuelven `ok=False` con
|
||||||
|
un error accionable, sin lanzar.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
|
||||||
|
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
|
||||||
|
|
||||||
|
### Estilos — presets y catálogo (sub-grupo `comfyui-styles`)
|
||||||
|
|
||||||
|
Capa de **estilo reutilizable** sobre los builders: un catálogo curado de ~190 modificadores de
|
||||||
|
estilo para el selector WAS (Prompt Styles Selector), generación de estilos por LLM, y *style
|
||||||
|
presets* gamedev (gameboy, ghibli, pixel-art-retro) que empaquetan como datos puros el look de un
|
||||||
|
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño). Página madre dedicada:
|
||||||
|
[comfyui-styles.md](comfyui-styles.md). Las 5 funciones:
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en formato `{nombre: {prompt, negative_prompt}}` para el selector WAS. Cada prompt son modificadores potentes (cámara, lente, iluminación, render, medio, paleta). **Pura**. |
|
||||||
|
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-...') -> dict` | Genera N estilos nuevos de una categoría temática vía `ask_llm` (grupo claude-direct), en el mismo formato del selector WAS. **Impura**. |
|
||||||
|
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=..., overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup) estilos nuevos sobre el `styles.json` del selector WAS de forma NO destructiva: preserva los existentes (salvo `overwrite`), backup con timestamp. **Impura**. |
|
||||||
|
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (gameboy, ghibli, pixel-art-retro) o el catálogo de nombres si `name=None`. Empaqueta el look como datos puros. **Pura**. |
|
||||||
|
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev + un subject del usuario a los kwargs que consume un builder de sujeto del grupo gamedev-2d (subject combinado + `**kwargs` listos para spread). **Pura**. |
|
||||||
|
|
||||||
|
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
|
||||||
|
|
||||||
|
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
|
||||||
|
prompt y los pasos en vivo, encolas y esperas el PNG. Requiere el server en `127.0.0.1:8188`
|
||||||
|
y la pestaña de ComfyUI abierta en un Chrome con `--remote-debugging-port=9222`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, time, glob
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||||
|
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
|
||||||
|
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
|
||||||
|
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
|
||||||
|
|
||||||
|
# 1. Construir (API format, función pura) con un prefijo de salida localizable.
|
||||||
|
prefix = f"demo_{int(time.time())}"
|
||||||
|
wf = comfyui_build_txt2img_workflow(
|
||||||
|
ckpt_name="dreamshaper_8.safetensors",
|
||||||
|
positive="placeholder",
|
||||||
|
steps=8, seed=111, filename_prefix=prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Cargar el grafo en la UI del navegador del usuario.
|
||||||
|
comfyui_load_workflow_ui(wf) # {'ok': True, 'loaded': True}
|
||||||
|
|
||||||
|
# 3. Tuning en vivo: prompt (widget de texto) + pasos (widget numérico).
|
||||||
|
comfyui_set_node_widget_ui("CLIPTextEncode", "text",
|
||||||
|
"a green glass bottle on a marble shelf", match="type")
|
||||||
|
comfyui_set_node_widget_ui("KSampler", "steps", 12, match="type")
|
||||||
|
|
||||||
|
# 4. Encolar (= pulsar "Queue Prompt") y localizar el PNG nuevo en output/.
|
||||||
|
comfyui_queue_prompt_ui() # {'ok': True, 'queued': True}
|
||||||
|
before = set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png")))
|
||||||
|
while True:
|
||||||
|
new = [p for p in set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png"))) - before
|
||||||
|
if prefix in os.path.basename(p)]
|
||||||
|
if new:
|
||||||
|
print("PNG generado:", new[0]); break
|
||||||
|
time.sleep(1.5)
|
||||||
|
```
|
||||||
|
|
||||||
|
Variante 100% headless (sin navegador): cambia los pasos 2-4 por
|
||||||
|
`comfyui_submit_workflow(wf)` → `comfyui_wait_result(prompt_id)`. Misma capacidad, sin UI.
|
||||||
|
|
||||||
|
## Ejemplo canónico imagen → 3D (Hunyuan3D-2 nativo)
|
||||||
|
|
||||||
|
Una imagen de un objeto → su malla GLB, en una sola llamada. Requiere el server en
|
||||||
|
`127.0.0.1:8188` y el checkpoint mini instalado (lo hace `install_3d_model` la primera vez,
|
||||||
|
reutilizando la cache de HF; ~60 s de GPU por reconstrucción en una RTX 3070).
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_install_3d_model import comfyui_install_3d_model
|
||||||
|
from pipelines.comfyui_image_to_3d_oneshot import comfyui_image_to_3d_oneshot
|
||||||
|
|
||||||
|
# 1. Asegurar el checkpoint (instantáneo si ya está; reused_cache=True).
|
||||||
|
comfyui_install_3d_model("mini")
|
||||||
|
|
||||||
|
# 2. Imagen en disco -> malla GLB en /tmp/meshes.
|
||||||
|
res = comfyui_image_to_3d_oneshot(
|
||||||
|
os.path.expanduser("~/ComfyUI/input/3d_src_robot_00001_.png"),
|
||||||
|
dest="/tmp/meshes", variant="mini", seed=42,
|
||||||
|
)
|
||||||
|
print(res["mesh_path"], res["faces"]) # /tmp/meshes/3d_mesh_00001_.glb 1668040
|
||||||
|
```
|
||||||
|
|
||||||
|
Para tunear nodo a nodo en vez del oneshot: `build_image_to_3d_workflow(image_name)` →
|
||||||
|
`submit_workflow` → `wait_result` → `fetch_output_mesh(prompt_id, dest=...)`.
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **No es un grupo de generación genérica de imágenes**: cubre ComfyUI concretamente (su API
|
||||||
|
y su frontend litegraph). Para otros backends (Automatic1111, diffusers) harían falta otras
|
||||||
|
funciones.
|
||||||
|
- **Los builders cubren txt2img, img2img, upscale (ESRGAN y hires-fix con re-difusión), LoRA
|
||||||
|
stacks, inpaint, ControlNet, SDXL refiner, FaceDetailer, vídeo (LTX/Wan) y 3D texturizado
|
||||||
|
multi-vista** (`build_txt2img_workflow`, `build_img2img_workflow`, `build_upscale_workflow`,
|
||||||
|
`build_hires_fix_workflow`, `inject_lora`, `build_inpaint_workflow`, `build_controlnet_workflow`,
|
||||||
|
`build_sdxl_refiner_workflow`, `build_facedetailer_workflow`, `build_video_workflow`,
|
||||||
|
`build_textured_3d_multiview_workflow`). Lo que aún NO tiene builder propio (IPAdapter,
|
||||||
|
multi-ControlNet avanzado) se monta en la UI a mano y se captura con `export_workflow_ui`, o se
|
||||||
|
importa de internet con `import_workflow_json`/`import_workflow_png`, se resuelven sus dependencias
|
||||||
|
con `resolve_workflow_deps` (instala nodos con `install_custom_node`, descubre modelos con
|
||||||
|
`search_civitai_models`) y se valida con `validate_workflow` antes de encolar.
|
||||||
|
- **Los 13 builders puros tienen tests de estructura** (`python/functions/ml/tests/test_comfyui_build_*.py`
|
||||||
|
+ `test_comfyui_inject_lora.py`): verifican los `class_type` esperados, que los parámetros se reflejan
|
||||||
|
en los nodos, la validez de las conexiones `[node_id, output_index]` y la pureza de `inject_lora`. Son
|
||||||
|
tests offline (no tocan GPU ni server); las funciones impuras del grupo (todo lo que habla con el server,
|
||||||
|
el navegador o Civitai/HuggingFace) no se cubren con unit tests por diseño — se validan con el server vivo.
|
||||||
|
- **Control de cola**: `interrupt_queue` corta la generación en curso + lee `/queue`; `batch_generate`
|
||||||
|
encola N variantes por seed (re-roll). No vacían la cola entera (eso es `POST /queue {"clear": true}`).
|
||||||
|
- **Las funciones `*_ui` requieren la pestaña abierta y el navegador con CDP** (puerto 9222 por
|
||||||
|
defecto). Sin target que matchee `server_url_substr`, devuelven `ok=False`. Para automatización
|
||||||
|
desatendida sin navegador, usa el camino API (`submit_workflow` + `wait_result`).
|
||||||
|
- **`download_model` no gestiona el catálogo del server**: tras bajar un modelo, llama
|
||||||
|
`refresh_nodes_ui` (o recarga la página) para que ComfyUI lo vea en los combos.
|
||||||
|
- **El camino imagen→3D nativo es shape-only**: los nodos nativos de Hunyuan3D-2
|
||||||
|
(`build_image_to_3d_workflow`, `fetch_output_mesh`, `install_3d_model`, `image_to_3d_oneshot`)
|
||||||
|
reconstruyen la FORMA, sin color ni textura horneada. Para **textura PBR** está
|
||||||
|
`build_textured_3d_multiview_workflow`, que usa el wrapper de kijai (requiere `custom_rasterizer`
|
||||||
|
CUDA + `ComfyUI_essentials` + el upscaler Remacri) y debe ejecutarse en 2 fases en 8 GB
|
||||||
|
(shape→`/free`→paint). Detalle y cobertura medida en `reports/0082`; shape-only y comparación vs la
|
||||||
|
app local en `reports/0069-2026-06-23-comfyui-img-to-3d.md`.
|
||||||
|
- **Estanqueidad de la malla**: el default de `build_image_to_3d_workflow` (`VoxelToMeshBasic`) da
|
||||||
|
malla NO estanca; con `watertight=True` (`VoxelToMesh surface-net`) sale estanca de raíz. Si ya
|
||||||
|
tienes el GLB en disco, `mesh_cleanup_oneshot` decima + cierra en una llamada (`method='voxel'`
|
||||||
|
garantiza `is_watertight=True`; `method='repair'` conserva caras sin garantía). Ver `reports/0088`.
|
||||||
|
- La primitiva de transport CDP es [`cdp_eval`](../../python/functions/browser/cdp_eval.md) (grupo
|
||||||
|
navegador): si necesitas leer/escribir algo del grafo que estas funciones no cubren, compón
|
||||||
|
`cdp_eval` directamente antes de inventar nada.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos).
|
Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos).
|
||||||
|
|
||||||
Orquestadores one-shot:
|
Orquestadores one-shot:
|
||||||
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON. Flags `run_models` (modelos baratos) y `run_llm` (interpretación LLM).
|
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON (+ PDF móvil con `emit_pdf`). Flags `run_models` (modelos baratos), `run_llm` (interpretación LLM), `run_series` (análisis de serie temporal por columna numérica) y `emit_pdf` (PDF vertical legible en móvil). Re-expresión sugerida por columna y avisos exploratorios se añaden siempre.
|
||||||
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
|
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
|
||||||
|
|
||||||
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
||||||
@@ -50,16 +50,32 @@ Orquestadores one-shot:
|
|||||||
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
|
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
|
||||||
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
|
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
|
||||||
|
|
||||||
|
### Series temporales (flag `run_series`)
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `adf_kpss_stationarity_py_datascience` | pure | Estacionariedad por consenso ADF + KPSS (hipótesis nulas opuestas) → veredicto `stationary`/`non_stationary`/`inconclusive` + aviso de correlación espuria. |
|
||||||
|
| `acf_pacf_py_datascience` | pure | ACF + PACF con bandas de confianza + lags significativos + Ljung-Box (¿ruido blanco?). Detecta autocorrelación que infla los p-valores OLS. |
|
||||||
|
| `stl_decompose_py_datascience` | pure | Descomposición STL (tendencia/estacional/resto) + fuerza de tendencia y estacional de Hyndman. Auto-infiere el periodo por autocorrelación. |
|
||||||
|
| `to_returns_py_datascience` | pure | Convierte una serie de niveles (precios) a retornos log/simples. Los niveles no son estacionarios; los retornos sí (unidad correcta para correlacionar/modelar). |
|
||||||
|
|
||||||
|
### Rigor y disciplina exploratoria
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `fdr_correction_py_datascience` | pure | Corrige p-valores por comparaciones múltiples (Benjamini-Hochberg FDR / Bonferroni FWER) → controla el data-mining bias. Ya integrada en `association_matrix`. |
|
||||||
|
| `suggest_reexpression_py_datascience` | pure | Escalera de potencias de Tukey: qué transformación (log/sqrt/Yeo-Johnson/...) simetriza mejor una columna numérica según su skew y dominio. No la ejecuta, la sugiere. |
|
||||||
|
| `exploratory_caveats_py_datascience` | pure | Genera las advertencias de que el EDA es exploratorio (correlación≠causalidad, overfitting in-sample, comparaciones múltiples, outliers, muestra pequeña, MNAR) según lo que el perfil realmente contiene. |
|
||||||
|
|
||||||
### Capa LLM y entrega
|
### Capa LLM y entrega
|
||||||
| ID | Pureza | Qué hace |
|
| ID | Pureza | Qué hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
|
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
|
||||||
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
|
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
|
||||||
|
| `render_eda_pdf_py_datascience` | impure | Renderiza el `TableProfile` a un PDF multipágina **vertical (A5), legible en móvil** (estilo Tufte: histogramas como small multiples, top-k, heatmap de asociación). 4ª salida del workflow, junto a JSON/Markdown/notebook. |
|
||||||
|
|
||||||
### Orquestadores (pipelines)
|
### Orquestadores (pipelines)
|
||||||
| ID | Qué hace |
|
| ID | Qué hace |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación + `run_models` + `run_llm`) → JSON + markdown. |
|
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación con FDR + `run_models` + `run_llm` + `run_series` + re-expresión + caveats) → JSON + markdown (+ PDF móvil con `emit_pdf`). |
|
||||||
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
||||||
|
|
||||||
## Contrato de datos
|
## Contrato de datos
|
||||||
@@ -68,15 +84,26 @@ Orquestadores one-shot:
|
|||||||
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||||
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
||||||
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||||
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}
|
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models,
|
||||||
|
series:{<col>:SeriesBlock}|None, # solo con run_series
|
||||||
|
caveats:{n, caveats:[{id,topic,message,reference}], note}} # siempre
|
||||||
|
|
||||||
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
||||||
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
||||||
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
||||||
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None}
|
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None,
|
||||||
|
reexpression:{recommended,ladder_power,reason,alternatives,skew}|None, # cols numéricas
|
||||||
|
series:SeriesBlock|None} # solo con run_series
|
||||||
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||||
|
|
||||||
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend}
|
SeriesBlock = {order_col, ordered, n, stationarity:{adf,kpss,verdict,warning},
|
||||||
|
acf_pacf:{acf,pacf,significant_acf_lags,ljung_box,is_autocorrelated},
|
||||||
|
stl:{period,trend_strength,seasonal_strength,...},
|
||||||
|
to_returns:{...}|absent, levels_suggested:bool}
|
||||||
|
|
||||||
|
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra,p_value,
|
||||||
|
p_value_adjusted,significant}], strong:[...], methods_legend,
|
||||||
|
multiple_testing:{method,alpha,n_tests,n_rejected}} # p-valores corregidos por FDR
|
||||||
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
||||||
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
||||||
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
||||||
@@ -91,11 +118,18 @@ import sys, os
|
|||||||
sys.path.insert(0, os.path.join("python", "functions"))
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
from pipelines.profile_table import profile_table
|
from pipelines.profile_table import profile_table
|
||||||
|
|
||||||
r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True)
|
r = profile_table(
|
||||||
|
"/ruta/datos.duckdb", "clientes",
|
||||||
|
run_models=True, run_llm=True, run_series=True, emit_pdf=True,
|
||||||
|
)
|
||||||
prof = r["profile"]
|
prof = r["profile"]
|
||||||
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
||||||
print(prof["correlations"]["strong"]) # pares correlacionados
|
print(r["pdf_path"]) # reports/eda_clientes_<ts>.pdf (móvil)
|
||||||
|
print(prof["correlations"]["strong"]) # pares fuertes Y significativos tras FDR
|
||||||
print(prof["models"]["kmeans"]["best_k"]) # segmentos
|
print(prof["models"]["kmeans"]["best_k"]) # segmentos
|
||||||
|
print(prof["series"]["precio"]["stationarity"]["verdict"]) # ¿serie estacionaria?
|
||||||
|
print(prof["columns"][0]["reexpression"]["recommended"]) # transformación sugerida
|
||||||
|
print(prof["caveats"]["caveats"][0]["message"]) # aviso exploratorio general
|
||||||
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
|
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,6 +155,9 @@ build_eda_notebook("/ruta/datos.duckdb", "clientes", "/tmp/eda.ipynb", run_model
|
|||||||
- **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
|
- **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
|
||||||
- **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
|
- **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
|
||||||
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
|
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
|
||||||
|
- **Series** (`run_series`) trata cada columna numérica como serie temporal: si hay una columna datetime se ordena por ella, si no por el orden físico de filas. Necesita ≥8 puntos válidos por columna; STL exige ≥2 periodos. La sugerencia de retornos (`to_returns`) solo aparece en columnas estrictamente positivas y no claramente estacionarias (series de niveles/precios).
|
||||||
|
- **PDF** (`emit_pdf`) genera un PDF A5 vertical legible en móvil junto al report markdown vía `render_eda_pdf` (matplotlib `PdfPages`, sin dependencias nuevas).
|
||||||
|
- **Correlaciones**: los p-valores de cada par se corrigen por comparaciones múltiples (FDR Benjamini-Hochberg) dentro de `association_matrix`; un par solo entra en `strong` si supera el umbral de magnitud Y es significativo tras la corrección.
|
||||||
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
||||||
|
|
||||||
## Estado
|
## Estado
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Capability group: `gamedev-2d` — assets 2D para Godot (generación + post-proceso + puente)
|
||||||
|
|
||||||
|
Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI**
|
||||||
|
(generación) y **Godot 4** (consumo). Tres capas:
|
||||||
|
|
||||||
|
1. **Builders de workflow 2D** (`gamedev-2d`, GPU): construyen el dict (API format)
|
||||||
|
de los workflows ComfyUI para pixel-art, tiles seamless, isométrico, sprites de
|
||||||
|
personaje y VFX en bucle. Son **puros** (no tocan GPU al construir); el coste GPU
|
||||||
|
está al enviar con `comfyui_submit_workflow`.
|
||||||
|
2. **Post-proceso determinista** (CPU): pixelizar, recortar a alpha.
|
||||||
|
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
|
||||||
|
con sus import settings.
|
||||||
|
|
||||||
|
Tag único del grupo: `gamedev-2d` — **47 funciones**: 36 builders de workflow (31 de
|
||||||
|
generación desde texto + 5 de transformación desde una imagen de entrada) + 11 de apoyo
|
||||||
|
(post-proceso, puente a Godot, style presets y pipelines one-shot). El tag plano `gamedev` quedó deprecado y unificado a
|
||||||
|
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
|
||||||
|
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
|
||||||
|
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
|
||||||
|
|
||||||
|
Documento hermano del grupo `comfyui` (generación genérica de imágenes/video/3D).
|
||||||
|
Diseño del puente: `docs/comfyui-godot-integration.md`. Planes origen: `reports/0135`
|
||||||
|
(pixelart), `reports/0139` (entornos/tiles/iso), `reports/0137` (personajes/sprites),
|
||||||
|
`reports/0140` (VFX), `reports/0143` (ronda 2b: builders), `reports/0147` (item icons),
|
||||||
|
`reports/0149` (parallax background).
|
||||||
|
|
||||||
|
## Builders de workflow 2D (`gamedev-2d`, puros — generación)
|
||||||
|
|
||||||
|
Construyen el dict API format listo para `comfyui_submit_workflow`. Cada uno compone
|
||||||
|
funciones existentes del registry (`comfyui_build_txt2img_workflow`, `comfyui_inject_*`,
|
||||||
|
`comfyui_build_ipadapter_workflow`) — no reinventan el grafo. class_types verificados
|
||||||
|
contra `/object_info` del server (8GB lowvram). Probados e2e en GPU: pixelart, seamless,
|
||||||
|
VFX (ver `reports/0143`).
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `comfyui_build_pixelart_workflow_py_ml` | `(positive, negative=…, *, ckpt_name="juggernaut_xl_v11…", pixel_lora="SDXL_pixel-art…", use_lcm=True, …) -> dict` | Fase 1 pixel-art: SDXL + LoRA SDXL_pixel-art (+ LCM 8 steps). El pixel-perfect es post (`comfyui_pixelize_image`). |
|
||||||
|
| `comfyui_build_seamless_tile_workflow_py_ml` | `(positive, negative="", *, tiling="enable", copy_model="Make a copy", circular_vae=True, material_lora=None, …) -> dict` | Textura tileable: `SeamlessTile` (Conv2d circular) + `CircularVAEDecode`. Coste VRAM ≈0. |
|
||||||
|
| `comfyui_build_isometric_workflow_py_ml` | `(positive, negative=…, *, iso_lora="SD15_isometric_game_assets…", grid_image=None, …) -> dict` | Asset iso 2:1: LoRA iso + ControlNet grid opcional. |
|
||||||
|
| `comfyui_build_sprite_sheet_workflow_py_ml` | `(subject, *, ref_image=None, pose_skeleton=None, char_lora=None, transparent=True, …) -> dict` | UN sprite de personaje: IPAdapter-FaceID + LoRA + ControlNet OpenPose (Advanced, end<1) + Rembg. Varias poses → sheet. SD1.5. |
|
||||||
|
| `comfyui_build_vfx_spritesheet_workflow_py_ml` | `(prompt, *, motion_model="mm_sd_v15_v2.ckpt", num_frames=16, closed_loop=True, lora=None, …) -> dict` | N frames AnimateDiff loop sobre negro (insumo de luma→alpha). 8GB: 16f@512² revienta, usar ≤8f@512² o bajar resolución. |
|
||||||
|
| `comfyui_build_item_icon_workflow_py_ml` | `(item, *, style="game icon, clean, centered", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN icono de item de inventario (espada/poción/anillo/libro/escudo): txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg (alpha). Set coherente = mismo style/checkpoint/lora por item. SD1.5. |
|
||||||
|
| `comfyui_build_portrait_avatar_workflow_py_ml` | `(character, *, style="character portrait", ref_face=None, checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN retrato/avatar de personaje (busto centrado, cara al espectador, fondo simple): txt2img + prompt scaffold de retrato + FaceDetailer (cara nítida) + LoRA estilo opcional; `ref_face` → IPAdapter-FaceID para rostro consistente entre retratos. Diálogo/perfil/selección. SD1.5. |
|
||||||
|
| `comfyui_build_emote_workflow_py_ml` | `(character, expression, *, ref_face=None, style="character portrait", checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN emote/expresión facial del MISMO personaje (alegre/triste/enfadado/sorprendido/neutral…) para diálogo, retratos reactivos o emotes de chat: txt2img + prompt scaffold de emote (`portrait of {character}, {expression} expression, emote, clean background`) + FaceDetailer (conserva la expresión); `ref_face` → IPAdapter-FaceID para que varíe SOLO la expresión y el rostro sea el mismo. UNA expresión por llamada; set = mismas claves variando `expression` → `comfyui_build_grid`. Probado e2e en GPU (`reports/0151`). SD1.5. |
|
||||||
|
| `comfyui_build_parallax_background_workflow_py_ml` | `(scene, *, style="game background, side-scroller…", layers=3, checkpoint="dreamshaper_8…", depth_node="DepthAnythingV2Preprocessor", width=1024, height=512, …) -> dict` | Fondo en capas para parallax 2.5D: genera el fondo apaisado (txt2img) + su depth map (`DepthAnythingV2Preprocessor` sobre el VAEDecode), dos SaveImage. El split en N bandas por profundidad es post (GAP: `split_parallax_layers`, aún no creada). Probado e2e en GPU (`reports/0149`). SD1.5. |
|
||||||
|
| `comfyui_build_normal_map_workflow_py_ml` | `(image, *, method="normal", strength=1.0, resolution=512, bg_threshold=0.1, filename_prefix="normal_map") -> dict` | Normal/depth map de un sprite existente para iluminación dinámica 2.5D (Godot CanvasItem `normal_map`, Unity sprite normal). `LoadImage → preprocesador controlnet_aux → SaveImage`. `method`: `normal` (default, `BAE-NormalMapPreprocessor`, normal canónico **azul/violeta** usable directo en motor), `normal_midas` (MiDaS, único con `strength`→`a`, paleta no canónica), `normal_dsine` (DSINE), `depth` (`DepthAnythingV2`, height en gris). `image` debe estar en `input/` de ComfyUI. Coste VRAM ≈0. Probado e2e en GPU (`reports/0150`). |
|
||||||
|
| `comfyui_build_ui_hud_workflow_py_ml` | `(element, *, ui_style="fantasy game UI", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN elemento de interfaz/HUD de juego (botón, marco/panel, barra de vida/maná/XP, icono de UI, cursor, viñeta de menú): txt2img cuadrado + prompt scaffold de UI (`{element}, {ui_style}, game UI element, centered, clean, plain background…`) + LoRA estilo opcional + Rembg (alpha). HUD coherente = mismo `ui_style`/`checkpoint`/`lora` por pieza, varía solo `element`. El texto/label lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU (`reports/0152`). SD1.5. |
|
||||||
|
| `comfyui_build_dialogue_box_workflow_py_ml` | `(box_style="fantasy RPG dialogue box", *, shape="rounded panel", checkpoint="dreamshaper_8…", width=768, height=256, transparent=True, seed=0, lora=None, …) -> dict` | EL contenedor de diálogo / bocadillo / panel de texto de juego (RPG, visual novel, aventura): marco **apaisado** (`width>height`, 768×256) con borde decorativo y un **interior plano/vacío** reservado para que el motor renderice el texto de la conversación encima → `{box_style}, {shape}, game UI dialogue box frame, ornate border, empty flat interior for text, plain background` + LoRA estilo opcional + Rembg (alpha). **DISTINTO de `ui_hud` (elementos sueltos: botón/barra/icono)**: esto es el panel-contenedor completo. `shape` (rounded panel/scroll parchment/stone tablet/speech bubble…) + set coherente = mismo `box_style`/`shape`/`checkpoint`/`lora`. El interior se mantiene liso (negativo rechaza `busy/decorated interior`); el texto lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `medieval fantasy dialogue box, wood and gold` 768×256 RGBA, panel madera+oro con interior plano y alpha (`reports/0171`). SD1.5. |
|
||||||
|
| `comfyui_build_status_effect_icon_workflow_py_ml` | `(effect, *, ui_style="game status icon, bold symbol, flat", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UN icono de estado / buff-debuff (veneno, quemadura, congelación, escudo, regeneración, aturdimiento, velocidad, sangrado, maldición): **símbolo compacto** que se superpone al HUD para indicar un efecto activo, optimizado para **legibilidad a tamaño reducido** (16-32 px) → `{effect} status effect icon, {ui_style}, simple bold symbol, centered, readable at small size, plain background…` + LoRA estilo opcional + Rembg (alpha). **`size` por defecto menor (256, no 512)** porque se muestra pequeño; el negativo rechaza `intricate details/complex/cluttered` para no perder legibilidad. **DISTINTO de `item_icon` (objeto de inventario) y `ui_hud` (chrome grande de interfaz)**: aquí es un símbolo de estado. Barra coherente = mismo `ui_style`/`checkpoint`/`lora`, varía solo `effect` (color habla del tipo). El texto/contador lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `poison` 256×256 RGBA, símbolo verde flat centrado (`reports/0162`). SD1.5. |
|
||||||
|
| `comfyui_build_skill_tree_node_workflow_py_ml` | `(skill, *, frame="hexagonal", state="unlocked", ui_style="fantasy skill tree node", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UN nodo de **árbol de habilidades / talentos** (RPG, ARPG, MOBA, roguelike): el icono de una `skill` **DENTRO de un marco** (`frame`: hexagonal/circular/diamond/shield) que la UI de progresión pinta en la rejilla, con variante de **estado** visual (`state`: `unlocked`=brillante/saturado, `locked`=gris/desaturado) → `{skill} skill icon inside a {frame} {ui_style} frame, {state} (…hint…), centered, plain background, game UI, skill tree talent node…` + LoRA estilo opcional + Rembg (alpha). El **marco** y el **estado** son la firma del asset. **DISTINTO de `item_icon` (objeto suelto sin marco), `status_effect_icon` (símbolo superpuesto sin marco) y `ui_hud` (chrome grande)**: aquí es el nodo enmarcado completo de la pantalla de talentos. Par de un mismo talento = mismo `skill`/`frame`/`ui_style`/`seed`, varía solo `state` (las dos caras de la rejilla). Árbol coherente = mismo `frame`/`ui_style`/`checkpoint`/`lora`, varía `skill`. El texto/coste lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `fireball` hexagonal unlocked 256×256 RGBA, nodo enmarcado brillante centrado (`reports/0173`). SD1.5. |
|
||||||
|
| `comfyui_build_achievement_badge_workflow_py_ml` | `(badge, *, tier="gold", style="game achievement badge, ornate", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UNA **insignia / medalla / logro** (achievement, recompensa, rango): un trofeo, una medalla con cinta, un escudo de logro o un badge de rango que el panel de logros pinta al desbloquear un hito, con **`tier` metálico** (`bronze`/`silver`/`gold`/`platinum`/`diamond`) que distingue el grado → `{badge} achievement badge, {tier} tier (…hint metálico…), {style}, medal with ribbon, centered, plain background, game UI reward, trophy emblem…` + LoRA estilo opcional + Rembg (alpha). El **tier metálico** y la forma de **medalla/trofeo con cinta** son la firma del asset. **DISTINTO de `item_icon` (objeto de inventario suelto, sin tier ni cinta), `status_effect_icon` (símbolo de estado superpuesto sin marco) y `skill_tree_node` (nodo enmarcado de la rejilla de talentos con estado unlocked/locked)**: aquí es la insignia de logro/recompensa del panel de achievements. Familia de un mismo logro = mismo `badge`/`style`/`seed`, varía solo `tier` (los grados); set coherente = mismo `style`/`checkpoint`/`lora`, varía `badge`. El nombre/descripción/fecha lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `dragon slayer` tier gold seed 77 256×256 RGBA, medalla circular dorada con emblema centrado y fondo recortado a alpha (esquina α=0, centro α=254; `prompt_id 8b8b7ede`, `reports/0175`). SD1.5. |
|
||||||
|
| `comfyui_build_card_art_workflow_py_ml` | `(subject, *, card_style="fantasy trading card art", checkpoint="juggernaut_xl_v11…", width=512, height=768, hires=True, seed=0, lora=None, …) -> dict` | LA ilustración central de UNA carta coleccionable (TCG): criatura/personaje/hechizo en formato **vertical** de carta (`width<height`, ~512×768), composición centrada + iluminación dramática (`{subject}, {card_style}, dramatic lighting, detailed illustration, centered composition, full art…`). `hires=True` → 2ª pasada de detalle (`comfyui_build_hires_fix_workflow`); si no, txt2img + LoRA estilo opcional. Genera SOLO la ilustración — el marco/título/stats los pone el motor/post (negativo rechaza `card frame/border/text/stats/UI`). Set coherente = mismo `card_style`/`checkpoint`/`lora`, varía solo `subject`. Probado e2e en GPU con SD1.5 (`reports/0153`); ⚠️ el path `hires=True` falla hoy por bug del builder `comfyui_build_hires_fix_workflow` (nodo `UltimateSDUpscale` pide `batch_size`) — usar `hires=False` hasta el fix. SD1.5/SDXL. |
|
||||||
|
| `comfyui_build_enemy_creature_workflow_py_ml` | `(creature, *, variant=None, style="game creature, full body", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN enemigo/criatura de juego (goblin, esqueleto, slime, dragón, boss, elemental): figura de **cuerpo entero** centrada, fondo limpio recortable a alpha (`{variant} {creature}, {style}, full body, centered, plain background, game asset…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). `variant` (ice/fire/elite/corrupted…) se antepone a la criatura para generar la familia del MISMO enemigo (misma `creature`/`seed`/`style`, varía solo `variant`); bestiario coherente = mismo `style`/`checkpoint`/`lora`, varía solo `creature`. El negativo empuja a UNA criatura entera sin recorte. Probado e2e en GPU con SD1.5 (`reports/0154`). SD1.5. |
|
||||||
|
| `comfyui_build_prop_object_workflow_py_ml` | `(prop, *, style="game prop, isometric or side view", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN prop/objeto de escenario (barril, cofre, antorcha, planta, mueble, roca, fuente, estatua): objeto inanimado aislado a **escala de escena y perspectiva de juego** (iso/lateral), centrado, fondo limpio recortable a alpha (`{prop}, {style}, game asset, single object, centered, plain background, scene prop, world object…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Objeto de MUNDO**, no icono plano de inventario (≠ `item_icon`, que es para una casilla de UI); este puebla el nivel. Atrezzo coherente = mismo `style`/`checkpoint`/`lora`, varía solo `prop`. El negativo excluye personas/criaturas (objeto inanimado). Probado e2e en GPU con SD1.5 (`reports/0155`). SD1.5. |
|
||||||
|
| `comfyui_build_vehicle_mount_workflow_py_ml` | `(vehicle, *, view="side", style="game vehicle", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN vehículo/montura que el personaje **USA o CONDUCE** (caballo, dragón-montura, nave espacial, coche, barco, carro, grifo, mecha): el vehículo **COMPLETO** en vista lateral o isométrica, centrado, fondo limpio recortable a alpha, **SIN jinete/conductor** (`{vehicle}, {view} view, {style}, full vehicle, centered, plain background, game asset, no rider, empty…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). Se genera VACÍO (el negativo rechaza `person/rider/driver/passenger`) para que el motor componga al personaje encima. **DISTINTO de `enemy_creature` (sujeto a COMBATIR) y `prop_object` (atrezzo inanimado que decora)**: aquí el objeto se MONTA/USA; una montura viva que se cabalga (caballo, dragón) entra aquí, no en `enemy_creature`. `view` (side/iso) fija la geometría del parque móvil; set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `vehicle`. Probado e2e en GPU con SD1.5 — `armored war horse with saddle` side 512×512 RGBA, vehículo centrado recortado a alpha (centroide 0.55/0.54, 4 esquinas transparentes, `reports/0169`). SD1.5. |
|
||||||
|
| `comfyui_build_topdown_sprite_workflow_py_ml` | `(subject, *, direction="south", style="top-down game sprite, RPG", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN sprite en **vista CENITAL (top-down)** estilo RPG clásico/roguelike (Zelda, juegos cenitales): personaje/objeto visto **desde arriba**, centrado, fondo limpio recortable a alpha (`{subject}, top-down view, overhead view, {direction} facing, {style}, centered, plain background, game asset…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). `direction` (south/north/east/west) para el sprite de movimiento: las 4 vistas del MISMO personaje = misma `subject`/`style`/`seed`, varía solo `direction` → montar con `comfyui_build_grid`. **DISTINTO de `sprite_sheet` (vista lateral/frontal de plataformas)**: el negativo por defecto rechaza side/front/3-4/isometric/perspective para forzar la cenital. Con SD1.5 sin LoRA sale picado alto; cenital estricto pide LoRA top-down + cfg alto. Probado e2e en GPU con SD1.5 (`reports/0156`). SD1.5. |
|
||||||
|
| `comfyui_build_splash_art_workflow_py_ml` | `(scene, *, mood="epic, cinematic", checkpoint="juggernaut_xl_v11…", width=1024, height=576, hires=True, seed=0, lora=None, …) -> dict` | LA ilustración grande de UN splash / pantalla de carga / key art en formato **pantalla apaisado 16:9** (`width>height`, ~1024×576), composición cinematográfica (`{scene}, {mood}, key art, game splash screen, dramatic lighting, cinematic composition, wide shot, epic scale, atmospheric…`). `hires=True` → 2ª pasada de detalle (`comfyui_build_hires_fix_workflow`) para verse a pantalla completa; si no, txt2img + LoRA estilo opcional. Genera SOLO la ilustración — el título/logo/barra de carga los pone el motor/post (negativo rechaza `text/title/logo/UI/frame/watermark`), dejando aire para superponer el título. Set coherente = mismo `mood`/`checkpoint`/`lora`, varía solo `scene`. Probado e2e en GPU con SD1.5 + hires (1024×576 → 1536×864, 54s, `reports/0159`). SD1.5/SDXL. |
|
||||||
|
| `comfyui_build_world_map_workflow_py_ml` | `(region, *, map_style="fantasy cartography, aged parchment", checkpoint="juggernaut_xl_v11…", width=768, height=768, hires=False, seed=0, lora=None, …) -> dict` | LA ilustración de la **pantalla de mapa** del juego: una lámina cartográfica en **vista cenital** de un continente/región/reino/mazmorra con aspecto de atlas fantasy (`map of {region}, {map_style}, top-down cartographic view, illustrated game world map, labeled regions, decorative border, compass rose, fantasy atlas, no people…`). **Cuadrado por defecto** (768×768; sube `width` para mundo apaisado, `height` para mazmorra en columna), `hires=False` por defecto (ponlo `True` para detalle fino de costas/relieve). Genera SOLO la ilustración — las marcas interactivas, los iconos pinchables, las rutas y el "estás aquí" los pone el motor SOBRE la lámina; la difusión dibuja labels/ornamentos **DECORATIVOS** pero NO garantiza ortografía ni posiciones usables como datos (el negativo rechaza `photo/3d render/perspective/character/person` para mantener la vista cenital plana). Atlas coherente = mismo `map_style`/`checkpoint`/`lora`, varía solo `region`. Probado e2e en GPU con SD1.5 — reino fantasy 768×768, lámina de pergamino con costas/montañas/regiones + borde ornamental + rosa de los vientos (`prompt_id bf4861fc`, `reports/0167`). SD1.5/SDXL. |
|
||||||
|
| `comfyui_build_decal_overlay_workflow_py_ml` | `(decal, *, on_black=True, style="grunge decal, high detail", checkpoint="dreamshaper_8…", size=512, seed=0, lora=None, …) -> dict` | UN decal/overlay con alpha para superponer sobre superficies/paredes/sprites con blend mode del motor (sangre, grietas, suciedad, óxido, quemaduras, salpicaduras, arañazos, musgo): textura **aislada sobre fondo PLANO** (`{decal}, {style}, single isolated decal, centered, on a solid pure black background, flat backdrop, sticker, no scenery, texture overlay, game asset…`) → txt2img cuadrado + LoRA estilo opcional. `on_black=True` (defecto) pensado para extraer alpha con **`comfyui_matting_luma_to_alpha`** (luma=alpha, conserva el falloff de translúcidos — la técnica gamedev correcta, ≠ recorte binario). **NO inyecta Rembg** (el matting es luma→alpha de disco, no un nodo): el SaveImage sale directo del VAEDecode. Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `decal`/`seed`. ⚠️ "grunge" en `style` arrastra fondo gris en SD1.5 → para fondo negro plano usar un `style` sin connotación de fondo + reroll de `seed`; luma Rec601 penaliza el rojo → para sangre roja pasar `luma_weights` con más peso al rojo. Probado e2e en GPU con SD1.5 (`reports/0160`). SD1.5. |
|
||||||
|
| `comfyui_build_projectile_workflow_py_ml` | `(projectile, *, direction="right", glow=False, style="game projectile, side view", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN proyectil orientado (flecha, bala, bola de fuego, rayo, misil, hechizo): sprite pequeño con **orientación** (apunta a la derecha por defecto, ángulo 0 — el motor rota el sprite), aislado, listo para instanciar. **`glow` elige el camino a alpha**: `glow=False` (defecto) = proyectil SÓLIDO con silueta → `plain background` + **Rembg** (alpha por recorte, como `item_icon`/`topdown_sprite`); `glow=True` = brillante/mágico → `glowing, on black background` **sin Rembg** (recortaría el halo), insumo de **`comfyui_matting_luma_to_alpha`** que el caller aplica luego (como `vfx_spritesheet`/`decal_overlay`). `glow=True` ignora `transparent`/`rembg_model`; el negativo por defecto NO rechaza "black background". `direction` se inserta como `pointing {direction}` (`""`/None = sin orientación). Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `projectile`/`seed`. Probado e2e en GPU con SD1.5 — fireball glow sobre negro + luma→alpha RGBA (`reports/0161`). SD1.5. |
|
||||||
|
| `comfyui_build_structure_workflow_py_ml` | `(structure, *, view="isometric", style="game building", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN edificio/estructura de escenario (casa, torre, castillo, tienda, posada, ruina, muralla, puente, templo, faro): UN **building COMPLETO** y centrado a perspectiva de juego (`{view} view`, iso por defecto), fondo limpio recortable a alpha (`{structure}, {view} view, {style}, full building, complete structure, single building, centered, plain background, game asset, architecture…`) → txt2img cuadrado + LoRA estilo/iso opcional + Rembg (alpha). **EDIFICACIÓN grande que ocupa varios tiles y define el escenario**, no un objeto pequeño suelto (≠ `prop_object`, que es atrezzo que se deja sobre un tile); el negativo rechaza `small object / single item / prop / furniture`. `view` fija la perspectiva del mapa (iso/side/front/top-down/¾); LoRA iso fija mejor el ángulo 2:1. Set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `structure`. Probado e2e en GPU con SD1.5 — `medieval blacksmith shop` iso 512×512 RGBA, edificio centrado recortado a alpha (centroide 0.54/0.53, `reports/0164`). SD1.5. |
|
||||||
|
| `comfyui_build_foliage_set_workflow_py_ml` | `(plant, *, view="side", style="game foliage, stylized", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN elemento de **vegetación/foliage** de escenario (árbol, arbusto, hierba alta, flores, helecho, hongo, cactus, tronco caído, juncos, hiedra): UN elemento de **naturaleza ORGÁNICA AISLADO** y centrado a perspectiva de juego (`{view} view`, `side` por defecto), fondo limpio recortable a alpha (`{plant}, {view} view, {style}, single plant element, centered, plain background, game nature asset, natural vegetation, organic, isolated plant…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Vegetación que viste el terreno**, distinta del **objeto MANUFACTURADO** suelto (≠ `prop_object`: barril/cofre/mueble) y del **EDIFICIO** (≠ `structure`: casa/torre); el negativo rechaza `building / manmade object / barrel / furniture / person` y `multiple plants / dense forest / jungle / landscape` (UN elemento, no un bosque) + `pot / planter / vase` (planta en maceta = `prop_object`). Recorte por **Rembg** (planta opaca de silueta definida), no luma→alpha. Set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `plant`. ⚠️ **dos gotchas reales SD1.5+Rembg**: (1) **plantas grandes (árbol) tienden a PAISAJE** (cielo+campo) en lugar de fondo plano → re-roll de seeds buscando fondo uniforme (`comfyui_batch_generate`); (2) **follaje verde claro sobre fondo claro → Rembg se come las hojas** y deja solo tronco/ramas → preferir elementos de **silueta compacta y color saturado** (hongo, arbusto denso) o `transparent=False` + matting manual. Probado e2e en GPU con SD1.5 — golden `a glowing mushroom` seed 7 512×512 RGBA, hongo centrado recortado a alpha limpio (centroide 0.51/0.58, opaco 19%, `prompt_id 8fb65a51`); evidencia del gotcha del roble en `reports/0170`. SD1.5. |
|
||||||
|
| `comfyui_build_trap_hazard_workflow_py_ml` | `(hazard, *, view="side", style="game hazard trap", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UNA **trampa/peligro JUGABLE** de nivel (pinchos del suelo, sierra giratoria, foso de lava, placa de presión, columna de llamas, trampa de flechas, charco ácido, descarga eléctrica, prensa, estaca cayendo): UN objeto de **peligro AISLADO** y centrado a perspectiva de juego (`{view} view`, `side` por defecto), fondo limpio recortable a alpha (`{hazard}, {view} view, {style}, single hazard object, trap, dangerous, centered, plain background, game asset, high detail`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Peligro al que el motor asigna hitbox de daño + estado activo/inactivo**, distinto del **objeto INERTE de decoración** (≠ `prop_object`: barril/cofre que solo ambienta) y del **enemigo VIVO** (≠ `enemy_creature`); el negativo rechaza `character / person / creature / multiple objects` para que salga el mecanismo, no un enemigo ni una escena. Recorte por **Rembg** (trampa sólida de silueta definida: pinchos/sierra/placa); ⚠️ para hazards **puramente etéreos** (columna de llamas, arco eléctrico, gas) usar `transparent=False` + `comfyui_matting_luma_to_alpha` (conserva el falloff translúcido para blend aditivo), no Rembg. `view` fija la perspectiva del nivel (side/top-down/iso); set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `hazard`. Probado e2e en GPU con SD1.5 — `spiked floor trap` side seed 7 512×512 RGBA, mecanismo de peligro centrado recortado a alpha (alpha extrema 0–255, fondo transparente real, `prompt_id ab1b1560`, `reports/0174`). SD1.5. |
|
||||||
|
| `comfyui_build_particle_texture_workflow_py_ml` | `(particle, *, soft=True, style="particle texture, soft glow", checkpoint="dreamshaper_8…", size=256, seed=0, lora=None, …) -> dict` | UNA textura de **partícula individual** reutilizable (chispa, humo, polvo, destello/flare, gota, copo, hoja, círculo de energía) — el "ladrillo" que el sistema de partículas del motor (Godot `GPUParticles2D`, Unity VFX Graph) instancia a **miles** y anima (spawn/fade/color over lifetime). Aislada y centrada **sobre fondo NEGRO** (`{particle} particle, {style}, isolated on pure black background, <soft|sharp> edges, single element, for game particle system…`) → txt2img cuadrado + LoRA estilo opcional. **`soft` controla el borde**: `soft=True` (defecto) → `soft glow, feathered edges` (humo/destello/gota); `soft=False` → `crisp sharp edges, high contrast` (chispa/copo/hoja). **NO inyecta Rembg** (rompería el falloff translúcido): insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, additive blend en el motor). **`size` por defecto pequeño (256)** porque se replica a miles. **DISTINTO de `vfx_spritesheet`** (ese es la SECUENCIA animada de un efecto; esto es UNA textura estática reutilizable) **y de `decal_overlay`** (ése es una mancha de desgaste estática para superponer; éste es un emisor de partículas). ⚠️ el `style` por defecto trae "soft glow" → si pides `soft=False` para algo nítido, usa un `style` sin connotación suave. Probado e2e en GPU con SD1.5 — `spark` 256×256 sobre negro plano (dark 85%) + luma→alpha RGBA con falloff preservado (`reports/0163`). SD1.5. |
|
||||||
|
| `comfyui_build_weather_overlay_workflow_py_ml` | `(weather, *, on_black=True, style="weather overlay, atmospheric", checkpoint="dreamshaper_8…", width=1024, height=576, seed=0, lora=None, …) -> dict` | UNA **capa de clima/atmósfera a PANTALLA COMPLETA** que cubre toda la vista del jugador y se superpone sobre la escena con blend del motor (lluvia, niebla, nieve, rayos de sol/god rays, polvo, viñeta de tormenta): cobertura **uniforme de borde a borde**, generada **APAISADA a resolución de pantalla** (16:9, 1024×576 por defecto — `width>height`, NO cuadrado) (`{weather} overlay, {style}, full screen atmospheric layer, <particles/streaks on pure black background \| translucent layer>, seamless full screen coverage, edge to edge, game VFX…`) → txt2img apaisado + LoRA estilo opcional. **`on_black` elige el modo de blend**: `on_black=True` (defecto) = clima BRILLANTE sobre **NEGRO puro** (estrías de lluvia, copos, haces de luz, motas), **sin Rembg**, insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, **blend aditivo/screen** — el negro desaparece, el clima brilla sobre la escena); `on_black=False` = **película TRANSLÚCIDA** semi-transparente (niebla densa, tinte de tormenta) para blend multiply/overlay o alpha global. El negativo rechaza `solid object/single subject/character/building/landscape scene/horizon line/frame` (cobertura total, NO un sujeto centrado) + (si `on_black`) `blue sky/gray/white background` para forzar negro plano. **DISTINTO de `decal_overlay`** (ése es una mancha LOCALIZADA que se pega en un punto de una superficie) **y de `vfx_spritesheet`** (ése es la SECUENCIA animada de UN efecto puntual): la capa de clima es UNA película estática de cobertura full-screen que el motor anima por scroll/loop/shader. Set coherente = mismo `style`/`checkpoint`/`lora`, varía `weather`/`seed`. ⚠️ algunos climas (lluvia/niebla) pintan cielo azul de fondo en SD1.5 aunque pidas negro → subir `cfg` + re-roll de `seed`; climas brillantes-sobre-negro (god rays, snow, sparks) salen más limpios que los difusos (fog). luma Rec601 penaliza el azul → para lluvia azulada ajustar `luma_weights`/`gamma` en el matting. Probado e2e en GPU con SD1.5 — `heavy rain` on_black seed 11 **1024×576** (16:9 exacto), estrías de lluvia brillantes sobre **negro plano** (esquinas luma 0.00, dark 89.6%, lluvia 1.4% brillante) apto luma→alpha aditivo (`prompt_id 5d2300d1`, `reports/0176`). SD1.5/SDXL. |
|
||||||
|
| `comfyui_build_rune_glyph_workflow_py_ml` | `(glyph, *, glow=True, style="arcane glowing rune", checkpoint="dreamshaper_8…", size=512, seed=0, lora=None, …) -> dict` | UNA **runa / glifo / sigilo mágico** (glifos rúnicos, círculos mágicos, sigilos de invocación, inscripciones brillantes) para hechizos, portales, marcas de conjuro y efectos de magia: símbolo arcano **aislado** sobre fondo uniforme (`{glyph}, {style}, magic symbol, single isolated glyph, centered, glowing on a solid pure black background, occult sigil, arcane inscription, no scenery, game asset…`) → txt2img cuadrado + LoRA estilo opcional. **`glow` elige el camino a alpha**: `glow=True` (defecto) = runa BRILLANTE sobre **NEGRO puro**, **sin Rembg** (recortaría el halo del resplandor), insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, **blend aditivo** en el motor — conserva el glow); `glow=False` = runa MATE/grabada sobre fondo plano (el negativo rechaza `glow/neon/bloom`), recorte/inversión por el caller. El negativo rechaza `realistic text/readable words/latin alphabet` (un glifo arcano, **no letras reales**) + fondo texturizado/niebla. **DISTINTO de `status_effect_icon`** (símbolo SÓLIDO de UI, recorte Rembg, legible a 16-32 px en el HUD): la runa es una marca translúcida que **emite luz** e se inscribe en el mundo. Grimorio coherente = mismo `style`/`checkpoint`/`lora`, varía `glyph`/`seed`. ⚠️ luma Rec601 penaliza el rojo → para runas rojas (sigilo demoníaco) pasar `luma_weights` con más peso al rojo + subir `gamma`; runas blancas/azules/doradas van con pesos por defecto. Probado e2e en GPU con SD1.5 — `circular summoning rune` glow seed 11 512×512, círculo de invocación brillante sobre **negro puro** (esquinas luma 0.00, dark 83%, runa 3.4% brillante, max 255) apto luma→alpha (`prompt_id 701d149a`, `reports/0172`). SD1.5. |
|
||||||
|
| `comfyui_build_title_lettering_workflow_py_ml` | `(text, *, letter_style="epic fantasy metallic", checkpoint="juggernaut_xl_v11…", width=1024, height=512, transparent=True, seed=0, lora=None, …) -> dict` | EL texto/logo de **título** de un juego (el nombre del juego o una palabra) renderizado con un **tratamiento de lettering** (metálico, tallado en fuego/piedra/madera, neón, cristal, oro), formato **apaisado** (`width>height`, 1024×512 por defecto), fondo plano recortable a alpha (`the word "{text}" as a game logo, {letter_style} lettering, stylized typography, centered, plain background…`) → txt2img apaisado + LoRA estilo opcional + Rembg (alpha). El **negativo NO rechaza texto** (el lettering es el sujeto) y empuja contra el ruido textual (`extra letters/jumbled text/deformed letters`). El VALOR es el ESTILO del lettering, **NO** la fidelidad tipográfica: ⚠️ la difusión renderiza texto de forma imperfecta — letras de más, deformadas o mal escritas; mitigar con palabras CORTAS en MAYÚSCULA, **re-roll de seeds** (`comfyui_batch_generate`), SDXL > SD1.5 para texto, o pintar el texto real con una fuente en el motor. **Una palabra que es un objeto concreto (DRAGON) → el modelo dibuja el objeto, no las letras** — usar palabras abstractas o reforzar `letter_style`. Marca coherente = mismo `letter_style`/`checkpoint`/`lora`, varía solo `text`. Recorte por **Rembg** (logo sólido), no luma→alpha. Probado e2e en GPU: `DRAGON`/`fire engraved` SD1.5 1024×512 → ilustró dragones rojos (alpha OK, confirma el gotcha de palabra-objeto, `prompt_id 6f3920b7`); `AETHER`/`epic fantasy metallic` SDXL 768×384 → **logo de texto metálico dorado** legible con ortografía imperfecta + alpha (`prompt_id 2a7fe8ba`, `reports/0165`). SD1.5/SDXL. |
|
||||||
|
|
||||||
|
## Animación de assets (vídeo) — caminos validados e2e
|
||||||
|
|
||||||
|
Tres vías para que un asset 2D se **mueva** (loop de VFX, sprite animado, fondo con
|
||||||
|
movimiento), todas cabiendo en 8GB **con la GPU vacía** (cierra el juego antes — el
|
||||||
|
vídeo NO convive con un juego AAA en VRAM). Los builders son del grupo hermano `comfyui`
|
||||||
|
(dominio `ml`); aquí se documenta su **uso gamedev**. Reutilizan el round-trip canónico
|
||||||
|
`build → comfyui_submit_workflow → (sondear /history) → comfyui_fetch_output_video`.
|
||||||
|
|
||||||
|
| Vía | Builder | Para qué (gamedev) | Validado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **txt2video (LTX)** | `comfyui_build_video_workflow(prompt, model='ltx', width=512, height=320, num_frames=25, fps=12)` | **Loop de elemento desde texto**: portal, antorcha, agua, humo, magia. Sale `.mp4`. Modelo LTX-Video 2B v0.9.5 (`ltx-video-2b-v0.9.5.safetensors` + text encoder `t5xxl_fp8`). | e2e GPU: portal mágico 512×320, **25 frames**, 2.08s, pico **7717 MiB / 8192**, `prompt_id 54eda033`, `reports/0186`. |
|
||||||
|
| **txt2video (Wan)** | `comfyui_build_video_workflow(prompt, model='wan', …)` | Igual que LTX pero con Wan2.1 T2V 1.3B (`wan2.1_t2v_1.3B_fp16` + `umt5_xxl_fp8` + `wan_2.1_vae`). Enlazado y visible en `/object_info`. | Enlace verificado en `reports/0186`; clip no generado aún (LTX cubrió el golden). |
|
||||||
|
| **img2vid (SVD)** | `comfyui_build_img2vid_workflow('sprite.png', width=512, height=512, video_frames=14, motion_bucket_id=127)` | **Animar un sprite/fondo YA generado**: copia la imagen a `~/ComfyUI/input/`, SVD la condiciona por CLIP_VISION (no usa prompt de texto) y la pone en movimiento. Sale `.webp` animado. | e2e GPU: `enemy_creature` (del pack) → 512×512 RGBA **14 frames** animado, pico **7463 MiB / 8192**, `prompt_id 5b501d03`, `reports/0186`. |
|
||||||
|
| **spritesheet (AnimateDiff)** | `comfyui_build_vfx_spritesheet_workflow(prompt, num_frames=8, closed_loop=True)` | N frames de un VFX 2D en bucle seamless sobre negro (insumo de luma→alpha + montaje de spritesheet). | e2e GPU previos (`reports/0140`/`0143`); 8GB: usar ≤8f@512² o bajar resolución (16f@512² revienta). |
|
||||||
|
|
||||||
|
**Límites VRAM (RTX 3070 8GB, GPU vacía):** LTX 512×320@25f → 7717 MiB; SVD 512×512@14f →
|
||||||
|
7463 MiB. Margen estrecho (~0.5 GB): con un juego AAA abierto (~2.7 GB) **ningún** camino
|
||||||
|
de vídeo cabe → cerrar el juego o ir a frames/res mínimos. La generación de **imagen**
|
||||||
|
estática sí convive con el juego. `comfyui_wait_result` **lanza** `TimeoutError` al
|
||||||
|
expirar (envolver en try/except); SVD es lento (>10 min para 14f en lowvram), pero el job
|
||||||
|
completa en GPU aunque el script de orquestación expire — recuperar el output sondeando
|
||||||
|
`/history` por `prompt_id`. Para transparencia, post-procesar los frames a alpha
|
||||||
|
(luma→alpha o rembg por frame).
|
||||||
|
|
||||||
|
## Builders de transformación (`gamedev-2d`, puros — parten de una imagen/dibujo de entrada)
|
||||||
|
|
||||||
|
A diferencia de los builders de **generación** de arriba (parten de TEXTO, txt2img desde
|
||||||
|
ruido), estos parten de una **imagen de entrada** y la transforman. Cuatro sub-ejes:
|
||||||
|
|
||||||
|
- **img2img** (`asset_variant`): parte de un asset **ya pintado**; el KSampler arranca del
|
||||||
|
latente de la imagen base (LoadImage → VAEEncode), no de ruido, así que con `denoise` medio
|
||||||
|
conserva la estructura mientras el prompt reescribe material/paleta/tier. Reescribe **todo** el
|
||||||
|
asset conservando forma **y** color del original.
|
||||||
|
- **sketch→ControlNet** (`sprite_from_sketch`): parte del **dibujo tosco** del dev (boceto,
|
||||||
|
lineart, garabato); es `txt2img` (arranca de ruido) pero condicionado por un ControlNet atado
|
||||||
|
al mapa de líneas del dibujo. Conserva solo la **forma**; la IA pone material/color/acabado.
|
||||||
|
- **inpaint** (`inpaint_asset`): parte de un asset **ya pintado** + una **máscara** que marca qué
|
||||||
|
región editar (blanco) y cuál conservar (negro); el sampler regenera **solo** la zona enmascarada
|
||||||
|
dejando el resto del pixel intacto. Cambia **una parte** (arma, casco, escudo, reparación), no el
|
||||||
|
asset entero.
|
||||||
|
- **outpaint** (`outpaint_asset`): parte de un asset **ya pintado** y **agranda el lienzo** por uno o
|
||||||
|
varios lados; el nodo `ImagePadForOutpaint` extiende el canvas **y genera** la máscara feathered de
|
||||||
|
la franja nueva (no la recibe el caller), y el sampler genera ahí contenido coherente. Cambia el
|
||||||
|
**tamaño** del asset (recortar/extender un fondo o parallax a otra resolución/aspect), no lo de dentro.
|
||||||
|
- **multi-vista 3D / 2.5D** (`directional_sprite`): parte del sprite **frontal** de un personaje y lo
|
||||||
|
**rota en 3D** (SV3D turntable u Stable Zero123 órbita) para producir N vistas direccionales del MISMO
|
||||||
|
personaje (8-way N/NE/E/SE/S/SW/W/NW o 4-way). A diferencia de `sprite_sheet` (re-poza con OpenPose 2D,
|
||||||
|
re-dibuja la silueta → identidad inconsistente entre ángulos), aquí la difusión 3D gira la figura sobre
|
||||||
|
su eje, así casco/arma/paleta son los mismos en cada dirección (**consistencia rotacional**). Cambia el
|
||||||
|
**ángulo de cámara**, no la pose ni el material.
|
||||||
|
|
||||||
|
Cubren el eje que el critic de generación (`reports/0178`) no exploró: derivar de un asset o
|
||||||
|
del dibujo del dev, no inventar un tipo nuevo desde texto.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `comfyui_build_asset_variant_workflow_py_ml` | `(input_image, variant, *, checkpoint="dreamshaper_8…", denoise=0.5, style="game asset", size=512, seed=0, lora=None, …) -> dict` | UNA **variante coherente de un asset 2D ya generado** (img2img): parte del sprite/icono que existe en `input_image` y produce su versión de **otro material/paleta/tier/estado** (`ice element`, `fire element`, `battle-damaged`, `golden tier 2`, `corrupted`) manteniendo **silueta, pose y composición** del original. Compone `comfyui_build_img2img_workflow` (LoadImage → VAEEncode → KSampler con `denoise`) + `comfyui_inject_lora` (estilo opcional) + `ImageScale` opcional (`size` normaliza la base a size×size; `size=None` preserva las dimensiones exactas sin deformar). El prompt es `{variant}, {style}, same composition, same pose, same silhouette, …`. **`denoise` es la palanca**: ~0.3 invisible, **0.45-0.6 recomendado** (cambia material/paleta, conserva forma), ~0.8 deriva la pose y se acerca a txt2img. Set de variantes del MISMO asset = mismo `input_image`/`style`/`seed`, varía solo `variant`. **DISTINTO de los builders txt2img** (`enemy_creature`, `item_icon`…): esos generan un tipo desde cero; éste transforma uno concreto. **NO inyecta Rembg** (img2img preserva el fondo/alpha del original según la base). ⚠️ la imagen base debe existir en `input/` del server (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); asset NO cuadrado + `size` fijo + `crop="disabled"` deforma → `size=None` o `crop="center"`. Probado e2e en GPU con SD1.5 — variante `ice element, frozen` del goblin `enemy_creature_00001_.png` denoise 0.5 seed 7 512×512 (`prompt_id 5e4a5d3d`): silueta conservada (luminance corr 0.63) + paleta a frío (blueness B−R −1.6→+1.9), `reports/0181`. SD1.5. |
|
||||||
|
| `comfyui_build_sprite_from_sketch_workflow_py_ml` | `(sketch_image, subject, *, control_type="lineart", checkpoint="dreamshaper_8…", style="game asset, clean, centered", strength=0.8, size=512, seed=0, lora=None, preprocess=True, controlnet_name=None, …) -> dict` | UN **sprite pintado a partir del BOCETO del dev**, guiado por **ControlNet** (sub-eje sketch→ControlNet, **NO img2img**). Recibe el dibujo tosco que existe en `sketch_image` (boceto/lineart/garabato) + `subject` (qué es), y genera un sprite en estilo de juego que **conserva la forma dibujada**: el dev marca la silueta, la IA pone material/color/acabado. Mecanismo: `txt2img` base (ruido, `EmptyLatentImage`, `denoise 1.0`) cuyo positivo pasa por `ControlNetApply` atado al mapa de líneas del boceto. `control_type` elige el **preprocesador** (`LineArtPreprocessor` / `ScribblePreprocessor` / `CannyEdgePreprocessor`, interpuesto entre el boceto y el ControlNet por un helper) y, por defecto, el **modelo CN emparejado**. Compone `comfyui_build_txt2img_workflow` + `comfyui_inject_controlnet` + `comfyui_inject_lora` (estilo opcional). **`strength` es la palanca**: 0 = ignora el dibujo (txt2img puro), ~0.8 recomendado (respeta forma dejando limpiar a la IA), 1.0 = se ciñe estricto. **DISTINTO de `asset_variant`** (img2img conserva forma+color de una imagen ya pintada) y de los txt2img (`enemy_creature`…, inventan la forma desde texto): éste conserva **solo la forma** del dibujo. ⚠️ el boceto debe existir en `input/` (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); `preprocess=False` solo si el sketch ya es un lineart limpio. **GOTCHA del server 8GB: solo `canny`/`depth`/`openpose` SD1.5 instalados** — para `lineart`/`scribble` pasa `controlnet_name="control_v11p_sd15_canny_fp16.safetensors"` u usa `control_type="canny"` (out-of-the-box); pendiente humano descargar `control_v11p_sd15_lineart_fp16`/`scribble`. Probado e2e en GPU con SD1.5 — boceto del goblin `enemy_creature_00001_.png` → `CannyEdgePreprocessor` → ControlNet canny, `subject="dark fantasy goblin warrior"` strength 0.85 seed 123 512×512 (`prompt_id ea6fc372`): pose/orejas/hombrera/lanza dentada/espada del dibujo conservadas, repintado en estilo de juego, `reports/0182`. SD1.5. |
|
||||||
|
| `comfyui_build_inpaint_asset_workflow_py_ml` | `(input_image, mask_image, prompt, *, checkpoint="dreamshaper_8…", denoise=1.0, style="game asset", grow_mask=6, size=None, seed=0, lora=None, mode="vae_encode", …) -> dict` | EDITA **solo una región** de un asset 2D ya pintado (**inpaint**, sub-eje propio). Recibe el asset en `input_image` + una **máscara** `mask_image` (BLANCO = editar, NEGRO = conservar) + `prompt` de qué poner ahí, y repinta **únicamente** la zona enmascarada dejando el resto del sprite intacto (cambiar/añadir un arma, quitar un casco, poner un escudo, reparar una zona dañada). Mecanismo (`mode="vae_encode"`): `VAEEncodeForInpaint` codifica el latente respetando la máscara y dilata su borde `grow_mask` px para difuminar la costura; `KSampler` (`denoise` alto) regenera solo esa región con `{prompt}, {style}, seamless blend…`. Compone `comfyui_build_inpaint_workflow` (base) + `comfyui_inject_lora` (estilo opcional); `size` escala imagen **Y** máscara de forma consistente (escalar solo una las desalinea). **`grow_mask` es la palanca de costura** (6-10 px difumina el borde lo/nuevo); `denoise` 1.0 reescribe entero, ~0.5-0.7 repara suave. **DISTINTO de `asset_variant`** (img2img reescribe TODO el asset) y de `sprite_from_sketch` (ControlNet parte de un dibujo de líneas para un sprite nuevo): éste edita **un trozo** delimitado por la máscara. **ERROR-PATH**: si el server no expone `VAEEncodeForInpaint`, pasar `mode="noise_mask"` → degrada a `VAEEncode` + `SetLatentNoiseMask` (+ `GrowMask`); `mask_image` vacío lanza `ValueError`. ⚠️ asset y máscara deben existir en `input/` (subir con `POST /upload/image`) y compartir resolución (o usar `size`); `ImageScale` aquí NO ofrece `lanczos` (válidos `bilinear`/`nearest-exact`/`area`/`bicubic`); pura, no valida. Probado e2e en GPU con SD1.5 — máscara circular (R70) sobre la mano del goblin `enemy_creature_00001_.png`, `prompt="a glowing blue magic orb"` grow_mask 8 denoise 1.0 seed 7 (`prompt_id 88b52c66`): orbe azul en la región, **resto idéntico** (diff medio dentro 40.3 vs fuera 1.97 → ratio 20.4×; 44.6% px cambiados dentro vs 1.7% fuera), `reports/0183`. SD1.5. |
|
||||||
|
| `comfyui_build_directional_sprite_workflow_py_ml` | `(input_image, *, directions=8, model="sv3d", elevation=0.0, size=None, orbit_frames=None, seed=0, ckpt=None, …) -> dict` | UN **sprite MULTI-DIRECCIONAL** del MISMO personaje rotado en 3D (**multi-vista 2.5D**, sub-eje propio): parte de la imagen **frontal** del personaje (fondo limpio, en `input/`) y construye el workflow que genera N vistas direccionales CONSISTENTES (8-way N/NE/E/SE/S/SW/W/NW o 4-way) para top-down/iso/shooter 8-way. `model="sv3d"` (default) = `SV3D_Conditioning` produce un **orbit turntable** de N frames equiespaciados en 360° en una pasada (mejor consistencia, `sv3d_p.safetensors`, nativo 576²); `model="zero123"` = `StableZero123_Conditioning_Batched` da un **batch** de N vistas por azimuth (fallback menor VRAM, `stable_zero123.ckpt`, nativo 256²). `elevation` (~15-30) da picado para cámara cenital; `orbit_frames` (SOLO sv3d) densifica el orbit (21 nativo) para submuestrear; el módulo expone `directional_sprite_view_order(directions)` (frame i = dirección i). **DISTINTO de `sprite_sheet`** (OpenPose 2D re-poza la silueta → identidad inconsistente): aquí la difusión 3D ROTA la figura sobre su eje → casco/arma/paleta idénticos en cada dirección (rotación 3D real, no re-dibujo). Construye, NO genera (el coste GPU es el `submit`); **pura, no valida** (la imagen frontal debe existir en `input/`). Hermana **pura** de `comfyui_generate_views_from_image` (orquestador impuro para recon 3D, 4 cardinales). ⚠️ VRAM RTX 3070 8GB: SV3D es modelo de vídeo, pesa — 8 frames@576² → pico **7145 MiB**; limpiar GPU antes (`POST /free`); OOM → baja `size`/`directions` o cae a zero123, NO matar procesos; `comfyui_wait_result` lanza `TimeoutError` pero el job completa (sondear `/history`). Probado e2e en GPU con SV3D — goblin `enemy_creature_00001_.png` (compuesto sobre blanco 576²) → 8 direcciones elevation 15 seed 7, **8 frames** 576² en 75 s, consistencia rotacional medida (MAE adyacentes 27 < frente↔espalda 29.6, spread de paleta 3.83 = mismo personaje en las 8 vistas; `prompt_id 8b9f75de`, `reports/0187`). SV3D/Zero123. |
|
||||||
|
| `comfyui_build_outpaint_asset_workflow_py_ml` | `(input_image, prompt, *, left=0, right=0, top=0, bottom=0, feather=40, checkpoint="dreamshaper_8…", denoise=1.0, style="game background", grow_mask=0, seed=0, lora=None, …) -> dict` | EXTIENDE **el lienzo** de un asset 2D ya pintado (**outpaint**, sub-eje propio). Recibe el asset en `input_image` + cuánto extender por cada lado (`left`/`right`/`top`/`bottom` px) + `prompt` de qué generar fuera de los bordes, y **agranda el canvas** generando contenido coherente con el original más allá de sus bordes (recortar/extender un fondo, parallax, card_art o splash a otra resolución/aspect ratio). Mecanismo: el nodo nativo `ImagePadForOutpaint` amplía el lienzo y **EMITE** a la vez la imagen extendida **y** la máscara feathered de la franja nueva (la genera el grafo, **NO** la recibe el caller); `VAEEncodeForInpaint` codifica respetando esa máscara y `KSampler` (`denoise` alto) genera lo nuevo con `{prompt}, {style}, seamless extension…`. Compone `comfyui_build_inpaint_workflow` (base; su `LoadImageMask` se elimina y `VAEEncodeForInpaint` se reconecta a las dos salidas del pad) + `comfyui_inject_lora` (estilo opcional). **`feather` difumina la costura** (40 px por defecto, no debe pasarse de la extensión); `grow_mask` (0 por defecto) dilata adicionalmente el borde si aparece costura dura. **DISTINTO de `inpaint_asset`**: éste **no recibe máscara** (la genera el pad) y cambia el **tamaño** del asset extendiendo hacia fuera, mientras inpaint edita una región **interior** con máscara externa del mismo tamaño. **ERROR-PATH**: `input_image`/`prompt` vacíos o las cuatro extensiones en 0 tras redondear (`left=3`→0) lanzan `ValueError`; si el server no expone `ImagePadForOutpaint`, consultar `/object_info`. ⚠️ el asset debe existir en `input/` (subir con `POST /upload/image`); las extensiones se redondean a múltiplo de 8 (`250→248`); pura, no valida. Probado e2e en GPU con SD1.5 — fondo `seamless_00004_.png` 512×512 extendido `right=256` feather 40 denoise 1.0 seed 7 (`prompt_id aa33de05`): canvas **512→768×512** (+256), original conservado (diff medio 7.2 lejos del borde) + franja nueva con contenido coherente (std 28.9, dist de paleta 28.6), `reports/0185`. SD1.5. |
|
||||||
|
|
||||||
|
## Funciones de post-proceso y puente (`gamedev-2d`, CPU)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `comfyui_pixelize_image_py_ml` | `(src, dst, *, downscale=8, colors=16, palette=None, dither=False, upscale_back=True) -> dict` | Pixel-perfect: downscale nearest + cuantización a N colores o paleta fija (game-boy/pico-8/nes). Fase 2 pixelart. Impura (I/O). |
|
||||||
|
| `comfyui_matting_luma_to_alpha_py_ml` | `(image_path, *, out_path=None, gamma=1.0, black_point=0.0, premultiply=False, luma_weights=(.299,.587,.114)) -> dict` | Frame VFX sobre negro -> RGBA usando luminancia como alpha (translúcidos con additive blend). Impura (I/O). |
|
||||||
|
| `comfyui_export_asset_to_godot_py_pipelines` | `(asset_path, kind, godot_project, *, name=None, reimport=True, godot_bin=None) -> dict` | Copia el asset a `res://assets/<dir>/` por `kind` + escribe `.import` + filtro Nearest si pixelart + reimport headless. Pipeline impuro. |
|
||||||
|
| `godot_map_asset_dir_py_core` | `(kind) -> str` | Mapea `kind` -> subcarpeta de `res://assets/`. Pura. |
|
||||||
|
| `godot_clean_asset_name_py_core` | `(filename, *, override=None) -> str` | Normaliza el nombre `<prefijo>_NNNNN_.<ext>` a snake_case seguro para `res://`. Pura. |
|
||||||
|
|
||||||
|
## Estilos (style presets) — calidad por ESTILO reutilizable
|
||||||
|
|
||||||
|
Un *style preset* es la receta curada de un look visual que se aplica a **TODOS** los
|
||||||
|
assets de un juego de una vez ("todo en Game Boy", "estilo Ghibli", "pixel-art retro").
|
||||||
|
En vez de repetir a mano `style`/`checkpoint`/`lora`/`negative` + post-proceso en cada
|
||||||
|
builder, el preset los empaqueta como DATOS puros y el helper los traduce a los kwargs de
|
||||||
|
cualquier builder de sujeto (item_icon, enemy_creature, prop_object, …) o del pipeline
|
||||||
|
`comfyui_generate_asset_pack_oneshot`. Diseño (issue 0087): función pura de presets +
|
||||||
|
helper de aplicación (NO un pipeline monolítico) — máxima composabilidad, sin acoplar
|
||||||
|
firmas. Extensible: añadir un estilo = una entrada en `_PRESETS`.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `comfyui_get_gamedev_style_preset_py_ml` | `(name=None) -> dict` | Devuelve la receta de un STYLE PRESET curado o el catálogo si `name=None`. Receta = `{subject_prefix, subject_suffix, style, negative, checkpoint, lora, lora_strength, size, transparent, post, notes}`. Pura, copias profundas. **6 estilos**: **gameboy** (sin LoRA → prompt + post `pixelize` paleta `game-boy` 4 tonos verde), **ghibli** (degrada a `SD15_watercolor_style` gratis instalado + prompt; no hay LoRA Ghibli dedicado ni se descargó nada gated), **pixel-art-retro** (reutiliza `SDXL_pixel-art` SDXL ya instalado → checkpoint `juggernaut_xl_v11` + size 768 + post `pixelize` 16 colores), **cyberpunk-neon** (prompt puro SD1.5, glow magenta/cyan, sin post), **low-poly-flat** (prompt puro SD1.5, facetas/flat shading PS1, sin post, transparent), **cartoon-cel-shaded** (LoRA `SD15_anime_style_box` 0.7 + prompt cel-shaded, sin post, transparent). Extensible: añadir un estilo = una entrada en `_PRESETS`. |
|
||||||
|
| `comfyui_apply_style_preset_py_ml` | `(preset, subject, *, style=None, negative=None) -> dict` | Traduce un preset + un `subject` a `{name, subject (con prefijo/sufijo), builder_kwargs={style,checkpoint,lora,lora_strength,negative}, size, transparent, post}`. Los `builder_kwargs` hacen `**spread` directo en cualquier builder de sujeto; `size`/`transparent` van aparte (recomendaciones); el caller aplica `post["pixelize"]` al PNG si existe. Pura, no muta el preset; `negative` se mergea (no reemplaza). |
|
||||||
|
|
||||||
|
**Ejemplo canónico (mismo subject, look del juego entero):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||||
|
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||||
|
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||||
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
|
||||||
|
preset = comfyui_get_gamedev_style_preset("gameboy") # o "ghibli" / "pixel-art-retro"
|
||||||
|
ap = comfyui_apply_style_preset(preset, "knight character")
|
||||||
|
wf = comfyui_build_enemy_creature_workflow(ap["subject"], size=ap["size"],
|
||||||
|
transparent=ap["transparent"], seed=7, **ap["builder_kwargs"])
|
||||||
|
pid = comfyui_submit_workflow(wf)["prompt_id"]
|
||||||
|
outs = comfyui_wait_result(pid, timeout=500)
|
||||||
|
fn = next(i["filename"] for o in outs.values() for i in o.get("images", []))
|
||||||
|
raw = comfyui_fetch_output_image(fn, dest_dir="/tmp")["path"]
|
||||||
|
if ap["post"].get("pixelize"): # gameboy/pixel-retro sellan el grid/paleta
|
||||||
|
comfyui_pixelize_image(raw, "/tmp/knight.png", **ap["post"]["pixelize"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Validado e2e en GPU con el MISMO `knight character` en los 3 estilos (`reports/0190`):
|
||||||
|
gameboy 4 colores verde (`prompt_id 0657e3e3`), ghibli 78 552 colores acuarela
|
||||||
|
(`42f2f492`), pixel-art-retro SDXL 768 16 colores (`84b08581`) — tres looks
|
||||||
|
visiblemente distintos y coherentes. **Gotcha**: en el flujo manual de arriba el `post` no
|
||||||
|
se aplica solo (el caller llama `comfyui_pixelize_image`) — para evitarlo usa el pipeline
|
||||||
|
one-shot `comfyui_generate_styled_asset_oneshot` (abajo), que auto-aplica el post. El LoRA y
|
||||||
|
el checkpoint deben casar de base (SDXL_pixel-art es SDXL → exige juggernaut); OOM en 8 GB →
|
||||||
|
bajar `size`, NO matar procesos.
|
||||||
|
|
||||||
|
## Pipelines one-shot (`gamedev-2d`, impuros)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `comfyui_generate_asset_pack_oneshot_py_pipelines` | `(pack, *, checkpoint="dreamshaper_8…", style="", lora=None, base_seed=0, size=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, …) -> dict` | **Set COHERENTE de assets 2D de un mismo juego de un solo tiro**: `pack=[{"kind","subject"}, …]` → despacha cada `kind` a su builder atómico (26 kinds: item_icon, enemy_creature, prop_object, seamless_tile, ui_hud, particle_texture, …) compartiendo el MISMO `checkpoint`/`lora` + `style` común inyectado al `subject` + `seed = base_seed + i`, encola (`submit`) + espera (`wait`) + descarga (`fetch`) cada uno, y (si `export_godot`) los exporta a Godot. Promoción a pipeline del patrón "N builders con el mismo estilo" (issue 0087). Fail-fast si `kind` desconocido; un OOM aislado no aborta el resto. Probado e2e en GPU SD1.5 512: `magic sword`(item_icon, seed 42) + `goblin warrior`(enemy_creature, seed 43), `style="dark fantasy, hand-painted"` → 2/2 PNG 512×512 RGBA coherentes (`prompt_id f7cfda43` + `11d1d031`, `reports/0179`). Impuro: HTTP + disco + (export) subprocess. |
|
||||||
|
| `comfyui_generate_character_set_oneshot_py_pipelines` | `(character, *, style="game character, full body, clean background", checkpoint="dreamshaper_8…", base_kind="enemy_creature", directions=8, make_directional=True, make_3d=True, directional_model="sv3d", elevation=15.0, seed=0, size=512, directional_size=None, flatten_color=(255,255,255), variant_3d="mini", lora=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, free_vram=True, …) -> dict` | **Set COMPLETO y COHERENTE de UN personaje de un solo tiro** (culminación cross-frontera del grupo): genera del MISMO personaje (1) imagen **base 2D** recortada a alpha, (2) **sprite direccional N-way** (vistas 3D consistentes SV3D/Zero123) y (3) **malla 3D `.glb`** (Hunyuan3D-2). La CLAVE es la coherencia: el direccional y el 3D parten de la **MISMA base 2D aplanada** (`base_flat`), no de tres generaciones independientes → mismo personaje en las tres representaciones, no tres personajes distintos. Compone un builder de personaje (`enemy_creature`/`portrait_avatar`/`topdown_sprite`, elegido por introspección) + `comfyui_flatten_alpha_on_color` (aplana la base recortada sobre blanco — los modelos 3D y `LoadImage` hacen `convert("RGB")` y tiran el alpha) + `comfyui_image_to_3d_oneshot` + `comfyui_build_directional_sprite_workflow` + `submit`/`wait`/`fetch` + `comfyui_export_asset_to_godot`. **Secuencial liberando VRAM** (`POST /free`) entre los pasos pesados, el 3D ANTES del direccional (SV3D es el de mayor pico, ~7.1 GB), para caber en 8 GB. Un fallo aislado (p.ej. OOM en el 3D) NO aborta el resto: deja el set PARCIAL. Promoción a pipeline (issue 0087) de la secuencia que hoy exige 4 llamadas a mano. Probado e2e en GPU — ver `reports/0188`. Impuro: HTTP + disco + (export) subprocess. |
|
||||||
|
| `comfyui_generate_styled_asset_oneshot_py_pipelines` | `(kind, subject, style_preset, *, seed=0, server="127.0.0.1:8188", out_dir=None, export_godot=None, style_override=None, negative_extra=None, free_vram=False, **builder_extra) -> dict` | **Aplica un ESTILO curado a UN asset de un solo tiro, con AUTO-POST**: `comfyui_get_gamedev_style_preset(style_preset)` → `comfyui_apply_style_preset` → despacha `kind` a su builder (REUTILIZA el dispatch `_SUPPORTED` del pack, mismos 26 kinds) → `submit`/`wait`/`fetch` → **auto-aplica el `post` del preset** (`comfyui_pixelize_image` si el estilo lo pide) → export opcional a Godot (como `pixelart` si hubo pixelize → fija el filtro Nearest). Cierra el hueco #1 de los style presets (report 0190): los estilos pixelart (gameboy, pixel-art-retro) salen ya pixelizados del pipeline, **sin llamar a `comfyui_pixelize_image` a mano**. Devuelve `path` (FINAL post-procesado) y `raw_path` (crudo); `path==raw_path` si el estilo no pide post. Kind/estilo desconocido → `ok=False` sin tocar la GPU (validación pura; parte pura aislada en `styled_asset_build_only`). Probado e2e en GPU: mismo `treasure chest`(prop_object) en cyberpunk-neon (`prompt_id 02473baa`), low-poly-flat (`7a186053`) y gameboy (`46b396e2`, crudo 17374 colores → final **4 colores** Game Boy, auto-pixelizado) — ver `reports/0191`. Impuro: HTTP + disco + (export) subprocess. |
|
||||||
|
|
||||||
|
## Ejemplo end-to-end con builder (Fase 1 GPU → Fase 2 CPU → Godot)
|
||||||
|
|
||||||
|
Flujo completo pixel-art: construir workflow → generar en ComfyUI → pixel-perfect → Godot.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_build_pixelart_workflow import comfyui_build_pixelart_workflow
|
||||||
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
|
||||||
|
# 1. Construir (puro) + 2. generar (GPU)
|
||||||
|
wf = comfyui_build_pixelart_workflow("isometric tiny house, pixel, 32x32 style", use_lcm=True, seed=42)
|
||||||
|
pid = comfyui_submit_workflow(wf)["prompt_id"]
|
||||||
|
outs = comfyui_wait_result(pid, timeout=300)
|
||||||
|
fn = next(img["filename"] for o in outs.values() for img in o.get("images", []))
|
||||||
|
raw = comfyui_fetch_output_image(fn, dest_dir="/tmp")["out_path"]
|
||||||
|
# 3. pixel-perfect (CPU) -> 4. export Godot (ver ejemplo de abajo)
|
||||||
|
px = comfyui_pixelize_image(raw, "/tmp/house_pixel.png", downscale=8, colors=16)
|
||||||
|
```
|
||||||
|
|
||||||
|
VFX: `comfyui_build_vfx_spritesheet_workflow(prompt, num_frames=8)` → submit → fetch N frames
|
||||||
|
→ `comfyui_matting_luma_to_alpha` por frame → montar sheet RGBA con `Image.alpha_composite`
|
||||||
|
(NO `comfyui_build_grid`, que aplana el alpha).
|
||||||
|
|
||||||
|
## Ejemplo canónico de post-proceso
|
||||||
|
|
||||||
|
Flujo: crudo generado en ComfyUI -> pixelizar -> exportar a Godot con Nearest.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha
|
||||||
|
from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot
|
||||||
|
|
||||||
|
OUT = os.path.expanduser("~/ComfyUI/output")
|
||||||
|
PROJ = os.path.expanduser("~/gamedev/projects/crossy_road")
|
||||||
|
|
||||||
|
# 1. Pixelizar un sprite crudo (SDXL+SDXL_pixel-art) a 16 colores
|
||||||
|
px = comfyui_pixelize_image(f"{OUT}/hero_00001_.png", "/tmp/hero_pixel.png",
|
||||||
|
downscale=8, colors=16)
|
||||||
|
|
||||||
|
# 2. Exportarlo a Godot como pixelart (carpeta sprites/, filtro Nearest, reimport)
|
||||||
|
exp = comfyui_export_asset_to_godot("/tmp/hero_pixel.png", "pixelart", PROJ)
|
||||||
|
print(exp["dest_res_path"], exp["pixelart_filter_set"], exp["reimported"])
|
||||||
|
|
||||||
|
# Rama VFX: frame de humo sobre negro -> RGBA -> carpeta vfx/
|
||||||
|
rgba = comfyui_matting_luma_to_alpha(f"{OUT}/vfx_loop_00007_.png", gamma=1.2, black_point=0.04)
|
||||||
|
comfyui_export_asset_to_godot(rgba["out_path"], "vfx", PROJ)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras (qué NO cubre)
|
||||||
|
|
||||||
|
- **Montaje de spritesheet dedicado** (grid RGBA + JSON sidecar para Godot/Unity):
|
||||||
|
no hay función propia todavía — el ejemplo VFX monta con `Image.alpha_composite`
|
||||||
|
inline. `comfyui_build_grid` NO sirve (aplana el alpha sobre fondo oscuro). Pendiente
|
||||||
|
de R4 (plan `reports/0140` F2).
|
||||||
|
- **Pipelines one-shot** (build → submit → wait → fetch → post en una call): el
|
||||||
|
**set coherente** ya está promovido — `comfyui_generate_asset_pack_oneshot` genera
|
||||||
|
un pack entero compartiendo checkpoint/style/lora/seed (issue 0087, ver tabla de
|
||||||
|
pipelines arriba). One-shots por-asset individuales (pixelart/sprite/VFX) siguen
|
||||||
|
encadenándose a mano; candidatos a promoción cuando el patrón se repita.
|
||||||
|
- **Sprite turnaround multi-vista** (N direcciones del mismo personaje con identidad fija):
|
||||||
|
cubierto por `comfyui_build_directional_sprite_workflow` (rotación 3D SV3D/Zero123,
|
||||||
|
consistencia rotacional medida — `reports/0187`). Lo que sigue **pendiente** es la
|
||||||
|
orquestación multi-POSE 2D con juez (re-pozar un personaje en N acciones manteniendo
|
||||||
|
identidad, distinto de rotarlo): `comfyui_build_sprite_sheet_workflow` produce UN frame;
|
||||||
|
el pipeline multi-pose con juez sigue pendiente (plan `reports/0137` T2).
|
||||||
|
- **Paletas lospec por red** (`load_lospec_palette`): no incluido. `pixelize` usa
|
||||||
|
paletas fijas embebidas (game-boy/pico-8/nes) o lista de hex, sin HTTP.
|
||||||
|
- **TileSet / SpriteFrames `.tres`**: Godot no los deriva solos; `export_asset_to_godot`
|
||||||
|
copia la textura y avisa, pero no genera el recurso (paso manual o futura función).
|
||||||
|
|
||||||
|
## Prerequisitos / notas
|
||||||
|
|
||||||
|
- **Godot CLI** para el reimport headless: autodetectado en PATH y en
|
||||||
|
`~/godot/Godot_v4.7-stable_linux.x86_64`. Si falta, `export_asset_to_godot` deja el
|
||||||
|
`.import` escrito y lo anota (no falla).
|
||||||
|
- **Filtro Nearest (Godot 4)**: se setea global en `project.godot`
|
||||||
|
(`default_texture_filter=0`), no por `.import`. La función lo asegura para pixelart.
|
||||||
|
- CPU-only: Pillow + numpy del venv del registry. Cero VRAM, cero red.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Capability group: `gamedev-engine` — runtime de juego C++ multiplataforma (PC + WebAssembly)
|
||||||
|
|
||||||
|
Cluster de primitivas C++ que forman el núcleo de un runtime de juego 2D portable a
|
||||||
|
escritorio (Windows/Linux/macOS) y navegador (WebAssembly via emscripten). Stack:
|
||||||
|
**SDL3** (ventana + input + GL context) + **sokol_gfx** (render) + **miniaudio**
|
||||||
|
(audio). Nacido del Issue 0072b.
|
||||||
|
|
||||||
|
A diferencia del grupo hermano `gamedev-2d` (generación de *assets* 2D con ComfyUI y
|
||||||
|
puente a Godot), este grupo es el **motor que ejecuta el juego**: el bucle de
|
||||||
|
simulación, la cámara, el input, el render por lotes y el audio. No genera arte; lo
|
||||||
|
consume en tiempo de ejecución.
|
||||||
|
|
||||||
|
Tag: `gamedev-engine`. Filtro: `mcp__registry__fn_search query="" tag="gamedev-engine"`.
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace | Pureza |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `game_loop_cpp_gamedev` | `loop_run(SDL_Window*, const LoopCfg&) -> void` | Game loop fixed-timestep estilo Glenn Fiedler ("Fix Your Timestep"): desacopla `on_fixed_update` (dt fijo) de `on_render` (factor de interpolación), acumulador con cap anti spiral-of-death. Branch automático desktop (while loop) vs `__EMSCRIPTEN__` (`emscripten_set_main_loop`). | impure |
|
||||||
|
| `input_unified_cpp_gamedev` | `input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)` | Snapshot unificado de input por frame para SDL3: mapea teclado (WASD+flechas), ratón, gamepad y touch a botones lógicos (left/right/up/down/action_a..y/start/back) y ejes analógicos, con flags `*_pressed` de rising edge limpio. | impure |
|
||||||
|
| `camera_2d_cpp_gamedev` | `world_to_screen / screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])` | Cámara ortográfica 2D pura (pos centro, zoom, rotación, viewport): conversiones world↔screen, AABB visible y matriz view-projection 4×4 column-major lista para cualquier renderer. Fast-path sin trig si `rotation==0`. | pure |
|
||||||
|
| `sokol_setup_cpp_gfx` | `make_environment() -> sg_environment; make_swapchain(int w, int h) -> sg_swapchain` | Builders puros para inicializar sokol_gfx sobre un GL context creado por SDL3 (no por sokol_app): `sg_environment` con defaults RGBA8 + depth/stencil y `sg_swapchain` del default framebuffer del contexto activo. | pure |
|
||||||
|
| `sprite_batch_cpp_gfx` | `sprite_batch_create(int cap=4096) -> SpriteBatch; sprite_batch_begin/draw/end` | Batched textured quad renderer sobre sokol_gfx: begin/draw/end con auto-flush por cambio de atlas o capacidad llena. Vertex layout pos+uv+color, alpha blending estándar, GLSL 330 / GLES 300. Base de plataformeros, top-down y UI sprites. | impure |
|
||||||
|
| `audio_engine_cpp_gamedev` | `engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)` | Lifecycle del engine de audio basado en miniaudio (single-header, public domain): inicializa device default, master volume, libera recursos. Cross-platform (WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten). Única TU que define `MINIAUDIO_IMPLEMENTATION`. | impure |
|
||||||
|
| `audio_play_cpp_gamedev` | `sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)` | Reproducción de audio sobre `fn::audio::Engine`: carga con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. | impure |
|
||||||
|
| `build_wasm_cpp_app_bash_infra` | `build_wasm_cpp_app(app_name, [--no-budget-check]) -> void` | Compila una app C++ del registry (`cpp/apps/<name>`) a WebAssembly via emscripten. Produce `build/wasm/<name>/<name>.{html,js,wasm,wasm.gz}`. Falla si el gzip supera 2 MB (budget). | impure |
|
||||||
|
|
||||||
|
## Ejemplo canónico (esqueleto de un juego 2D)
|
||||||
|
|
||||||
|
Las primitivas se componen así dentro del `main()` de una app C++ del registry. Cada
|
||||||
|
identificador (`fn::game_loop`, `fn::input`, `fn::camera2d`, `fn::gfx`, `fn::audio`)
|
||||||
|
proviene de la función homónima del registry; la app solo aporta la lógica de juego.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 1. Ventana + GL context con SDL3, sokol_gfx encima (sokol_setup)
|
||||||
|
sg_setup(...); // usa make_environment() del registry
|
||||||
|
auto swap = fn::gfx::make_swapchain(W, H);
|
||||||
|
auto batch = fn::gfx::sprite_batch_create(4096);
|
||||||
|
auto audio = fn::audio::engine_init();
|
||||||
|
fn::audio::play_sound_oneshot(audio, "assets/music.ogg", 0.6f);
|
||||||
|
|
||||||
|
// 2. Estado de cámara e input
|
||||||
|
fn::game::Camera2D cam{ .pos = {0,0}, .zoom = 1.0f, .viewport = {W, H} };
|
||||||
|
fn::input::InputState in{};
|
||||||
|
|
||||||
|
// 3. Game loop fixed-timestep: simulación e interpolación desacopladas
|
||||||
|
fn::game::LoopCfg cfg{
|
||||||
|
.on_event = [&](const SDL_Event* e){ fn::input::input_process_event(in, e); },
|
||||||
|
.on_fixed_update = [&](float dt){ /* mover entidades usando in.left_pressed... */ },
|
||||||
|
.on_render = [&](float alpha){
|
||||||
|
fn::gfx::sprite_batch_begin(batch);
|
||||||
|
// draw sprites con cam.view_proj_matrix(...) como transform
|
||||||
|
fn::gfx::sprite_batch_end(batch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fn::game::loop_run(window, cfg);
|
||||||
|
|
||||||
|
// 4. Distribuir a navegador: build_wasm_cpp_app "<app>" -> build/wasm/<app>/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras (qué NO cubre)
|
||||||
|
|
||||||
|
- **Generación de assets** (sprites, tiles, VFX): es el grupo hermano `gamedev-2d`
|
||||||
|
(ComfyUI → post-proceso → Godot). Este grupo solo los *renderiza* en runtime.
|
||||||
|
- **Física / colisiones / ECS**: no incluidos. El `on_fixed_update` recibe el dt fijo;
|
||||||
|
la simulación la pone la app.
|
||||||
|
- **TileSet / mapas / escenas**: no hay sistema de niveles; `sprite_batch` dibuja quads
|
||||||
|
sueltos.
|
||||||
|
- **App end-to-end consumidora**: a fecha de hoy estas primitivas son el núcleo del
|
||||||
|
runtime (Issue 0072b) pero **no hay todavía una app C++ que las componga end-to-end**
|
||||||
|
(varias están marcadas `pendiente-usar`). El ejemplo de arriba es el esqueleto
|
||||||
|
previsto, no un binario validado. Cuando exista la primera app, su `app.md`
|
||||||
|
declarará estas IDs en `uses_functions` y servirá de validación e2e.
|
||||||
|
|
||||||
|
## Prerequisitos / notas
|
||||||
|
|
||||||
|
- **SDL3** para ventana + input + GL context; **sokol_gfx** (vendored en `cpp/vendor/`)
|
||||||
|
para render; **miniaudio** (single-header) para audio.
|
||||||
|
- Target nativo: GLSL 330. Target WebAssembly (emscripten): GLES 300 — `sprite_batch`
|
||||||
|
emite ambos shaders.
|
||||||
|
- `build_wasm_cpp_app` exige el SDK de emscripten en el entorno y respeta un budget de
|
||||||
|
2 MB gzip por defecto (`--no-budget-check` para saltarlo).
|
||||||
@@ -10,24 +10,27 @@ partir de una sola foto se estima un mapa de profundidad monocular con un modelo
|
|||||||
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
|
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
|
||||||
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
|
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
|
||||||
|
|
||||||
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
|
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
|
||||||
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
|
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
|
||||||
|
de fondo con los dos pasos de reconstruccion:
|
||||||
|
|
||||||
```
|
```
|
||||||
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
|
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Funciones
|
## Funciones
|
||||||
|
|
||||||
| ID | Firma corta | Que hace |
|
| ID | Firma corta | Que hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
|
||||||
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
|
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
|
||||||
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
|
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
|
||||||
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
||||||
|
|
||||||
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
||||||
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
|
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
|
||||||
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
|
||||||
|
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
|
||||||
|
|
||||||
## Ejemplo canonico (end-to-end imagen → glb)
|
## Ejemplo canonico (end-to-end imagen → glb)
|
||||||
|
|
||||||
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
|||||||
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
|
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
|
||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, "python/functions/datascience")
|
sys.path.insert(0, "python/functions/datascience")
|
||||||
|
from remove_background import remove_background
|
||||||
from estimate_image_depth import estimate_image_depth
|
from estimate_image_depth import estimate_image_depth
|
||||||
from depth_to_relief_glb import depth_to_relief_glb
|
from depth_to_relief_glb import depth_to_relief_glb
|
||||||
|
|
||||||
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
|
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
|
||||||
OUT = "/tmp/cats_relief.glb"
|
OUT = "/tmp/cats_relief.glb"
|
||||||
|
|
||||||
|
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
|
||||||
|
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
|
||||||
|
assert cut["status"] == "ok"
|
||||||
|
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
|
||||||
|
|
||||||
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
|
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
|
||||||
assert est["status"] == "ok"
|
assert est["status"] == "ok"
|
||||||
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
|
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
|
||||||
|
|
||||||
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
|
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
|
||||||
|
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
|
||||||
assert res["status"] == "ok"
|
assert res["status"] == "ok"
|
||||||
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
|
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
|
||||||
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
|
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
|
||||||
@@ -70,15 +80,19 @@ O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
|
|||||||
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
|
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
|
||||||
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
|
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
|
||||||
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
|
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
|
||||||
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
|
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
|
||||||
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
|
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
|
||||||
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
|
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
|
||||||
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
|
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
|
||||||
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
|
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
|
||||||
datascience). Ver gotchas en cada `.md`.
|
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
|
||||||
|
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
|
||||||
|
en cada `.md`.
|
||||||
|
|
||||||
## Prerequisitos
|
## Prerequisitos
|
||||||
|
|
||||||
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
|
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
|
||||||
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
|
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
|
||||||
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
|
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
|
||||||
|
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
|
||||||
|
`remove_background` cae al umbral NumPy).
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Capability: sql-connect
|
||||||
|
|
||||||
|
Conexión directa y consulta a un **Microsoft SQL Server** desde el registry, con el caso prioritario de **Navision** (el ERP corre sobre SQL Server). Las funciones Python usan el driver **pymssql** (más simple en Linux/WSL que pyodbc: trae FreeTDS embebido, no necesita ODBC driver manager).
|
||||||
|
|
||||||
|
Existe para **eliminar el ida y vuelta manual** con Navision: en vez de escribir una query, que el usuario la ejecute en su SGBD y pegue el CSV, estas funciones se conectan al servidor y devuelven las filas — iteración rápida sobre una query en un solo comando.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `mssql_connect_py_infra` | `mssql_connect(host, database, user, password, port=1433, login_timeout=15, query_timeout=30) -> pymssql.Connection` | Abre una conexión a SQL Server vía pymssql. Credenciales por argumento (nunca hardcodeadas). `login_timeout` acota la fase de login para que un host inalcanzable no cuelgue. Devuelve la conexión abierta; el caller la cierra con `.close()`. Lanza `RuntimeError` claro (host:port/db) si falla. |
|
||||||
|
| `mssql_query_py_infra` | `mssql_query(conn, sql, params=None, max_rows=None) -> dict` | Ejecuta una SELECT parametrizada sobre una conexión abierta y mapea las filas a dicts. Binding seguro del driver (placeholders `%s`/`%(nombre)s`, sin inyección). Devuelve `{columns, rows:[{col:val}], row_count}`. 0 filas → lista vacía sin error. `max_rows` limita con `fetchmany`. Read-only (no commit), no cierra la conexión. |
|
||||||
|
| `run_mssql_query_py_pipelines` | `run_mssql_query(host, database, user, password, sql, params=None, port=1433, max_rows=None, login_timeout=15, query_timeout=30) -> dict` | **Pipeline one-shot**: compone `mssql_connect` + `mssql_query` y cierra siempre la conexión (try/finally). CLI imprime JSON o CSV. Para iterar sobre una query de Navision en un solo `fn run`. |
|
||||||
|
|
||||||
|
## Ejemplo canónico
|
||||||
|
|
||||||
|
One-shot para iterar sobre Navision (la contraseña se lee de una env var, nunca se pasa por la línea de comandos):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/egutierrez/fn_registry
|
||||||
|
MSSQL_PASSWORD=$(pass navision/password) \
|
||||||
|
./fn run run_mssql_query \
|
||||||
|
--host 10.0.0.5 --database navdb --user sa \
|
||||||
|
--sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \
|
||||||
|
--param CLI-0001 \
|
||||||
|
--format csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Conexión persistente para muchas queries seguidas (abrir una vez, consultar N veces):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.mssql_connect import mssql_connect
|
||||||
|
from infra.mssql_query import mssql_query
|
||||||
|
|
||||||
|
conn = mssql_connect("10.0.0.5", "navdb", "sa", os.environ["MSSQL_PASSWORD"])
|
||||||
|
try:
|
||||||
|
abiertos = mssql_query(
|
||||||
|
conn,
|
||||||
|
"SELECT [No_], [Amount] FROM [dbo].[Cartera] WHERE [Open] = 1 AND [Customer No_] = %s",
|
||||||
|
params=("CLI-0001",),
|
||||||
|
)
|
||||||
|
print(abiertos["row_count"], abiertos["columns"])
|
||||||
|
posted = mssql_query(conn, "SELECT TOP 10 [Document No_], [Amount] FROM [dbo].[Posted Cartera]")
|
||||||
|
print(posted["rows"])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas del grupo
|
||||||
|
|
||||||
|
- **Conectividad WSL2 → Windows**: el `host` debe ser la **IP LAN del Windows** que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`. Probablemente el servidor real de Navision no sea alcanzable desde un entorno aislado sin red a la oficina + credenciales.
|
||||||
|
- **Credenciales desde `pass`, nunca hardcodeadas.** Patrón: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. La función recibe la contraseña como argumento; el caller la resuelve. `--password` literal existe pero queda visible en la lista de procesos — usa `--password-env`.
|
||||||
|
- **Placeholders pymssql** son `%s` (posicional) y `%(nombre)s` (nombrado), NO `?` (eso es pyodbc). Pasa los valores como `params`, jamás concatenados en el SQL (inyección).
|
||||||
|
- **`mssql_query` no abre ni cierra la conexión** — la toma prestada. Para ráfagas de queries, abre con `mssql_connect` una vez y reúsala; el pipeline `run_mssql_query` abre y cierra por llamada (cómodo, no eficiente en ráfaga).
|
||||||
|
- **Read-only por uso**: pensado para SELECT (Navision: cartera, posted cartera, movimientos). No hace commit.
|
||||||
|
- **Requiere `pymssql`** instalado en el venv (`uv add pymssql`). Import perezoso: el módulo carga sin la dependencia, pero la llamada falla con `RuntimeError` claro si falta.
|
||||||
|
- **Datos sintéticos en ejemplos** [POL-MMNSEG-001-1.0]: los `No_`/`Customer No_` de los ejemplos son ficticios. Sobre datos reales de Navision aplica la política de protección de datos.
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **Solo SQL Server (Navision)**. No es una capa SQL genérica: para PostgreSQL usa el grupo `postgres`; para DuckDB el grupo `duckdb`. Generalizar a MySQL/otros engines sería especulativo (KISS) hasta que haya un caso real.
|
||||||
|
- **No es ETL ni BI**: solo conecta y devuelve filas. Para llevar datos de Navision a un destino analítico, compón con los grupos `duckdb`/`postgres` (cargar las filas) o léelas en un notebook.
|
||||||
|
- **No gestiona el servidor** (no crea bases, no administra logins). Solo cliente de lectura.
|
||||||
|
|
||||||
|
## Relación con otros grupos
|
||||||
|
|
||||||
|
- `postgres` / `duckdb` — capas CRUD para otros engines; mismo espíritu (conectar + consultar), distinto motor. SQL Server (Navision) es la fuente; esos son destinos analíticos/BI.
|
||||||
|
- `metabase` / `bigquery` — el trabajo Aurgi consume datos ya en BigQuery/Metabase; este grupo abre la puerta a leer Navision en origen para iterar queries antes de modelarlas.
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# Integración ComfyUI → Godot: puente de assets ordenado y gestionado
|
||||||
|
|
||||||
|
Diseño del puente entre la generación de assets en **ComfyUI** (`~/ComfyUI/`) y su consumo en
|
||||||
|
proyectos **Godot 4** (`~/gamedev/projects/`). El objetivo es que un asset generado (sprite,
|
||||||
|
tileset, pixelart, spritesheet VFX, audio, malla 3D GLB) viaje a un proyecto Godot a la carpeta
|
||||||
|
correcta, con los *import settings* adecuados a su tipo, sin romper los archivos `.import`
|
||||||
|
existentes ni desordenar el proyecto.
|
||||||
|
|
||||||
|
- **Fecha:** 26/06/2026
|
||||||
|
- **Alcance:** mapa de ambas estructuras + convención de carpetas destino + tabla
|
||||||
|
tipo-de-asset → carpeta Godot → import settings + propuesta de función(es) del registry para
|
||||||
|
automatizar el traslado. Es documento de diseño; la implementación se delega a `fn-constructor`.
|
||||||
|
- **Fuera de alcance:** generación (GPU, otro agente), implementación de las funciones, descarga
|
||||||
|
de modelos.
|
||||||
|
|
||||||
|
Documento hermano del catálogo de capacidades de generación: `~/ComfyUI/CAPABILITIES.md` (fuera
|
||||||
|
del repo) y `docs/capabilities/comfyui-overview.md` (versionable). Este documento añade la pata
|
||||||
|
que faltaba: **qué pasa con el asset una vez generado**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mapa de ComfyUI (origen de los assets)
|
||||||
|
|
||||||
|
ComfyUI vive en `~/ComfyUI/` (clon del repo, no versionado en `fn_registry`). Las carpetas
|
||||||
|
relevantes para el puente son:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/ComfyUI/
|
||||||
|
├── output/ # ★ AQUÍ caen TODOS los assets generados
|
||||||
|
│ ├── *.png # imágenes (sprites, pixelart, tiles) — ~150 hoy
|
||||||
|
│ ├── *.webp # spritesheets animados / vídeo corto (SaveAnimatedWEBP)
|
||||||
|
│ ├── *.mp4 # vídeo (SaveVideo)
|
||||||
|
│ ├── *.glb / *.obj / *.ply # mallas 3D (SaveGLB)
|
||||||
|
│ └── 3D/ # subcarpeta de salidas 3D (texturizadas, multi-vista)
|
||||||
|
├── input/ # imágenes de entrada (img2img, image-to-3d)
|
||||||
|
├── models/ + /mnt/2tb/comfyui_models/ # checkpoints/loras/vae/... (vía extra_model_paths.yaml)
|
||||||
|
├── user/default/workflows/ # grafos UI, agrupados por capacidad (01_txt2img, 02_img2img, …)
|
||||||
|
├── skills_library/<slug>/ # recetas de estilo reproducibles (recipe.json + samples)
|
||||||
|
└── custom_nodes/ # nodos extra (PixelArt-Detector, IPAdapter, Hunyuan3D, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`output/` es el único punto de origen del puente.** Cada nodo `Save*` escribe ahí con un
|
||||||
|
patrón de nombre `<prefijo>_NNNNN_.<ext>` (sufijo numérico de 5 dígitos). Ejemplos reales en
|
||||||
|
disco hoy: `bench160_3000_00001_.png`, `svd_motion_hi_00001_.webp`, `3d_robot_mesh_00001_.glb`,
|
||||||
|
`output/3D/character_clean_textured_00001_.glb`.
|
||||||
|
- **Modelos centralizados** en `/mnt/2tb/comfyui_models/` vía `extra_model_paths.yaml`
|
||||||
|
(`is_default: true`). No intervienen en el puente (son insumo de generación, no asset de salida).
|
||||||
|
- **Tipos que generamos hoy** (recuento real del `output/`): `png` (mayoría), `glb` (15), `mp4`
|
||||||
|
(6), `webp` (2), `obj`/`ply` (formatos 3D crudos). **Audio aún no se genera en ComfyUI** (no hay
|
||||||
|
`wav`/`ogg`/`mp3` en `output/`); el plan de audio existe como report aparte. El puente lo
|
||||||
|
contempla igualmente porque Godot lo consume y porque el audio puede llegar de otra fuente.
|
||||||
|
- Catálogo navegable de qué sabemos generar y con qué función/grafo: `~/ComfyUI/CAPABILITIES.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mapa de Godot (destino de los assets)
|
||||||
|
|
||||||
|
Los proyectos viven en `~/gamedev/projects/` (fuera de `fn_registry`, igual que ComfyUI). Hay una
|
||||||
|
**biblioteca maestra** de assets en `~/gamedev/assets/` y, por proyecto, una copia local de los
|
||||||
|
assets que usa. Proyectos reales localizados:
|
||||||
|
|
||||||
|
- `~/gamedev/projects/crossy_road/` (juego "LizardRoad", Godot 4.7, móvil portrait 640×1280)
|
||||||
|
- `~/gamedev/projects/risk/` (pilotaje previo)
|
||||||
|
|
||||||
|
Estructura canónica de un proyecto Godot 4 (tomada de `crossy_road`, que es el patrón real):
|
||||||
|
|
||||||
|
```
|
||||||
|
~/gamedev/projects/crossy_road/
|
||||||
|
├── project.godot # config del proyecto (nombre, autoloads, rendering, display)
|
||||||
|
├── .godot/ # ★ caché de import (regenerable) — NUNCA se versiona ni se toca a mano
|
||||||
|
│ └── imported/ # binarios .ctex/.sample/... generados desde los .import
|
||||||
|
├── assets/ # assets del proyecto (copia local de la biblioteca)
|
||||||
|
│ ├── biomas/ agua.png + agua.png.import (par obligatorio por cada asset)
|
||||||
|
│ ├── kenney/ packs CC0
|
||||||
|
│ ├── external/ otros CC0
|
||||||
|
│ └── audio/sfx/ step.wav + step.wav.import
|
||||||
|
├── scenes/ *.tscn (escenas)
|
||||||
|
├── scripts/ *.gd + *.gd.uid
|
||||||
|
├── addons/godot_ai/ addon del MCP (control del editor desde Claude)
|
||||||
|
└── export_presets.cfg / android/ (build móvil)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cómo importa Godot 4 cada asset — el archivo `.import`
|
||||||
|
|
||||||
|
**Regla de oro:** en Godot, **cada asset es un par `<archivo>` + `<archivo>.import`**. El
|
||||||
|
`.import` es un INI que declara el `importer`, el `type` de recurso resultante, un `uid://`
|
||||||
|
estable y los parámetros de importación. Godot genera el binario importado en `.godot/imported/`
|
||||||
|
y lo regenera al reimportar. **Romper o desincronizar el `.import` = el asset no carga o se
|
||||||
|
reimporta con settings por defecto.** Por eso el puente debe respetar este par.
|
||||||
|
|
||||||
|
Ejemplos reales de `.import` por tipo (de `crossy_road`):
|
||||||
|
|
||||||
|
**Textura** (`importer="texture"`, `type="CompressedTexture2D"`):
|
||||||
|
```ini
|
||||||
|
[remap]
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cedkstexk3ciw"
|
||||||
|
path="res://.godot/imported/agua.png-<hash>.ctex"
|
||||||
|
[params]
|
||||||
|
compress/mode=0 ; 0=Lossless (correcto para pixelart), 2=VRAM
|
||||||
|
mipmaps/generate=false ; off para 2D/pixelart
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audio WAV** (`importer="wav"`, `type="AudioStreamWAV"`):
|
||||||
|
```ini
|
||||||
|
[remap]
|
||||||
|
importer="wav"
|
||||||
|
type="AudioStreamWAV"
|
||||||
|
[params]
|
||||||
|
edit/loop_mode=0 ; 0=Disabled (sfx), 1=Forward (música en bucle)
|
||||||
|
compress/mode=2
|
||||||
|
force/mono=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Malla 3D GLB** (`importer="scene"`, produce `PackedScene`): el GLB se importa como escena glTF;
|
||||||
|
sus opciones (escala raíz, generación de colisión, manejo de materiales/texturas) viven en el
|
||||||
|
`.import` de tipo `scene`. No había ninguno en los proyectos actuales (son 2D), pero es el
|
||||||
|
importer canónico de Godot para `.glb`/`.gltf`.
|
||||||
|
|
||||||
|
### El gotcha del filtro de textura (Godot 4 ≠ Godot 3)
|
||||||
|
|
||||||
|
En **Godot 4** el filtro Nearest/Linear **no es un campo del `.import` por defecto** (en Godot 3
|
||||||
|
sí lo era). El filtro se controla de dos formas:
|
||||||
|
|
||||||
|
1. **Global del proyecto (recomendado, KISS):** Project Settings → Rendering → Textures →
|
||||||
|
Canvas Textures → Default Texture Filter → **Nearest**. En `project.godot` es la clave
|
||||||
|
`rendering/textures/canvas_textures/default_texture_filter=0` (0 = Nearest). Tras cambiarlo hay
|
||||||
|
que **reimportar** las texturas.
|
||||||
|
2. **Override por asset:** en el panel Import de una textura concreta, Texture > Filter →
|
||||||
|
`Nearest` (override del default). Esto sí escribe la opción en su `.import`.
|
||||||
|
|
||||||
|
> **Hallazgo (read-only) en `crossy_road`:** su `project.godot` **no** declara
|
||||||
|
> `default_texture_filter`, así que usa el default **Linear** → cualquier asset pixelart se ve
|
||||||
|
> **borroso** (sus `biomas/*.png` tienen `mipmaps/generate=false` y `compress/mode=0`, correcto,
|
||||||
|
> pero les falta el Nearest). Para un proyecto pixelart, setear el global una vez es el primer
|
||||||
|
> paso del puente. (No se modificó nada; queda anotado para el constructor.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. El puente: convención de carpetas destino en Godot
|
||||||
|
|
||||||
|
Convención propuesta para la copia local de assets dentro de cada proyecto, alineada con lo que
|
||||||
|
ya existe (`assets/audio/sfx/`, `assets/kenney/`) y extendida a todos los tipos que generamos:
|
||||||
|
|
||||||
|
```
|
||||||
|
res://assets/
|
||||||
|
├── sprites/ # PNG individuales: personajes, props, objetos sueltos
|
||||||
|
├── tilesets/ # PNG de tiles + el recurso TileSet .tres derivado
|
||||||
|
├── vfx/ # spritesheets / animaciones (WEBP, o PNG en grid) → SpriteFrames/AtlasTexture
|
||||||
|
├── audio/
|
||||||
|
│ ├── sfx/ # efectos cortos (WAV) — loop OFF
|
||||||
|
│ └── music/ # música (OGG/WAV) — loop ON
|
||||||
|
├── models/ # mallas 3D GLB (+ sus texturas/materiales)
|
||||||
|
└── _generated/ # opcional: zona de aterrizaje de lo recién traído de ComfyUI, antes de clasificar
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`pixelart` no es una carpeta**, es un *atributo transversal*: un sprite, tile o VFX puede ser
|
||||||
|
pixelart. Lo que cambia es el import setting (Nearest + Lossless), no la ubicación. El pixelart
|
||||||
|
va a `sprites/` / `tilesets/` / `vfx/` según su rol, y el proyecto entero se marca Nearest a
|
||||||
|
nivel global cuando es un juego pixelart.
|
||||||
|
- **Biblioteca maestra vs copia local:** `~/gamedev/assets/` es la fuente ordenada; cada proyecto
|
||||||
|
guarda dentro su copia (`<proyecto>/assets/...`) porque Godot referencia por rutas `res://`
|
||||||
|
relativas a la raíz del proyecto. El puente copia de `~/ComfyUI/output/` → bien a la biblioteca
|
||||||
|
maestra, bien directo a un proyecto.
|
||||||
|
|
||||||
|
### Naming y versionado
|
||||||
|
|
||||||
|
- ComfyUI nombra `<prefijo>_NNNNN_.<ext>` (p. ej. `svd_motion_hi_00001_.webp`). Al exportar,
|
||||||
|
**renombrar a snake_case limpio y semántico** quitando el sufijo `_NNNNN_` y los guiones bajos
|
||||||
|
de cola: `svd_motion_hi_00001_.webp` → `explosion_loop.webp`.
|
||||||
|
- Sin espacios ni mayúsculas en nombres de archivo (consistencia con `res://` y multiplataforma).
|
||||||
|
- Versionado: sufijo opcional `_vN` cuando se itera un asset que ya está en uso
|
||||||
|
(`hero_idle.png` → `hero_idle_v2.png`), para no pisar el `uid://` del que ya referencian escenas.
|
||||||
|
**Nunca** sobrescribir un asset en uso sin querer: cambia su contenido pero conserva su `uid`,
|
||||||
|
lo que puede ser deseable (hot-swap) o no (regresión visual). Decisión explícita por asset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tabla: tipo de asset ComfyUI → carpeta Godot → import settings clave
|
||||||
|
|
||||||
|
| Tipo (ComfyUI) | Ext salida | Carpeta Godot destino | Importer Godot (`type`) | Import settings clave |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Sprite individual** | `.png` | `res://assets/sprites/` | `texture` → `CompressedTexture2D` | `compress/mode=0` (Lossless); `mipmaps/generate=false`; repeat off; filtro = default del proyecto (Linear si arte vectorial, Nearest si pixelart) |
|
||||||
|
| **Pixelart** (sprite/tile) | `.png` | `sprites/` o `tilesets/` (según rol) | `texture` → `CompressedTexture2D` | **CRÍTICO: Nearest** (global `default_texture_filter=0` o override por asset); `compress/mode=0` Lossless; `mipmaps/generate=false`; repeat off |
|
||||||
|
| **Tileset** | `.png` | `res://assets/tilesets/` | `texture` + recurso **`TileSet` (.tres)** manual/script | Nearest (suele ser pixelart); además crear `TileSet` con tamaño de celda (region grid) — Godot **no** deriva el TileSet automáticamente del PNG |
|
||||||
|
| **VFX spritesheet** | `.webp` / `.png` (grid) | `res://assets/vfx/` | `texture` → **`SpriteFrames`** (AnimatedSprite2D) o **`AtlasTexture`** por frame | Nearest si pixelart; definir `hframes`/`vframes` o regiones por frame; Godot **no** convierte un sheet a `SpriteFrames` solo: se hace en editor o por script de import |
|
||||||
|
| **Audio SFX** | `.wav` | `res://assets/audio/sfx/` | `wav` → `AudioStreamWAV` | `edit/loop_mode=0` (Disabled); `compress/mode` según peso |
|
||||||
|
| **Audio música** | `.ogg` / `.wav` | `res://assets/audio/music/` | `oggvorbisstr` (OGG) / `wav` | **loop ON** (`edit/loop_mode=1` en WAV; `loop=true` en OGG) |
|
||||||
|
| **Malla 3D** | `.glb` (preferido) | `res://assets/models/` | `scene` → `PackedScene` (glTF) | escala raíz coherente; generar colisión si se necesita; si la textura es pixelart, poner el material en Nearest; preferir `.glb` sobre `.obj`/`.ply` (los lleva embebidos) |
|
||||||
|
|
||||||
|
Notas que la tabla condensa:
|
||||||
|
|
||||||
|
- **Godot no automatiza dos conversiones clave:** (1) PNG de tiles → recurso `TileSet`, y (2)
|
||||||
|
spritesheet → `SpriteFrames`. Ambas requieren un paso manual en editor o un script de import
|
||||||
|
(o un addon, p. ej. el TexturePacker importer / aseprite-importers). El puente puede generar el
|
||||||
|
`.tres` por script a partir de la geometría conocida del grid.
|
||||||
|
- **WEBP animado (SVD):** nuestros `svd_*.webp` son clips de Stable Video Diffusion. Para usarlos
|
||||||
|
como animación 2D en Godot, lo robusto es **descomponer en frames** (PNG en grid) y construir
|
||||||
|
`SpriteFrames`, no cargar el WEBP animado tal cual.
|
||||||
|
- **3D pixelart/low-poly:** el GLB importa como escena; cuidar que el material no aplique filtro
|
||||||
|
Linear a una textura pixelart (se setea en el material/import de la malla).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Propuesta de función(es) del registry (diseño, NO implementación)
|
||||||
|
|
||||||
|
Búsqueda en el registry: `mcp__registry__fn_search query="godot export asset import"` → **0
|
||||||
|
resultados**. Gap limpio. Hoy llevar un asset de ComfyUI a Godot es manual (copiar + abrir editor
|
||||||
|
+ tocar import a mano). Alineado con la doctrina del registry (registry-first + crecer por
|
||||||
|
composición de helpers atómicos, issue 0087), la propuesta es **un pipeline one-shot que compone
|
||||||
|
helpers pequeños**:
|
||||||
|
|
||||||
|
### Pipeline principal
|
||||||
|
|
||||||
|
```
|
||||||
|
export_asset_to_godot_py_pipelines (impura, kind=pipeline, tag de grupo: godot, comfyui)
|
||||||
|
|
||||||
|
Firma:
|
||||||
|
export_asset_to_godot(
|
||||||
|
asset_path: str, # ruta en ~/ComfyUI/output/ (o cualquier archivo)
|
||||||
|
kind: str, # "sprite" | "pixelart" | "tileset" | "vfx" | "sfx" | "music" | "model"
|
||||||
|
godot_project: str, # ruta raíz del proyecto Godot destino
|
||||||
|
dest_name: str = "", # nombre limpio destino (default: snake_case del origen sin _NNNNN_)
|
||||||
|
pixelart: bool = False, # fuerza Nearest + Lossless en la textura
|
||||||
|
loop: bool = False, # audio en bucle (música)
|
||||||
|
reimport: bool = True, # lanza reimport headless al final
|
||||||
|
) -> dict # {dest_res_path, import_written, reimported, warnings[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Comportamiento:
|
||||||
|
1. Resolver la carpeta destino por `kind` (tabla §4) dentro de `res://assets/`.
|
||||||
|
2. Copiar el archivo con nombre limpio (snake_case, sin sufijo `_NNNNN_`).
|
||||||
|
3. Escribir/asegurar el `.import` adecuado al tipo (texture/wav/scene) con los settings clave.
|
||||||
|
4. Si `pixelart=True`, además asegurar el global del proyecto
|
||||||
|
(`default_texture_filter=0` en `project.godot`) o el override por asset.
|
||||||
|
5. Si `reimport=True`, lanzar reimport headless para que Godot regenere `.godot/imported/`.
|
||||||
|
6. Devolver el `res://` final + avisos (p. ej. "tileset copiado pero falta crear el TileSet .tres",
|
||||||
|
"WEBP animado: descomponer en frames antes de SpriteFrames").
|
||||||
|
|
||||||
|
### Helpers atómicos que compone (delegar a `fn-constructor` en paralelo)
|
||||||
|
|
||||||
|
| Helper (id tentativo) | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `godot_asset_dest_dir_py_core` | pura | mapea `kind` → subdir de `res://assets/` (tabla §4) |
|
||||||
|
| `godot_clean_asset_name_py_core` | pura | quita sufijo `_NNNNN_`, normaliza a snake_case, sin espacios |
|
||||||
|
| `godot_write_texture_import_py_infra` | impura | escribe `.import` de textura (compress/mipmaps/filter) preservando `uid` si ya existe |
|
||||||
|
| `godot_write_audio_import_py_infra` | impura | escribe `.import` de audio (loop_mode) |
|
||||||
|
| `godot_ensure_pixelart_project_py_infra` | impura | setea `default_texture_filter=0` en `project.godot` (idempotente) |
|
||||||
|
| `godot_reimport_headless_bash_infra` | impura | `godot --headless --path <proj> --import` para regenerar la caché |
|
||||||
|
| `godot_build_spriteframes_tres_py_infra` | impura | genera `.tres` de `SpriteFrames`/`AtlasTexture` a partir de un sheet + geometría de grid |
|
||||||
|
| `godot_build_tileset_tres_py_infra` | impura | genera `.tres` de `TileSet` a partir de un PNG + tamaño de celda |
|
||||||
|
|
||||||
|
### DoD esbozado (según `dod_quality.md`)
|
||||||
|
|
||||||
|
- **Golden:** `export_asset_to_godot("~/ComfyUI/output/hero.png", "pixelart",
|
||||||
|
"~/gamedev/projects/crossy_road")` deja `res://assets/sprites/hero.png` + su `.import` con
|
||||||
|
Nearest + Lossless, el `project.godot` con `default_texture_filter=0`, y la reimport headless
|
||||||
|
sale con exit 0; assert: el `.import` contiene el override Nearest y el binario aparece en
|
||||||
|
`.godot/imported/`.
|
||||||
|
- **Edge 1 (audio música):** `kind="music", loop=True` → `.import` con `edit/loop_mode=1`.
|
||||||
|
- **Edge 2 (GLB 3D):** `kind="model"` copia a `res://assets/models/` y la escena glTF carga
|
||||||
|
(reimport sin error).
|
||||||
|
- **Edge 3 (tileset):** copia el PNG y **avisa** que falta el `.tres` del TileSet (o lo genera con
|
||||||
|
`godot_build_tileset_tres`), sin romper nada.
|
||||||
|
- **Error path:** `kind` desconocido → error claro sin copiar nada; `godot_project` sin
|
||||||
|
`project.godot` → aborta y no escribe; nunca sobrescribe un asset en uso sin `dest_name`
|
||||||
|
explícito.
|
||||||
|
- **Idempotencia:** re-exportar el mismo asset preserva el `uid://` existente (no rompe las
|
||||||
|
referencias de las escenas que ya lo usan).
|
||||||
|
|
||||||
|
### ¿Automatizar el import vía MCP godot-ai?
|
||||||
|
|
||||||
|
El addon `godot_ai` (MCP, server `127.0.0.1:8000/mcp`) está presente en `crossy_road` y `risk`.
|
||||||
|
Con el editor abierto, `filesystem_manage` op `reimport` (recibe `paths`) puede forzar reimport
|
||||||
|
desde el editor vivo. **Pero** la convención (`CONVENTIONS.md` de gamedev) ya observa que el
|
||||||
|
reimport por MCP suele ser **innecesario**: `project_run` recompila desde disco al arrancar, y un
|
||||||
|
`godot --headless --import` regenera la caché sin editor abierto. **Recomendación:** el puente usa
|
||||||
|
**reimport headless por CLI** (`godot_reimport_headless_bash_infra`) como mecanismo por defecto
|
||||||
|
(no requiere editor abierto ni MCP), y deja el MCP como opción cuando el editor ya está vivo y se
|
||||||
|
quiere refrescar en caliente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Resumen operativo (TL;DR)
|
||||||
|
|
||||||
|
1. **Origen:** todo asset generado cae en `~/ComfyUI/output/` (PNG/WEBP/MP4/GLB).
|
||||||
|
2. **Destino:** `res://assets/{sprites,tilesets,vfx,audio/{sfx,music},models}/` dentro del
|
||||||
|
proyecto Godot; cada asset es un par `archivo` + `archivo.import`.
|
||||||
|
3. **Import por tipo:** textura (Lossless, mipmaps off, **Nearest si pixelart**), audio (loop
|
||||||
|
off=sfx / on=música), GLB (escena glTF). Tabla §4.
|
||||||
|
4. **Gotcha Godot 4:** el Nearest pixelart se setea **global** (`default_texture_filter=0`) o por
|
||||||
|
override de asset — **no** es un flag por defecto del `.import`. `crossy_road` hoy está en
|
||||||
|
Linear (pixelart borroso): anotado.
|
||||||
|
5. **Godot no automatiza** PNG→TileSet ni sheet→SpriteFrames: paso manual o script de import.
|
||||||
|
6. **Automatización:** pipeline `export_asset_to_godot` (gap confirmado, 0 funciones hoy) que
|
||||||
|
compone helpers atómicos + reimport headless. Diseño en §5; implementación se delega a
|
||||||
|
`fn-constructor`.
|
||||||
|
|
||||||
|
## Fuentes
|
||||||
|
|
||||||
|
- ComfyUI: `~/ComfyUI/CAPABILITIES.md`, `~/ComfyUI/extra_model_paths.yaml`, listado de
|
||||||
|
`~/ComfyUI/output/` (read-only).
|
||||||
|
- Godot: `~/gamedev/CONVENTIONS.md`, `~/gamedev/README.md`,
|
||||||
|
`~/gamedev/projects/crossy_road/project.godot` y sus `*.import` (read-only).
|
||||||
|
- Godot 4 pixel art texture filter: [GDQuest — Setting up pixel art graphics in Godot 4](https://www.gdquest.com/library/pixel_art_setup_godot4/),
|
||||||
|
[Godot Forum — How to import pixel art in Godot 4](https://forum.godotengine.org/t/how-to-import-pixel-art-in-godot-4/7105).
|
||||||
|
- Godot 4 sprite sheets: [Godot docs — 2D sprite animation](https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html),
|
||||||
|
[godot-4-aseprite-importers](https://github.com/nklbdev/godot-4-aseprite-importers).
|
||||||
@@ -30,6 +30,7 @@ type auditFnMeta struct {
|
|||||||
domain string
|
domain string
|
||||||
lang string
|
lang string
|
||||||
signature string
|
signature string
|
||||||
|
filePath string // registry-relative path to the .go source (Go funcs only)
|
||||||
}
|
}
|
||||||
|
|
||||||
// skipDirs are directory names ignored when walking source for audits.
|
// skipDirs are directory names ignored when walking source for audits.
|
||||||
@@ -80,15 +81,16 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
|||||||
return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err)
|
return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all Go/Python/TS functions from registry: id → name, domain, lang, signature.
|
// Load all Go/Python/TS functions from registry: id → name, domain, lang,
|
||||||
rows, err := db.Query(`SELECT id, name, domain, lang, COALESCE(signature, '') FROM functions WHERE lang IN ('go','py','ts')`)
|
// signature, file_path. file_path feeds the Go .go fallback (see auditGoApp).
|
||||||
|
rows, err := db.Query(`SELECT id, name, domain, lang, COALESCE(signature, ''), COALESCE(file_path, '') FROM functions WHERE lang IN ('go','py','ts')`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err)
|
return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err)
|
||||||
}
|
}
|
||||||
allFunctions := make(map[string]auditFnMeta) // id → meta
|
allFunctions := make(map[string]auditFnMeta) // id → meta
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m auditFnMeta
|
var m auditFnMeta
|
||||||
if err := rows.Scan(&m.id, &m.name, &m.domain, &m.lang, &m.signature); err != nil {
|
if err := rows.Scan(&m.id, &m.name, &m.domain, &m.lang, &m.signature, &m.filePath); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
allFunctions[m.id] = m
|
allFunctions[m.id] = m
|
||||||
@@ -144,7 +146,7 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
|||||||
|
|
||||||
switch app.lang {
|
switch app.lang {
|
||||||
case "go":
|
case "go":
|
||||||
importedIDs = append(importedIDs, auditGoApp(absDir, allFunctions)...)
|
importedIDs = append(importedIDs, auditGoApp(absDir, allFunctions, registryRoot)...)
|
||||||
scannedLangs["go"] = true
|
scannedLangs["go"] = true
|
||||||
case "py":
|
case "py":
|
||||||
importedIDs = append(importedIDs, auditPyApp(absDir, allFunctions)...)
|
importedIDs = append(importedIDs, auditPyApp(absDir, allFunctions)...)
|
||||||
@@ -197,11 +199,18 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
|||||||
// Strategy:
|
// Strategy:
|
||||||
// 1. Find all "fn-registry/functions/<domain>" import paths (production code only).
|
// 1. Find all "fn-registry/functions/<domain>" import paths (production code only).
|
||||||
// 2. For each domain, collect registry functions in that domain.
|
// 2. For each domain, collect registry functions in that domain.
|
||||||
// 3. Grep source files for the exported symbol. The token tried first is the
|
// 3. Grep source files for the exported symbol. Tokens tried, in order:
|
||||||
// real Go func identifier parsed from the registry signature; fallback is
|
// a) the real Go func identifier parsed from the registry signature;
|
||||||
// PascalCase(name). Many functions deviate (e.g. sqlite_column_exists has
|
// b) PascalCase(name) (with commonAbbrevs);
|
||||||
// `func ColumnExists`), so signature is the source of truth.
|
// c) the real exported func read straight from the function's .go file.
|
||||||
func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
|
//
|
||||||
|
// Many functions deviate from snake_case→PascalCase (e.g. sqlite_column_exists
|
||||||
|
// has `func ColumnExists`, wails_bind_crud has `func GenerateWailsCRUD`). The
|
||||||
|
// signature is usually the source of truth, but some signatures omit the `func`
|
||||||
|
// keyword or list a different primary symbol; step (c) reads the .go file as a
|
||||||
|
// last-resort fallback so those cases stop being false positives ("unused").
|
||||||
|
// The .go read is cached per execution to avoid reopening the same file.
|
||||||
|
func auditGoApp(appDir string, all map[string]auditFnMeta, registryRoot string) []string {
|
||||||
// Step 1: collect imported domains.
|
// Step 1: collect imported domains.
|
||||||
importedDomains := collectGoImportedDomains(appDir)
|
importedDomains := collectGoImportedDomains(appDir)
|
||||||
if len(importedDomains) == 0 {
|
if len(importedDomains) == 0 {
|
||||||
@@ -216,6 +225,10 @@ func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache for the .go fallback: registry file_path → real exported func name.
|
||||||
|
// Populated lazily, only when the cheaper tokens fail to match.
|
||||||
|
goFileSymbolCache := make(map[string]string)
|
||||||
|
|
||||||
for _, m := range all {
|
for _, m := range all {
|
||||||
if m.lang != "go" {
|
if m.lang != "go" {
|
||||||
continue
|
continue
|
||||||
@@ -223,17 +236,76 @@ func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
|
|||||||
if !importedDomains[m.domain] {
|
if !importedDomains[m.domain] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tokens := goCandidateTokens(m)
|
matched := false
|
||||||
for _, tok := range tokens {
|
for _, tok := range goCandidateTokens(m) {
|
||||||
if containsToken(blob, tok) {
|
if containsToken(blob, tok) {
|
||||||
used = append(used, m.id)
|
matched = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !matched && goSignatureSymbol(m) == "" {
|
||||||
|
// Fallback (c): read the registry .go file and look for the real
|
||||||
|
// exported func name. Gated on an EMPTY signature symbol on purpose:
|
||||||
|
// when the signature already yields a concrete `func <Name>` it is the
|
||||||
|
// authoritative symbol, so reading the .go (which can only guess the
|
||||||
|
// file's first exported func) must not override it. Several registry
|
||||||
|
// functions share one .go file via the "TU adicional" pattern (e.g.
|
||||||
|
// cdp_new_tab lives in cdp_list_tabs.go); without this gate the first
|
||||||
|
// func would be mis-attributed to every sibling and suppress real
|
||||||
|
// "unused" findings. The file read therefore only happens for the rare
|
||||||
|
// functions whose stored signature omits the `func` keyword.
|
||||||
|
if sym := goRealExportedName(registryRoot, m.filePath, goFileSymbolCache); sym != "" {
|
||||||
|
if containsToken(blob, sym) {
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
used = append(used, m.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return used
|
return used
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// goRealExportedFnRe matches a top-level exported func declaration in a .go
|
||||||
|
// source file: `func Name(` or the generic form `func Name[T any](`. It captures
|
||||||
|
// the func identifier. Method declarations (`func (r *T) Name(`) are skipped on
|
||||||
|
// purpose — a registry function's primary symbol is a top-level func, and method
|
||||||
|
// names would risk spurious matches. Used by the .go fallback to recover the real
|
||||||
|
// symbol name when the registry signature/name heuristics fail.
|
||||||
|
var goRealExportedFnRe = regexp.MustCompile(`^func\s+([A-Z][A-Za-z0-9_]*)\s*[\(\[]`)
|
||||||
|
|
||||||
|
// goRealExportedName reads the registry .go file at filePath (relative to
|
||||||
|
// registryRoot) and returns the first exported func identifier found. Results
|
||||||
|
// are memoised in cache (filePath → symbol, "" when the file is unreadable or
|
||||||
|
// has no exported func) so a file is opened at most once per audit run.
|
||||||
|
func goRealExportedName(registryRoot, filePath string, cache map[string]string) string {
|
||||||
|
if filePath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if sym, ok := cache[filePath]; ok {
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
cache[filePath] = "" // pre-seed so an unreadable file is not retried
|
||||||
|
abs := filePath
|
||||||
|
if !filepath.IsAbs(abs) {
|
||||||
|
abs = filepath.Join(registryRoot, filePath)
|
||||||
|
}
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
if m := goRealExportedFnRe.FindStringSubmatch(sc.Text()); m != nil {
|
||||||
|
cache[filePath] = m[1]
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// goCandidateTokens returns the identifiers we try when looking for usages
|
// goCandidateTokens returns the identifiers we try when looking for usages
|
||||||
// of a Go function in source. Real exported name from signature first,
|
// of a Go function in source. Real exported name from signature first,
|
||||||
// PascalCase(name) as fallback.
|
// PascalCase(name) as fallback.
|
||||||
@@ -241,10 +313,8 @@ var goSignatureFnRe = regexp.MustCompile(`^\s*func\s+(?:\([^)]*\)\s+)?([A-Z][A-Z
|
|||||||
|
|
||||||
func goCandidateTokens(m auditFnMeta) []string {
|
func goCandidateTokens(m auditFnMeta) []string {
|
||||||
out := []string{}
|
out := []string{}
|
||||||
if m.signature != "" {
|
if sym := goSignatureSymbol(m); sym != "" {
|
||||||
if match := goSignatureFnRe.FindStringSubmatch(m.signature); match != nil {
|
out = append(out, sym)
|
||||||
out = append(out, match[1])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pascal := snakeToPascal(m.name)
|
pascal := snakeToPascal(m.name)
|
||||||
if pascal != "" && (len(out) == 0 || out[0] != pascal) {
|
if pascal != "" && (len(out) == 0 || out[0] != pascal) {
|
||||||
@@ -253,6 +323,21 @@ func goCandidateTokens(m auditFnMeta) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// goSignatureSymbol returns the exported Go identifier parsed from the registry
|
||||||
|
// signature (`func Name(...)` or `func (r *T) Name(...)`), or "" when the
|
||||||
|
// signature is empty or does not start with a `func` declaration. A non-empty
|
||||||
|
// result is the authoritative symbol for the function and gates off the .go
|
||||||
|
// fallback in auditGoApp.
|
||||||
|
func goSignatureSymbol(m auditFnMeta) string {
|
||||||
|
if m.signature == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if match := goSignatureFnRe.FindStringSubmatch(m.signature); match != nil {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// collectGoImportedDomains returns the set of registry domains imported by .go files.
|
// collectGoImportedDomains returns the set of registry domains imported by .go files.
|
||||||
var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`)
|
var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`)
|
||||||
|
|
||||||
@@ -452,6 +537,34 @@ var commonAbbrevs = map[string]string{
|
|||||||
"io": "IO",
|
"io": "IO",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"ui": "UI",
|
"ui": "UI",
|
||||||
|
// Issue 0057 — abbreviations verified consistent across the registry's own
|
||||||
|
// Go func names (each entry maps a real `func <Name>` deviation). These only
|
||||||
|
// improve the PascalCase fallback; the signature and the .go fallback remain
|
||||||
|
// the primary sources of truth. Deliberately NOT added because the registry
|
||||||
|
// itself is inconsistent for them (mapping would create more mismatches than
|
||||||
|
// it fixes): "cdp" (uses Cdp: CdpGetHTML, CdpNavigate — not CDP) and
|
||||||
|
// "pdf" (CdpPrintPDF vs PdfSimpleReport).
|
||||||
|
"ohlcv": "OHLCV",
|
||||||
|
"duckdb": "DuckDB",
|
||||||
|
"clickhouse": "ClickHouse",
|
||||||
|
"nordvpn": "NordVPN",
|
||||||
|
"sha256": "SHA256",
|
||||||
|
"md5": "MD5",
|
||||||
|
"ansi": "ANSI",
|
||||||
|
"cidr": "CIDR",
|
||||||
|
"aead": "AEAD",
|
||||||
|
"pty": "PTY",
|
||||||
|
"vps": "VPS",
|
||||||
|
"wg": "WG",
|
||||||
|
"vt": "VT",
|
||||||
|
"fft": "FFT",
|
||||||
|
"ema": "EMA",
|
||||||
|
"rsi": "RSI",
|
||||||
|
"sma": "SMA",
|
||||||
|
"vwap": "VWAP",
|
||||||
|
"ax": "AX",
|
||||||
|
"e2e": "E2E",
|
||||||
|
"urls": "URLs",
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasTSSources reports whether appDir contains any production .ts/.tsx files
|
// hasTSSources reports whether appDir contains any production .ts/.tsx files
|
||||||
|
|||||||
@@ -148,6 +148,273 @@ func main() { fmt.Println("hello") }
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSnakeToPascal_HandlesAbbreviations verifies the commonAbbrevs expansion
|
||||||
|
// (issue 0057, Fase 1). Each "want" is the exported Go symbol the registry
|
||||||
|
// actually uses for that snake_case name. It also pins the deliberate
|
||||||
|
// non-mappings (cdp, pdf): the registry's own convention is mixed-case there,
|
||||||
|
// so the abbreviation must NOT fire.
|
||||||
|
func TestSnakeToPascal_HandlesAbbreviations(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
// New abbreviations added by issue 0057 (verified against real func names).
|
||||||
|
{"fetch_ohlcv", "FetchOHLCV"},
|
||||||
|
{"normalize_ohlcv", "NormalizeOHLCV"},
|
||||||
|
{"duckdb_open", "DuckDBOpen"},
|
||||||
|
{"load_ohlcv_from_duckdb", "LoadOHLCVFromDuckDB"},
|
||||||
|
{"clickhouse_open", "ClickHouseOpen"},
|
||||||
|
{"nordvpn_container_run", "NordVPNContainerRun"},
|
||||||
|
{"parse_nordvpn_status", "ParseNordVPNStatus"},
|
||||||
|
{"hash_sha256", "HashSHA256"},
|
||||||
|
{"hash_md5", "HashMD5"},
|
||||||
|
{"strip_ansi", "StripANSI"},
|
||||||
|
{"parse_ip_cidr", "ParseIPCIDR"},
|
||||||
|
{"open_aead", "OpenAEAD"},
|
||||||
|
{"seal_aead", "SealAEAD"},
|
||||||
|
{"pty_capture_stream", "PTYCaptureStream"},
|
||||||
|
{"setup_vps_app", "SetupVPSApp"},
|
||||||
|
{"vps_setup_app", "VPSSetupApp"},
|
||||||
|
{"wg_keygen", "WGKeygen"},
|
||||||
|
{"wg_peer_add", "WGPeerAdd"},
|
||||||
|
{"vt_render", "VTRender"},
|
||||||
|
{"fft", "FFT"},
|
||||||
|
{"ema", "EMA"},
|
||||||
|
{"rsi", "RSI"},
|
||||||
|
{"sma", "SMA"},
|
||||||
|
{"vwap", "VWAP"},
|
||||||
|
{"cdp_get_ax_outline", "CdpGetAXOutline"},
|
||||||
|
{"audit_e2e_coverage", "AuditE2ECoverage"},
|
||||||
|
{"e2e_run_checks", "E2ERunChecks"},
|
||||||
|
{"extract_urls", "ExtractURLs"},
|
||||||
|
// Pre-existing abbreviations (regression guard — must keep working).
|
||||||
|
{"http_json_response", "HTTPJSONResponse"},
|
||||||
|
{"sqlite_open", "SQLiteOpen"},
|
||||||
|
{"random_hex_id", "RandomHexID"},
|
||||||
|
// Deliberate non-mappings: registry uses mixed-case (Cdp, Pdf) here, so
|
||||||
|
// the snake_case→Pascal conversion must leave them mixed-case. These are
|
||||||
|
// the cases the .go fallback (Fase 2) and the signature path cover.
|
||||||
|
{"cdp_get_html", "CdpGetHTML"},
|
||||||
|
{"cdp_navigate", "CdpNavigate"},
|
||||||
|
{"pdf_simple_report", "PdfSimpleReport"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := snakeToPascal(c.in); got != c.want {
|
||||||
|
t.Errorf("snakeToPascal(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goFallbackEnv builds a minimal registry.db + app on disk for the .go fallback
|
||||||
|
// test. The registry function gen_wails_crud_go_infra mimics wails_bind_crud:
|
||||||
|
// its signature omits the `func` keyword (so the signature regex misses) and its
|
||||||
|
// PascalCase("gen_wails_crud")="GenWailsCRUD" differs from the real exported
|
||||||
|
// symbol "GenerateWailsCRUD". The app calls the real symbol. When writeFnFile is
|
||||||
|
// true, the registry .go file exists and the fallback can recover the symbol.
|
||||||
|
func goFallbackEnv(t *testing.T, fnFilePath string, writeFnFile bool) UsesFunctionsAudit {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
dbPath := filepath.Join(root, "registry.db")
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE functions (id TEXT PRIMARY KEY, name TEXT, domain TEXT, lang TEXT, signature TEXT, file_path TEXT);
|
||||||
|
CREATE TABLE apps (id TEXT PRIMARY KEY, lang TEXT, dir_path TEXT, uses_functions TEXT DEFAULT '[]');
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(
|
||||||
|
`INSERT INTO functions (id,name,domain,lang,signature,file_path) VALUES (?,?,?,?,?,?)`,
|
||||||
|
"gen_wails_crud_go_infra", "gen_wails_crud", "infra", "go",
|
||||||
|
"GenerateWailsCRUD(spec WailsCRUDSpec) string", fnFilePath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(
|
||||||
|
`INSERT INTO apps (id,lang,dir_path,uses_functions) VALUES (?,?,?,?)`,
|
||||||
|
"myapp_go_infra", "go", "apps/myapp", `["gen_wails_crud_go_infra"]`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
if writeFnFile {
|
||||||
|
fnAbsDir := filepath.Join(root, filepath.Dir(fnFilePath))
|
||||||
|
if err := os.MkdirAll(fnAbsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := "package infra\n\ntype WailsCRUDSpec struct{}\n\nfunc GenerateWailsCRUD(spec WailsCRUDSpec) string { return \"\" }\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(root, fnFilePath), []byte(src), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir := filepath.Join(root, "apps", "myapp")
|
||||||
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
appSrc := "package main\n\nimport (\n\t\"fmt\"\n\t\"fn-registry/functions/infra\"\n)\n\nfunc main() {\n\tfmt.Println(infra.GenerateWailsCRUD(infra.WailsCRUDSpec{}))\n}\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(appSrc), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := AuditUsesFunctions(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
return results[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditUsesFunctions_GoFileFallback verifies the .go fallback (issue 0057,
|
||||||
|
// Fase 2): when neither the registry signature nor PascalCase(name) yields the
|
||||||
|
// real exported symbol, the auditor reads the function's .go file to recover it,
|
||||||
|
// so a genuinely-used function is not a false "unused". The error sub-case (file
|
||||||
|
// absent) shows the fallback degrades gracefully and the function is then
|
||||||
|
// correctly reported unused — proving the fallback is load-bearing.
|
||||||
|
func TestAuditUsesFunctions_GoFileFallback(t *testing.T) {
|
||||||
|
t.Run("golden: .go fallback recovers real symbol -> not unused", func(t *testing.T) {
|
||||||
|
got := goFallbackEnv(t, "functions/infra/gen_wails_crud.go", true)
|
||||||
|
if len(got.Unused) != 0 {
|
||||||
|
t.Errorf("Unused = %v, want [] (fallback should find GenerateWailsCRUD)", got.Unused)
|
||||||
|
}
|
||||||
|
if len(got.Missing) != 0 {
|
||||||
|
t.Errorf("Missing = %v, want []", got.Missing)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error: missing .go file -> flagged unused, no crash", func(t *testing.T) {
|
||||||
|
got := goFallbackEnv(t, "functions/infra/gen_wails_crud.go", false)
|
||||||
|
if len(got.Unused) != 1 || got.Unused[0] != "gen_wails_crud_go_infra" {
|
||||||
|
t.Errorf("Unused = %v, want [gen_wails_crud_go_infra] (no fallback file to read)", got.Unused)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditUsesFunctions_SharedGoFileNotMisattributed pins the regression caught
|
||||||
|
// during issue 0057 verification: several registry functions can share one .go
|
||||||
|
// file (the "TU adicional" pattern, e.g. cdp_new_tab living in cdp_list_tabs.go).
|
||||||
|
// Because they have valid signatures, the .go fallback must stay GATED OFF for
|
||||||
|
// them — otherwise the file's first exported func (here ListTabs) would be
|
||||||
|
// mis-attributed to a sibling (NewTab) and suppress a genuine "unused" finding.
|
||||||
|
// The app below uses only ListTabs; NewTab must remain flagged unused.
|
||||||
|
func TestAuditUsesFunctions_SharedGoFileNotMisattributed(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dbPath := filepath.Join(root, "registry.db")
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE functions (id TEXT PRIMARY KEY, name TEXT, domain TEXT, lang TEXT, signature TEXT, file_path TEXT);
|
||||||
|
CREATE TABLE apps (id TEXT PRIMARY KEY, lang TEXT, dir_path TEXT, uses_functions TEXT DEFAULT '[]');
|
||||||
|
INSERT INTO functions (id,name,domain,lang,signature,file_path) VALUES
|
||||||
|
('list_tabs_go_browser','list_tabs','browser','go','func ListTabs() error','functions/browser/tabs.go'),
|
||||||
|
('new_tab_go_browser','new_tab','browser','go','func NewTab() error','functions/browser/tabs.go');
|
||||||
|
INSERT INTO apps (id,lang,dir_path,uses_functions) VALUES
|
||||||
|
('tabsapp_go_browser','go','apps/tabsapp','["list_tabs_go_browser","new_tab_go_browser"]');
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
// Shared registry .go file: ListTabs is the FIRST exported func.
|
||||||
|
fnDir := filepath.Join(root, "functions", "browser")
|
||||||
|
if err := os.MkdirAll(fnDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tabsSrc := "package browser\n\nfunc ListTabs() error { return nil }\n\nfunc NewTab() error { return nil }\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(fnDir, "tabs.go"), []byte(tabsSrc), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App calls only ListTabs, but declares both.
|
||||||
|
appDir := filepath.Join(root, "apps", "tabsapp")
|
||||||
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
appSrc := "package main\n\nimport (\n\t\"fmt\"\n\t\"fn-registry/functions/browser\"\n)\n\nfunc main() {\n\tfmt.Println(browser.ListTabs())\n}\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(appSrc), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := AuditUsesFunctions(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
got := results[0]
|
||||||
|
if len(got.Unused) != 1 || got.Unused[0] != "new_tab_go_browser" {
|
||||||
|
t.Errorf("Unused = %v, want [new_tab_go_browser] (sibling must NOT rescue via shared file)", got.Unused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoRealExportedName verifies the .go symbol extractor: top-level exported
|
||||||
|
// funcs (plain and generic) are recovered, method receivers are skipped, the
|
||||||
|
// result is cached, and unreadable/empty paths return "" without error.
|
||||||
|
func TestGoRealExportedName(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "functions", "infra"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// File whose first exported func is preceded by an unexported func + a method.
|
||||||
|
src := "package infra\n\n" +
|
||||||
|
"import \"fmt\"\n\n" +
|
||||||
|
"func helper() {}\n\n" +
|
||||||
|
"type T struct{}\n\n" +
|
||||||
|
"func (t *T) Save() {}\n\n" +
|
||||||
|
"func GenerateWailsCRUD(spec int) string { fmt.Println(spec); return \"\" }\n\n" +
|
||||||
|
"func WailsStreamData[X any](xs []X) {}\n"
|
||||||
|
rel := "functions/infra/sample.go"
|
||||||
|
if err := os.WriteFile(filepath.Join(root, rel), []byte(src), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cache := map[string]string{}
|
||||||
|
|
||||||
|
t.Run("golden: first top-level exported func (skips helper + method)", func(t *testing.T) {
|
||||||
|
if got := goRealExportedName(root, rel, cache); got != "GenerateWailsCRUD" {
|
||||||
|
t.Errorf("got %q, want GenerateWailsCRUD", got)
|
||||||
|
}
|
||||||
|
if cache[rel] != "GenerateWailsCRUD" {
|
||||||
|
t.Errorf("cache[%q] = %q, want GenerateWailsCRUD", rel, cache[rel])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("edge: generic func form func Name[T any](", func(t *testing.T) {
|
||||||
|
genRel := "functions/infra/gen.go"
|
||||||
|
genSrc := "package infra\n\nfunc WailsStreamData[X any](xs []X) {}\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(root, genRel), []byte(genSrc), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := goRealExportedName(root, genRel, cache); got != "WailsStreamData" {
|
||||||
|
t.Errorf("got %q, want WailsStreamData", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error: missing file -> empty string, cached", func(t *testing.T) {
|
||||||
|
missRel := "functions/infra/does_not_exist.go"
|
||||||
|
if got := goRealExportedName(root, missRel, cache); got != "" {
|
||||||
|
t.Errorf("got %q, want empty for missing file", got)
|
||||||
|
}
|
||||||
|
if v, ok := cache[missRel]; !ok || v != "" {
|
||||||
|
t.Errorf("missing file should be cached as empty, got ok=%v v=%q", ok, v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error: empty file_path -> empty string", func(t *testing.T) {
|
||||||
|
if got := goRealExportedName(root, "", cache); got != "" {
|
||||||
|
t.Errorf("got %q, want empty for empty path", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not
|
// TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not
|
||||||
// exist on disk get an entry with nil Missing/Unused slices (cannot inspect).
|
// exist on disk get an entry with nil Missing/Unused slices (cannot inspect).
|
||||||
func TestAuditUsesFunctions_MissingDir(t *testing.T) {
|
func TestAuditUsesFunctions_MissingDir(t *testing.T) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: cdp_set_file_input
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def cdp_set_file_input(selector: str, file_paths, *, port: int = 9222, target_url_substr: str = '', timeout_s: float = 10.0) -> dict"
|
||||||
|
description: "Asigna uno o varios archivos a un <input type=file> de una pestana de un Chrome con remote debugging, via CDP, SIN abrir el dialogo nativo del sistema operativo. Localiza el target por substring de URL, abre el WebSocket y ejecuta DOM.enable -> DOM.getDocument -> DOM.querySelector(selector) -> DOM.setFileInputFiles con las rutas ABSOLUTAS. Es el unico metodo robusto para subir archivos por CDP: el navegador no permite escribir el value de un file input desde JS (seguridad) y simular drag&drop es fragil; setFileInputFiles inyecta los File y dispara el evento change que la SPA escucha. Base de whatsapp_send_image y de cualquier flujo de subida de archivos sobre el navegador diario sin robar el foco al usuario."
|
||||||
|
tags: [cdp, browser, automation, upload, file-input, python, navegator]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json", "os", "urllib.request", "websocket"]
|
||||||
|
params_schema:
|
||||||
|
params:
|
||||||
|
- name: selector
|
||||||
|
desc: "Selector CSS del <input type=file> destino. Debe resolver a UN elemento (se usa el primer match). El input puede estar oculto (display:none); CDP lo localiza igual."
|
||||||
|
- name: file_paths
|
||||||
|
desc: "Ruta (str) o lista de rutas a asignar. Se expanden (~) y se convierten a rutas ABSOLUTAS; cada una debe existir en disco o se aborta con ok=False antes de tocar la red."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging de Chrome. Default 9222."
|
||||||
|
- name: target_url_substr
|
||||||
|
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
|
||||||
|
output: "dict {ok: bool, error: str, node_id: int (nodeId CDP del input localizado, 0 si no se encontro), selector: str (eco), files: list[str] (rutas absolutas asignadas)}. ok=True solo si el input se localizo y setFileInputFiles no devolvio error. Nunca lanza: errores de archivo/red/conexion/transport se devuelven en 'error' con ok=False."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_asigna_archivos_al_input", "test_edge_archivo_inexistente_ok_false_sin_red", "test_edge_selector_no_encontrado_ok_false", "test_error_create_connection_lanza_ok_false"]
|
||||||
|
test_file_path: "python/functions/browser/cdp_set_file_input_test.py"
|
||||||
|
file_path: "python/functions/browser/cdp_set_file_input.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.cdp_set_file_input import cdp_set_file_input
|
||||||
|
|
||||||
|
# Requiere un Chrome lanzado con --remote-debugging-port=9222.
|
||||||
|
# Adjuntar una imagen al input de subida de una pestana (WhatsApp Web, un formulario, etc).
|
||||||
|
# El input "vivo" suele exponerse tras pulsar el boton "Adjuntar"/"Subir" (haz el click antes).
|
||||||
|
res = cdp_set_file_input(
|
||||||
|
'input[type="file"][multiple]',
|
||||||
|
"/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png",
|
||||||
|
target_url_substr="whatsapp",
|
||||||
|
)
|
||||||
|
print(res["ok"], res["node_id"], res["error"])
|
||||||
|
# -> True 120
|
||||||
|
```
|
||||||
|
|
||||||
|
O directo por CLI: `python3 python/functions/browser/cdp_set_file_input.py 'input[type="file"]' /ruta/abs.png whatsapp`.
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites **subir/adjuntar un archivo** a una pestana abierta sin que aparezca el
|
||||||
|
dialogo nativo de archivos del sistema operativo (que CDP no puede operar). Es la primitiva
|
||||||
|
de subida sobre la que se construye `whatsapp_send_image_py_browser` y cualquier
|
||||||
|
automatizacion de formularios con `<input type=file>`. El patron tipico: primero un click
|
||||||
|
real (`cdp_click_xy_py_browser`) en el boton que expone el input vivo, luego esta funcion
|
||||||
|
con el selector del input y la ruta absoluta del archivo.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **El input debe existir en el DOM al llamar.** Muchas SPAs (WhatsApp Web) solo crean/activan
|
||||||
|
el `<input type=file>` "vivo" DESPUES de pulsar el boton de adjuntar. Haz el click real
|
||||||
|
primero; si asignas sobre un input persistente/decoy, `setFileInputFiles` puede devolver ok
|
||||||
|
pero la SPA no reacciona (no aparece el preview).
|
||||||
|
- **Rutas ABSOLUTAS obligatorias.** `setFileInputFiles` exige rutas absolutas; la funcion ya
|
||||||
|
convierte (`os.path.abspath` + expanduser), pero el archivo debe existir o aborta con ok=False
|
||||||
|
antes de abrir la conexion.
|
||||||
|
- **El selector debe resolver al input correcto.** Si hay varios `<input type=file>` (uno por
|
||||||
|
tipo: fotos, documento...), afina el selector (`[multiple]`, `[accept*="image"]`). Si no
|
||||||
|
matchea ninguno, devuelve `ok=False` con "no element matches selector".
|
||||||
|
- **Dispara `change`, no `input`.** `DOM.setFileInputFiles` emite el evento `change` nativo
|
||||||
|
(que la mayoria de uploads escuchan). Si una SPA solo escucha otro evento, no bastara.
|
||||||
|
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases). Sin
|
||||||
|
remote debugging, `GET /json` falla y devuelve `ok=False`.
|
||||||
|
- Nunca lanza: errores de archivo/red/WS/transport se reportan en `error` con `ok=False`.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Asigna archivos a un <input type=file> de una pestana de Chrome via Chrome DevTools Protocol.
|
||||||
|
|
||||||
|
Primitiva de input CDP para subir archivos SIN abrir el dialogo nativo del sistema
|
||||||
|
operativo: localiza un target (pestana) por substring de su URL, abre el WebSocket de
|
||||||
|
depuracion y ejecuta la secuencia `DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector`
|
||||||
|
(localiza el input por selector CSS) -> `DOM.setFileInputFiles` (asigna las rutas
|
||||||
|
absolutas al input).
|
||||||
|
|
||||||
|
Es el unico metodo robusto para adjuntar archivos por CDP: el navegador no permite
|
||||||
|
escribir el `value` de un `<input type=file>` desde JavaScript (seguridad), y simular
|
||||||
|
drag&drop es fragil. `DOM.setFileInputFiles` inyecta los `File` directamente y dispara el
|
||||||
|
evento `change` que la SPA escucha. Base de `whatsapp_send_image` y de cualquier flujo de
|
||||||
|
subida de archivos sobre el navegador diario sin robar el foco al usuario.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import websocket
|
||||||
|
|
||||||
|
|
||||||
|
def _call(ws, msg_id: int, method: str, params: dict) -> dict:
|
||||||
|
"""Envia un comando CDP y drena eventos hasta la respuesta con el mismo id."""
|
||||||
|
ws.send(json.dumps({"id": msg_id, "method": method, "params": params}))
|
||||||
|
while True:
|
||||||
|
raw = ws.recv()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
|
||||||
|
continue
|
||||||
|
if parsed.get("id") == msg_id:
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def cdp_set_file_input(
|
||||||
|
selector: str,
|
||||||
|
file_paths,
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
target_url_substr: str = "",
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Asigna uno o varios archivos a un `<input type=file>` de una pestana de Chrome.
|
||||||
|
|
||||||
|
Localiza el target `page` por substring de URL, abre el WebSocket CDP y ejecuta
|
||||||
|
`DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector(selector)` ->
|
||||||
|
`DOM.setFileInputFiles`. Equivale a que el usuario elija esos archivos en el dialogo
|
||||||
|
nativo, pero sin abrirlo: ideal para automatizar subidas (adjuntar imagen en WhatsApp,
|
||||||
|
subir un fichero a un formulario) sobre una pestana ya abierta.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
selector: Selector CSS del `<input type=file>` destino. Debe resolver a UN
|
||||||
|
elemento (se usa el primer match). El input puede estar oculto.
|
||||||
|
file_paths: Ruta (str) o lista de rutas a asignar. Se expanden (`~`) y se
|
||||||
|
convierten a rutas ABSOLUTAS; cada una debe existir en disco.
|
||||||
|
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||||
|
target_url_substr: Substring que debe contener la URL del target (pestana). Si
|
||||||
|
"", usa el primer target de tipo "page".
|
||||||
|
timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con claves:
|
||||||
|
ok: bool — True si el input se localizo y los archivos se asignaron sin error.
|
||||||
|
error: str — mensaje de error (vacio si ok).
|
||||||
|
node_id: int — nodeId CDP del input localizado (0 si no se encontro).
|
||||||
|
selector: str — eco del selector usado.
|
||||||
|
files: list[str] — rutas absolutas que se intentaron asignar.
|
||||||
|
|
||||||
|
Nunca lanza: errores de archivo, red, conexion o transport se devuelven en
|
||||||
|
"error" con ok=False.
|
||||||
|
"""
|
||||||
|
# 1. Normalizar y validar las rutas (antes de tocar la red).
|
||||||
|
if isinstance(file_paths, str):
|
||||||
|
raw_paths = [file_paths]
|
||||||
|
else:
|
||||||
|
raw_paths = list(file_paths)
|
||||||
|
abs_paths = [os.path.abspath(os.path.expanduser(p)) for p in raw_paths]
|
||||||
|
|
||||||
|
if not abs_paths:
|
||||||
|
return {"ok": False, "error": "no file paths provided",
|
||||||
|
"node_id": 0, "selector": selector, "files": []}
|
||||||
|
missing = [p for p in abs_paths if not os.path.isfile(p)]
|
||||||
|
if missing:
|
||||||
|
return {"ok": False, "error": f"file(s) not found: {missing}",
|
||||||
|
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||||
|
|
||||||
|
# 2. Listar targets via HTTP y elegir el primer page que matchee.
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
f"http://127.0.0.1:{port}/json", timeout=5
|
||||||
|
) as resp:
|
||||||
|
targets = json.loads(resp.read().decode())
|
||||||
|
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
|
||||||
|
return {"ok": False, "error": str(e),
|
||||||
|
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||||
|
|
||||||
|
chosen = None
|
||||||
|
for t in targets:
|
||||||
|
if t.get("type") != "page":
|
||||||
|
continue
|
||||||
|
url = t.get("url", "")
|
||||||
|
if target_url_substr == "" or target_url_substr in url:
|
||||||
|
chosen = t
|
||||||
|
break
|
||||||
|
|
||||||
|
if chosen is None:
|
||||||
|
return {"ok": False, "error": f"no target matching {target_url_substr}",
|
||||||
|
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||||
|
|
||||||
|
ws_url = chosen.get("webSocketDebuggerUrl", "")
|
||||||
|
|
||||||
|
# 3. Abrir WS y correr la secuencia DOM.
|
||||||
|
try:
|
||||||
|
ws = websocket.create_connection(ws_url, timeout=timeout_s)
|
||||||
|
except Exception as e: # noqa: BLE001 — conexion WS
|
||||||
|
return {"ok": False, "error": str(e),
|
||||||
|
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_call(ws, 1, "DOM.enable", {})
|
||||||
|
doc = _call(ws, 2, "DOM.getDocument", {"depth": 0})
|
||||||
|
root_id = doc.get("result", {}).get("root", {}).get("nodeId", 0)
|
||||||
|
if not root_id:
|
||||||
|
return {"ok": False, "error": "DOM.getDocument did not return a root nodeId",
|
||||||
|
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||||
|
|
||||||
|
qs = _call(ws, 3, "DOM.querySelector",
|
||||||
|
{"nodeId": root_id, "selector": selector})
|
||||||
|
node_id = qs.get("result", {}).get("nodeId", 0)
|
||||||
|
if not node_id:
|
||||||
|
return {"ok": False, "error": f"no element matches selector: {selector}",
|
||||||
|
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||||
|
|
||||||
|
sf = _call(ws, 4, "DOM.setFileInputFiles",
|
||||||
|
{"files": abs_paths, "nodeId": node_id})
|
||||||
|
err = sf.get("error")
|
||||||
|
if err:
|
||||||
|
return {"ok": False, "error": json.dumps(err),
|
||||||
|
"node_id": node_id, "selector": selector, "files": abs_paths}
|
||||||
|
except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv
|
||||||
|
return {"ok": False, "error": str(e),
|
||||||
|
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception: # noqa: BLE001 — cierre best-effort
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"ok": True, "error": "", "node_id": node_id,
|
||||||
|
"selector": selector, "files": abs_paths}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sel = sys.argv[1] if len(sys.argv) > 1 else 'input[type="file"]'
|
||||||
|
path = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
|
substr = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||||
|
out = cdp_set_file_input(sel, path, port=9222, target_url_substr=substr)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Tests para cdp_set_file_input — mockean urlopen + create_connection.
|
||||||
|
|
||||||
|
Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y
|
||||||
|
websocket.create_connection (un fake que responde a cada comando DOM por su id, con un
|
||||||
|
nodeId de raiz, un nodeId de input y una respuesta vacia para setFileInputFiles). Asi NO
|
||||||
|
hace falta Chrome. Para la validacion de existencia de archivo se usan rutas reales
|
||||||
|
(__file__) y rutas inexistentes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser import cdp_set_file_input as mod # noqa: E402
|
||||||
|
from browser.cdp_set_file_input import cdp_set_file_input # noqa: E402
|
||||||
|
|
||||||
|
_THIS = os.path.abspath(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return json.dumps(self._payload).encode()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWS:
|
||||||
|
"""WebSocket falso que responde a cada comando DOM por method."""
|
||||||
|
|
||||||
|
def __init__(self, *, query_node_id=4242, set_error=None):
|
||||||
|
self.sent = []
|
||||||
|
self._inbox = []
|
||||||
|
self.closed = False
|
||||||
|
self.query_node_id = query_node_id
|
||||||
|
self.set_error = set_error
|
||||||
|
|
||||||
|
def send(self, raw):
|
||||||
|
msg = json.loads(raw)
|
||||||
|
self.sent.append(msg)
|
||||||
|
mid = msg["id"]
|
||||||
|
method = msg["method"]
|
||||||
|
if method == "DOM.getDocument":
|
||||||
|
resp = {"id": mid, "result": {"root": {"nodeId": 1}}}
|
||||||
|
elif method == "DOM.querySelector":
|
||||||
|
resp = {"id": mid, "result": {"nodeId": self.query_node_id}}
|
||||||
|
elif method == "DOM.setFileInputFiles":
|
||||||
|
if self.set_error is not None:
|
||||||
|
resp = {"id": mid, "error": self.set_error}
|
||||||
|
else:
|
||||||
|
resp = {"id": mid, "result": {}}
|
||||||
|
else: # DOM.enable y demas
|
||||||
|
resp = {"id": mid, "result": {}}
|
||||||
|
self._inbox.append(json.dumps(resp))
|
||||||
|
|
||||||
|
def recv(self):
|
||||||
|
if self._inbox:
|
||||||
|
return self._inbox.pop(0)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _patch(targets, ws_obj=None, create_conn_exc=None):
|
||||||
|
orig_urlopen = mod.urllib.request.urlopen
|
||||||
|
orig_create = mod.websocket.create_connection
|
||||||
|
|
||||||
|
def fake_urlopen(url, timeout=5):
|
||||||
|
return _FakeResp(targets)
|
||||||
|
|
||||||
|
def fake_create(ws_url, timeout=10):
|
||||||
|
if create_conn_exc is not None:
|
||||||
|
raise create_conn_exc
|
||||||
|
return ws_obj
|
||||||
|
|
||||||
|
mod.urllib.request.urlopen = fake_urlopen
|
||||||
|
mod.websocket.create_connection = fake_create
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
mod.urllib.request.urlopen = orig_urlopen
|
||||||
|
mod.websocket.create_connection = orig_create
|
||||||
|
|
||||||
|
|
||||||
|
_TARGETS = [
|
||||||
|
{"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_asigna_archivos_al_input():
|
||||||
|
"""setFileInputFiles recibe la ruta ABSOLUTA y el nodeId del input localizado."""
|
||||||
|
ws = _FakeWS(query_node_id=777)
|
||||||
|
with _patch(_TARGETS, ws_obj=ws):
|
||||||
|
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
|
||||||
|
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["error"] == ""
|
||||||
|
assert res["node_id"] == 777
|
||||||
|
assert res["files"] == [_THIS]
|
||||||
|
# El ultimo comando es setFileInputFiles con la ruta absoluta y el nodeId del input.
|
||||||
|
setcmd = [m for m in ws.sent if m["method"] == "DOM.setFileInputFiles"][0]
|
||||||
|
assert setcmd["params"]["files"] == [_THIS]
|
||||||
|
assert setcmd["params"]["nodeId"] == 777
|
||||||
|
assert ws.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_archivo_inexistente_ok_false_sin_red():
|
||||||
|
"""Una ruta inexistente devuelve ok=False antes de abrir cualquier conexion."""
|
||||||
|
res = cdp_set_file_input('input[type="file"]', "/no/existe/imagen.png",
|
||||||
|
target_url_substr="whatsapp")
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert "not found" in res["error"]
|
||||||
|
assert res["node_id"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_selector_no_encontrado_ok_false():
|
||||||
|
"""Si querySelector devuelve nodeId 0, no se asignan archivos y ok=False."""
|
||||||
|
ws = _FakeWS(query_node_id=0)
|
||||||
|
with _patch(_TARGETS, ws_obj=ws):
|
||||||
|
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert "no element matches selector" in res["error"]
|
||||||
|
# NO se llamo a setFileInputFiles.
|
||||||
|
assert all(m["method"] != "DOM.setFileInputFiles" for m in ws.sent)
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_create_connection_lanza_ok_false():
|
||||||
|
"""Si create_connection lanza, se captura y devuelve ok=False sin relanzar."""
|
||||||
|
with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")):
|
||||||
|
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert "ws down" in res["error"]
|
||||||
|
assert res["files"] == [_THIS]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_clear_node_outputs_ui
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_clear_node_outputs_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
|
||||||
|
description: "Limpia outputs/previews residuales de TODOS los nodos del grafo de ComfyUI en la UI via CDP: vacia app.nodeOutputs (store de previews keyed by node_id) y borra imgs/images de cada nodo vivo, sin tocar la topologia del grafo (no borra nodos ni links). Arregla el bug de imagenes pegadas a nodos que no corresponden tras cargar un workflow nuevo con app.loadApiJson. Compone cdp_eval. Impura: red (CDP) + muta la UI."
|
||||||
|
tags: [comfyui, browser, cdp, ml, ui-automation, image-generation]
|
||||||
|
uses_functions: ["cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||||
|
- name: server_url_substr
|
||||||
|
desc: "Substring de la URL de la pestana de ComfyUI (default '8188', el puerto del server). Identifica la pestana entre las abiertas."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||||
|
output: "dict {ok, cleared, error, store_cleared, nodes_touched, nodes}. ok/cleared True si la limpieza termino sin excepcion. store_cleared = entradas borradas de app.nodeOutputs; nodes_touched = nodos a los que se les quito un preview; nodes = total de nodos del grafo."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/comfyui_clear_node_outputs_ui.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
|
||||||
|
|
||||||
|
# Requiere la UI de ComfyUI abierta en el Chrome con CDP en el puerto 9222.
|
||||||
|
print(comfyui_clear_node_outputs_ui())
|
||||||
|
# -> {'ok': True, 'cleared': True, 'error': '', 'store_cleared': 6, 'nodes_touched': 2, 'nodes': 12}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando ves previews/outputs de imagenes pegados a nodos que no los produjeron
|
||||||
|
(una imagen bajo un `CheckpointLoaderSimple`, un `SaveGLB`, etc.) tras haber
|
||||||
|
cargado varios workflows seguidos en la misma pestana. Es la limpieza no
|
||||||
|
destructiva: borra los previews residuales del grafo actual SIN recargarlo ni
|
||||||
|
perder la topologia. `comfyui_load_workflow_ui(..., clear_outputs=True)` la
|
||||||
|
invoca automaticamente antes de cargar, asi que normalmente no hace falta
|
||||||
|
llamarla a mano; usala solo para limpiar un grafo ya cargado sin recargarlo.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Requiere la pestana de ComfyUI abierta en un Chrome con
|
||||||
|
`--remote-debugging-port=9222`. Si no hay target que matchee
|
||||||
|
`server_url_substr`, `cdp_eval` devuelve error y aqui `ok=False`.
|
||||||
|
- Borra TODOS los previews del grafo, incluidos los legitimos de la ultima
|
||||||
|
ejecucion. Si quieres conservar un preview concreto, no la llames; el residuo
|
||||||
|
cross-workflow se evita de raiz cargando con
|
||||||
|
`comfyui_load_workflow_ui(..., clear_outputs=True)`.
|
||||||
|
- No es `app.clean()`: a proposito NO hace `rootGraph.clear()`, por eso es
|
||||||
|
segura sobre el grafo vivo del usuario (no borra nodos ni conexiones).
|
||||||
|
- El store que vacia es `app.nodeOutputs`; el nombre interno puede variar entre
|
||||||
|
versiones de ComfyUI. Si una version renombra el store, el borrado del store
|
||||||
|
no aplica pero el barrido de `node.imgs`/`node.images` sigue limpiando los
|
||||||
|
previews visibles.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Limpia los outputs/previews residuales de los nodos de ComfyUI en la UI via CDP.
|
||||||
|
|
||||||
|
ComfyUI cachea los outputs de cada ejecucion en `app.nodeOutputs`, un store
|
||||||
|
indexado por node_id. La ruta de carga `app.loadApiJson` (la que usa
|
||||||
|
comfyui_load_workflow_ui) reconstruye el grafo pero NO resetea ese store ni los
|
||||||
|
previews de los nodos. Cuando un workflow nuevo reusa un node_id que ya existia
|
||||||
|
en el store, el preview cacheado del workflow anterior se vuelve a pintar sobre
|
||||||
|
el nodo nuevo, que muchas veces es de otro tipo (ej. una imagen pegada bajo un
|
||||||
|
`CheckpointLoaderSimple` o un `SaveGLB`).
|
||||||
|
|
||||||
|
Esta funcion vacia `app.nodeOutputs` y borra `imgs`/`images` de todos los nodos
|
||||||
|
vivos del grafo, sin tocar la topologia del grafo (no borra nodos ni links), y
|
||||||
|
marca el canvas dirty para repintar. Es la version no destructiva de
|
||||||
|
`app.clean()` (que ademas haria `rootGraph.clear()`).
|
||||||
|
|
||||||
|
Funcion impura: hace red (CDP WebSocket) y muta el estado de la UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||||
|
from cdp_eval import cdp_eval
|
||||||
|
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_clear_node_outputs_ui(
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
server_url_substr: str = "8188",
|
||||||
|
timeout_s: float = 15.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Limpia previews/outputs residuales de todos los nodos del grafo de ComfyUI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||||
|
server_url_substr: substring de la URL de la pestana de ComfyUI (default
|
||||||
|
"8188", el puerto del server). Identifica la pestana entre las
|
||||||
|
abiertas.
|
||||||
|
timeout_s: timeout de la conexion CDP en segundos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok: bool, cleared: bool, error: str, store_cleared: int,
|
||||||
|
nodes_touched: int, nodes: int}. ok/cleared True si la limpieza termino
|
||||||
|
sin excepcion en la pagina. `store_cleared` es el numero de entradas
|
||||||
|
eliminadas de `app.nodeOutputs`; `nodes_touched` los nodos a los que se
|
||||||
|
les quito un preview; `nodes` el total de nodos del grafo.
|
||||||
|
"""
|
||||||
|
expr = (
|
||||||
|
"(function(){"
|
||||||
|
" if(!window.app){ return {ok:false, cleared:false, error:'window.app no disponible en la pestana'}; }"
|
||||||
|
" try{"
|
||||||
|
" var store=0;"
|
||||||
|
" if(app.nodeOutputs){ for(var k in app.nodeOutputs){ if(Object.prototype.hasOwnProperty.call(app.nodeOutputs,k)){ delete app.nodeOutputs[k]; store++; } } }"
|
||||||
|
" var nodes=(app.graph && app.graph._nodes)? app.graph._nodes : [];"
|
||||||
|
" var touched=0;"
|
||||||
|
" for(var i=0;i<nodes.length;i++){"
|
||||||
|
" var nd=nodes[i];"
|
||||||
|
" if(nd.imgs!==undefined || nd.images!==undefined){ touched++; }"
|
||||||
|
" nd.imgs=undefined;"
|
||||||
|
" nd.images=undefined;"
|
||||||
|
" nd.imageIndex=null;"
|
||||||
|
" nd.overIndex=null;"
|
||||||
|
" if('animatedImages' in nd){ nd.animatedImages=undefined; }"
|
||||||
|
" }"
|
||||||
|
" if(app.graph && app.graph.setDirtyCanvas){ app.graph.setDirtyCanvas(true,true); }"
|
||||||
|
" return {ok:true, cleared:true, error:'', store_cleared:store, nodes_touched:touched, nodes:nodes.length};"
|
||||||
|
" }catch(e){ return {ok:false, cleared:false, error:String(e)}; }"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
r = cdp_eval(
|
||||||
|
expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=server_url_substr,
|
||||||
|
await_promise=False,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
if not r["ok"]:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"cleared": False,
|
||||||
|
"error": r["error"],
|
||||||
|
"store_cleared": 0,
|
||||||
|
"nodes_touched": 0,
|
||||||
|
"nodes": 0,
|
||||||
|
}
|
||||||
|
val = r["value"] or {}
|
||||||
|
return {
|
||||||
|
"ok": bool(val.get("cleared")),
|
||||||
|
"cleared": bool(val.get("cleared")),
|
||||||
|
"error": val.get("error", ""),
|
||||||
|
"store_cleared": int(val.get("store_cleared", 0)),
|
||||||
|
"nodes_touched": int(val.get("nodes_touched", 0)),
|
||||||
|
"nodes": int(val.get("nodes", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
comfyui_clear_node_outputs_ui(),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_export_workflow_ui
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_export_workflow_ui(*, port: int = 9222, server_url_substr: str = '8188', api_format: bool = True, save_path: str | None = None, timeout_s: float = 15.0) -> dict"
|
||||||
|
description: "Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP. Con api_format=True devuelve el API format ((await app.graphToPrompt()).output, listo para POST /prompt); con False el UI graph serializado (app.graph.serialize(), recargable en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval. Impura: red (CDP) + escritura opcional."
|
||||||
|
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||||
|
uses_functions: ["cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json", "os"]
|
||||||
|
params:
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||||
|
- name: server_url_substr
|
||||||
|
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||||
|
- name: api_format
|
||||||
|
desc: "True devuelve el API format (POST /prompt); False el UI graph serializado (recargable con la UI). Default True."
|
||||||
|
- name: save_path
|
||||||
|
desc: "Si se pasa, ruta donde escribir el JSON (se expande ~ y se crean los padres). None no escribe a disco."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||||
|
output: "dict {ok: bool, workflow: dict, saved_to: str|None, error: str}. workflow es el API format o el UI graph segun api_format; saved_to es la ruta escrita o None."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/comfyui_export_workflow_ui.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.comfyui_export_workflow_ui import comfyui_export_workflow_ui
|
||||||
|
|
||||||
|
# Captura el API format del grafo actual y guardalo a disco.
|
||||||
|
out = comfyui_export_workflow_ui(api_format=True, save_path="/tmp/wf_actual.json")
|
||||||
|
print(out["ok"], len(out["workflow"]), "nodos ->", out["saved_to"])
|
||||||
|
|
||||||
|
# El API format devuelto es re-enviable por API:
|
||||||
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
|
resp = comfyui_submit_workflow(out["workflow"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Para capturar lo que el usuario tiene montado en la UI y (a) re-enviarlo por API
|
||||||
|
con `comfyui_submit_workflow`, (b) persistirlo como plantilla, o (c) verificar
|
||||||
|
que un cambio hecho con `comfyui_set_node_widget_ui` quedo reflejado en el grafo.
|
||||||
|
Es el reverso de `comfyui_load_workflow_ui`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `api_format=True` da el formato de POST /prompt (sin links visuales ni
|
||||||
|
posiciones); `api_format=False` da el grafo de UI (con todo lo necesario para
|
||||||
|
`app.loadGraphData`). Elige segun si vas a re-enviar por API o a recargar en UI.
|
||||||
|
- `graphToPrompt()` es asincrono: se espera la Promise (`await_promise=True`). Si
|
||||||
|
la pestana no tiene `window.app`, devuelve `ok=False` con error claro.
|
||||||
|
- El export refleja el estado EN VIVO del grafo, incluidos los cambios de
|
||||||
|
`comfyui_set_node_widget_ui` aplicados antes.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP.
|
||||||
|
|
||||||
|
Con api_format=True devuelve el API format (el dict que acepta POST /prompt,
|
||||||
|
extraido de `(await app.graphToPrompt()).output`); con False devuelve el UI graph
|
||||||
|
serializado (`app.graph.serialize()`, con links y posiciones para volver a
|
||||||
|
cargar en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval.
|
||||||
|
|
||||||
|
Funcion impura: hace red (CDP WebSocket) y, si save_path, escribe en disco.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||||
|
from cdp_eval import cdp_eval
|
||||||
|
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_export_workflow_ui(
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
server_url_substr: str = "8188",
|
||||||
|
api_format: bool = True,
|
||||||
|
save_path: str | None = None,
|
||||||
|
timeout_s: float = 15.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Exporta el workflow actual del grafo de la UI de ComfyUI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||||
|
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||||
|
api_format: True devuelve el API format (POST /prompt); False devuelve el
|
||||||
|
UI graph serializado (recargable con la UI). Default True.
|
||||||
|
save_path: si se pasa, ruta donde escribir el JSON exportado. Se expande
|
||||||
|
~ y se crean los directorios padre. None no escribe a disco.
|
||||||
|
timeout_s: timeout de la conexion CDP en segundos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok: bool, workflow: dict, saved_to: str|None, error: str}.
|
||||||
|
"""
|
||||||
|
if api_format:
|
||||||
|
expr = (
|
||||||
|
"(async function(){"
|
||||||
|
" if(!window.app || typeof app.graphToPrompt!=='function'){"
|
||||||
|
" return {error:'window.app.graphToPrompt no disponible en la pestana'};"
|
||||||
|
" }"
|
||||||
|
" try{ var p = await app.graphToPrompt(); return {workflow: p.output, error:''}; }"
|
||||||
|
" catch(e){ return {error:String(e)}; }"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
await_p = True
|
||||||
|
else:
|
||||||
|
expr = (
|
||||||
|
"(function(){"
|
||||||
|
" if(!window.app || !app.graph || typeof app.graph.serialize!=='function'){"
|
||||||
|
" return {error:'window.app.graph.serialize no disponible en la pestana'};"
|
||||||
|
" }"
|
||||||
|
" try{ return {workflow: app.graph.serialize(), error:''}; }"
|
||||||
|
" catch(e){ return {error:String(e)}; }"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
await_p = False
|
||||||
|
|
||||||
|
r = cdp_eval(
|
||||||
|
expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=server_url_substr,
|
||||||
|
await_promise=await_p,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
if not r["ok"]:
|
||||||
|
return {"ok": False, "workflow": {}, "saved_to": None, "error": r["error"]}
|
||||||
|
val = r["value"] or {}
|
||||||
|
if val.get("error"):
|
||||||
|
return {"ok": False, "workflow": {}, "saved_to": None, "error": val["error"]}
|
||||||
|
|
||||||
|
workflow = val.get("workflow") or {}
|
||||||
|
saved_to = None
|
||||||
|
if save_path:
|
||||||
|
path = os.path.expanduser(save_path)
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(workflow, fh, ensure_ascii=False, indent=2)
|
||||||
|
saved_to = path
|
||||||
|
|
||||||
|
return {"ok": True, "workflow": workflow, "saved_to": saved_to, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
out = comfyui_export_workflow_ui(api_format=True)
|
||||||
|
print(json.dumps(
|
||||||
|
{"ok": out["ok"], "nodes": len(out["workflow"]), "error": out["error"]},
|
||||||
|
ensure_ascii=False, indent=2,
|
||||||
|
))
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_load_workflow_ui
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.1.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_load_workflow_ui(workflow: dict, *, port: int = 9222, server_url_substr: str = '8188', filename: str = 'workflow.json', clear_outputs: bool = True, timeout_s: float = 20.0) -> dict"
|
||||||
|
description: "Carga un workflow ComfyUI (API format) en la UI del navegador via CDP: inyecta app.loadApiJson(<workflow>, filename) en la pestana de ComfyUI abierta y reconstruye el grafo visual. Por defecto (clear_outputs=True) limpia antes los previews/outputs residuales para que un preview cacheado del workflow anterior no se pegue a un nodo nuevo que reusa el mismo node_id. Compone cdp_eval + comfyui_clear_node_outputs_ui. Impura: red (CDP WebSocket) + muta el grafo de la UI."
|
||||||
|
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||||
|
uses_functions: ["cdp_eval_py_browser", "comfyui_clear_node_outputs_ui_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json"]
|
||||||
|
params:
|
||||||
|
- name: workflow
|
||||||
|
desc: "dict en API format (claves = node_ids, valores con class_type + inputs); tipicamente el resultado de comfyui_build_txt2img_workflow."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||||
|
- name: server_url_substr
|
||||||
|
desc: "Substring de la URL de la pestana de ComfyUI (default '8188', el puerto del server). Identifica la pestana entre las abiertas."
|
||||||
|
- name: filename
|
||||||
|
desc: "Nombre que ComfyUI asocia al workflow cargado. Default 'workflow.json'."
|
||||||
|
- name: clear_outputs
|
||||||
|
desc: "Si True (default) limpia previews/outputs residuales (app.nodeOutputs + node.imgs) antes de cargar, evitando que un preview cacheado de un workflow anterior se pegue a un nodo nuevo que reusa el mismo node_id. False conserva los previews previos a proposito."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
|
||||||
|
output: "dict {ok: bool, loaded: bool, error: str}. ok/loaded True si app.loadApiJson termino sin excepcion en la pagina."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/comfyui_load_workflow_ui.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||||
|
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
|
||||||
|
|
||||||
|
wf = comfyui_build_txt2img_workflow(
|
||||||
|
ckpt_name="IMG_dreamshaper_8.safetensors",
|
||||||
|
positive="a red apple on a wooden table, sharp focus",
|
||||||
|
)
|
||||||
|
# Requiere la UI de ComfyUI abierta en el Chrome con CDP en el puerto 9222.
|
||||||
|
print(comfyui_load_workflow_ui(wf)) # -> {'ok': True, 'loaded': True, 'error': ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando tienes un workflow en API format (lo construyo con
|
||||||
|
`comfyui_build_txt2img_workflow` o lo exporto de otro lado) y quieres verlo y
|
||||||
|
editarlo en la UI del navegador del usuario antes de encolarlo. Es el puente
|
||||||
|
"API format -> grafo visual": cargas, luego ajustas widgets con
|
||||||
|
`comfyui_set_node_widget_ui` y encolas con `comfyui_queue_prompt_ui`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Requiere que la pestana de ComfyUI ya este abierta en un Chrome con
|
||||||
|
`--remote-debugging-port=9222`. Si no hay target que matchee `server_url_substr`,
|
||||||
|
`cdp_eval` devuelve error y aqui `ok=False`.
|
||||||
|
- `app.loadApiJson` REEMPLAZA el grafo actual de la UI por el del workflow; pierde
|
||||||
|
los cambios no exportados. Exporta antes con `comfyui_export_workflow_ui` si los
|
||||||
|
necesitas.
|
||||||
|
- Espera la Promise de carga (`await_promise=True`). El conteo de nodos cargados
|
||||||
|
se puede verificar con `cdp_eval("app.graph._nodes.length", target_url_substr="8188")`.
|
||||||
|
- `app.loadApiJson` (a diferencia de la ruta del menu `app.loadGraphData`) NO
|
||||||
|
llama a `app.clean()`, asi que NO resetea el store `app.nodeOutputs` ni los
|
||||||
|
previews de los nodos. Sin `clear_outputs=True`, un preview cacheado de un
|
||||||
|
workflow anterior se re-pinta sobre el nodo nuevo que reuse el mismo node_id
|
||||||
|
(visto: imagen 3D pegada bajo un `CheckpointLoaderSimple`/`SaveGLB`). El
|
||||||
|
default `clear_outputs=True` lo evita delegando en
|
||||||
|
`comfyui_clear_node_outputs_ui`.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-24) — anade `clear_outputs=True` (default): limpia los
|
||||||
|
previews/outputs residuales (`app.nodeOutputs` + `node.imgs`) antes de cargar,
|
||||||
|
delegando en `comfyui_clear_node_outputs_ui`. Fija el bug de imagenes
|
||||||
|
residuales pegadas a nodos que reusan node_id entre workflows.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Carga un workflow ComfyUI (API format) en la UI del navegador via CDP.
|
||||||
|
|
||||||
|
Inyecta `app.loadApiJson(<workflow>, filename)` en la pestana de ComfyUI ya
|
||||||
|
abierta en el navegador diario, reconstruyendo el grafo visual a partir del API
|
||||||
|
format (el mismo dict que produce comfyui_build_txt2img_workflow). Compone la
|
||||||
|
primitiva de transport cdp_eval; no abre ventana nueva ni reinventa CDP.
|
||||||
|
|
||||||
|
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||||
|
from cdp_eval import cdp_eval
|
||||||
|
from comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
|
||||||
|
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from browser.comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_load_workflow_ui(
|
||||||
|
workflow: dict,
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
server_url_substr: str = "8188",
|
||||||
|
filename: str = "workflow.json",
|
||||||
|
clear_outputs: bool = True,
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Carga un workflow API format en el grafo de la UI de ComfyUI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workflow: dict en API format (claves = node_ids, valores con class_type +
|
||||||
|
inputs). Tipicamente el resultado de comfyui_build_txt2img_workflow.
|
||||||
|
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||||
|
server_url_substr: substring de la URL de la pestana de ComfyUI (default
|
||||||
|
"8188", el puerto del server). Identifica la pestana entre todas las
|
||||||
|
abiertas.
|
||||||
|
filename: nombre que ComfyUI asocia al workflow cargado.
|
||||||
|
clear_outputs: si True (default), limpia los previews/outputs residuales
|
||||||
|
(app.nodeOutputs + node.imgs) ANTES de cargar, replicando lo que hace
|
||||||
|
la ruta del menu (app.clean()). Evita que un preview cacheado de un
|
||||||
|
workflow anterior se pegue a un nodo nuevo que reusa el mismo node_id
|
||||||
|
(bug de imagenes residuales). Ponlo en False solo si quieres conservar
|
||||||
|
a proposito los previews del grafo previo.
|
||||||
|
timeout_s: timeout de la conexion CDP en segundos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok: bool, loaded: bool, error: str}. ok/loaded True si
|
||||||
|
app.loadApiJson termino sin excepcion en la pagina.
|
||||||
|
"""
|
||||||
|
if clear_outputs:
|
||||||
|
comfyui_clear_node_outputs_ui(
|
||||||
|
port=port,
|
||||||
|
server_url_substr=server_url_substr,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
expr = (
|
||||||
|
"(async function(){"
|
||||||
|
" if(!window.app || typeof app.loadApiJson!=='function'){"
|
||||||
|
" return {loaded:false, error:'window.app.loadApiJson no disponible en la pestana'};"
|
||||||
|
" }"
|
||||||
|
" try{"
|
||||||
|
f" await app.loadApiJson({json.dumps(workflow)}, {json.dumps(filename)});"
|
||||||
|
" return {loaded:true, error:'', nodes: app.graph? app.graph._nodes.length : -1};"
|
||||||
|
" }catch(e){ return {loaded:false, error:String(e)}; }"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
r = cdp_eval(
|
||||||
|
expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=server_url_substr,
|
||||||
|
await_promise=True,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
if not r["ok"]:
|
||||||
|
return {"ok": False, "loaded": False, "error": r["error"]}
|
||||||
|
val = r["value"] or {}
|
||||||
|
return {
|
||||||
|
"ok": bool(val.get("loaded")),
|
||||||
|
"loaded": bool(val.get("loaded")),
|
||||||
|
"error": val.get("error", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||||
|
|
||||||
|
wf = comfyui_build_txt2img_workflow(
|
||||||
|
ckpt_name="IMG_v1-5-pruned-emaonly-fp16.safetensors",
|
||||||
|
positive="a red apple on a wooden table, sharp focus",
|
||||||
|
)
|
||||||
|
print(json.dumps(comfyui_load_workflow_ui(wf), ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_queue_prompt_ui
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_queue_prompt_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 20.0) -> dict"
|
||||||
|
description: "Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar 'Queue Prompt'): llama app.queuePrompt(0) en la pestana, que serializa el grafo al API format y hace POST /prompt al server. Compone cdp_eval. Impura: red (CDP) + dispara trabajo de GPU."
|
||||||
|
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||||
|
uses_functions: ["cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json"]
|
||||||
|
params:
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||||
|
- name: server_url_substr
|
||||||
|
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
|
||||||
|
output: "dict {ok: bool, queued: bool, error: str}. queued True si app.queuePrompt resolvio sin excepcion."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/comfyui_queue_prompt_ui.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
|
||||||
|
print(comfyui_queue_prompt_ui()) # -> {'ok': True, 'queued': True, 'error': ''}
|
||||||
|
# El PNG aparece en ~/ComfyUI/output/. Para esperar el resultado por API se usa
|
||||||
|
# el prompt_id; si solo encolas desde la UI, sondea la carpeta output/ o usa el
|
||||||
|
# historial (GET /history) para localizar el archivo nuevo.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Como ultimo paso del flujo por UI: tras cargar (`comfyui_load_workflow_ui`) y
|
||||||
|
ajustar widgets (`comfyui_set_node_widget_ui`), dispara la generacion sin que el
|
||||||
|
usuario pulse el boton. Reproduce exactamente "Queue Prompt" del frontend.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Tiene efecto secundario real: arranca trabajo de GPU en el server. No es
|
||||||
|
idempotente — cada llamada encola un prompt nuevo.
|
||||||
|
- `app.queuePrompt(0)` encola el grafo TAL CUAL esta en la UI en ese momento, no
|
||||||
|
un workflow que le pases. Para encolar uno concreto, cargalo antes con
|
||||||
|
`comfyui_load_workflow_ui`.
|
||||||
|
- No devuelve el `prompt_id` (la UI lo gestiona internamente). Para correlar el
|
||||||
|
resultado por API mejor usa `comfyui_submit_workflow` (devuelve prompt_id) +
|
||||||
|
`comfyui_wait_result`; esta funcion es para el caso "como si pulsara el boton".
|
||||||
|
- Si el grafo tiene errores de validacion, ComfyUI los muestra en la UI y la
|
||||||
|
Promise puede rechazar: aqui se refleja como `ok=False` con el error.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar "Queue Prompt").
|
||||||
|
|
||||||
|
Llama `app.queuePrompt(0)` en la pestana de ComfyUI abierta en el navegador, que
|
||||||
|
serializa el grafo visual al API format y hace POST /prompt al server. Compone
|
||||||
|
cdp_eval.
|
||||||
|
|
||||||
|
Funcion impura: hace red (CDP WebSocket) y dispara trabajo de GPU en el server.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||||
|
from cdp_eval import cdp_eval
|
||||||
|
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_queue_prompt_ui(
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
server_url_substr: str = "8188",
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Encola el grafo actual de la UI de ComfyUI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||||
|
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||||
|
timeout_s: timeout de la conexion CDP en segundos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok: bool, queued: bool, error: str}. queued True si
|
||||||
|
app.queuePrompt resolvio sin excepcion.
|
||||||
|
"""
|
||||||
|
expr = (
|
||||||
|
"(async function(){"
|
||||||
|
" if(!window.app || typeof app.queuePrompt!=='function'){"
|
||||||
|
" return {queued:false, error:'window.app.queuePrompt no disponible en la pestana'};"
|
||||||
|
" }"
|
||||||
|
" try{ await app.queuePrompt(0); return {queued:true, error:''}; }"
|
||||||
|
" catch(e){ return {queued:false, error:String(e)}; }"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
r = cdp_eval(
|
||||||
|
expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=server_url_substr,
|
||||||
|
await_promise=True,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
if not r["ok"]:
|
||||||
|
return {"ok": False, "queued": False, "error": r["error"]}
|
||||||
|
val = r["value"] or {}
|
||||||
|
return {
|
||||||
|
"ok": bool(val.get("queued")),
|
||||||
|
"queued": bool(val.get("queued")),
|
||||||
|
"error": val.get("error", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(json.dumps(comfyui_queue_prompt_ui(), ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_refresh_nodes_ui
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_refresh_nodes_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
|
||||||
|
description: "Refresca los combos del grafo de ComfyUI desde la UI via CDP: llama app.refreshComboInNodes(), que vuelve a pedir GET /object_info y actualiza los combos de todos los nodos (checkpoints, loras, vae, samplers) sin recargar la pagina. Util tras descargar modelos nuevos. Compone cdp_eval. Impura: red (CDP) + refresca estado de la UI."
|
||||||
|
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||||
|
uses_functions: ["cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json"]
|
||||||
|
params:
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||||
|
- name: server_url_substr
|
||||||
|
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||||
|
output: "dict {ok: bool, refreshed: bool, error: str}. refreshed True si app.refreshComboInNodes resolvio sin excepcion."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/comfyui_refresh_nodes_ui.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_download_model import comfyui_download_model
|
||||||
|
from browser.comfyui_refresh_nodes_ui import comfyui_refresh_nodes_ui
|
||||||
|
|
||||||
|
# Tras bajar un checkpoint nuevo, refresca los combos para que aparezca en los
|
||||||
|
# CheckpointLoaderSimple sin recargar la pagina.
|
||||||
|
comfyui_download_model("https://.../nuevo.safetensors", "checkpoints")
|
||||||
|
print(comfyui_refresh_nodes_ui()) # -> {'ok': True, 'refreshed': True, 'error': ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Justo despues de añadir modelos a `~/ComfyUI/models/` (con
|
||||||
|
`comfyui_download_model` o a mano) para que los nodos de la UI vean los archivos
|
||||||
|
nuevos en sus combos sin un F5 que perderia el grafo no guardado.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Solo refresca combos (listas que vienen de /object_info): checkpoints, loras,
|
||||||
|
vae, samplers, schedulers. NO recarga el grafo ni cambia los valores ya
|
||||||
|
seleccionados.
|
||||||
|
- Si el server no ve aun el archivo nuevo (lo copiaste a la carpeta equivocada o
|
||||||
|
ComfyUI no reescanea), el combo seguira sin mostrarlo aunque `refreshed=True`:
|
||||||
|
el refresh fue exitoso pero el catalogo del server no lo incluye.
|
||||||
|
- Requiere la pestana de ComfyUI abierta en el Chrome con CDP; sin target,
|
||||||
|
`ok=False`.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Refresca los combos del grafo de ComfyUI desde la UI via CDP.
|
||||||
|
|
||||||
|
Llama `app.refreshComboInNodes()`, que vuelve a pedir GET /object_info al server
|
||||||
|
y actualiza los combos de todos los nodos (lista de checkpoints, loras, vaes,
|
||||||
|
samplers) sin recargar la pagina. Util tras descargar modelos nuevos con
|
||||||
|
comfyui_download_model para que aparezcan en los CheckpointLoaderSimple sin un
|
||||||
|
F5. Compone cdp_eval.
|
||||||
|
|
||||||
|
Funcion impura: hace red (CDP WebSocket) y refresca estado de la UI.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||||
|
from cdp_eval import cdp_eval
|
||||||
|
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_refresh_nodes_ui(
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
server_url_substr: str = "8188",
|
||||||
|
timeout_s: float = 15.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Refresca los combos (checkpoints/loras/vae) de los nodos del grafo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||||
|
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||||
|
timeout_s: timeout de la conexion CDP en segundos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok: bool, refreshed: bool, error: str}. refreshed True si
|
||||||
|
app.refreshComboInNodes resolvio sin excepcion.
|
||||||
|
"""
|
||||||
|
expr = (
|
||||||
|
"(async function(){"
|
||||||
|
" if(!window.app || typeof app.refreshComboInNodes!=='function'){"
|
||||||
|
" return {refreshed:false, error:'window.app.refreshComboInNodes no disponible en la pestana'};"
|
||||||
|
" }"
|
||||||
|
" try{ await app.refreshComboInNodes(); return {refreshed:true, error:''}; }"
|
||||||
|
" catch(e){ return {refreshed:false, error:String(e)}; }"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
r = cdp_eval(
|
||||||
|
expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=server_url_substr,
|
||||||
|
await_promise=True,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
if not r["ok"]:
|
||||||
|
return {"ok": False, "refreshed": False, "error": r["error"]}
|
||||||
|
val = r["value"] or {}
|
||||||
|
return {
|
||||||
|
"ok": bool(val.get("refreshed")),
|
||||||
|
"refreshed": bool(val.get("refreshed")),
|
||||||
|
"error": val.get("error", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(json.dumps(comfyui_refresh_nodes_ui(), ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_set_node_widget_ui
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_set_node_widget_ui(node: str, widget_name: str, value, *, match: str = 'type', port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
|
||||||
|
description: "Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP. Localiza el nodo en app.graph._nodes por type (comfyClass), id o title; asigna widget.value, invoca widget.callback si existe y marca el canvas dirty. Cubre widgets numericos (steps/cfg/seed) y de texto (CLIPTextEncode.text). Compone cdp_eval. Impura: red (CDP) + muta el grafo."
|
||||||
|
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||||
|
uses_functions: ["cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["json"]
|
||||||
|
params:
|
||||||
|
- name: node
|
||||||
|
desc: "Identificador del nodo a localizar, interpretado segun `match`."
|
||||||
|
- name: widget_name
|
||||||
|
desc: "Nombre del widget a editar (ej. 'text', 'steps', 'seed', 'cfg', 'sampler_name')."
|
||||||
|
- name: value
|
||||||
|
desc: "Nuevo valor (str, int, float o bool). Se serializa a JSON para inyectarlo."
|
||||||
|
- name: match
|
||||||
|
desc: "Criterio de busqueda: 'type' (por comfyClass/type, ej. 'CLIPTextEncode'/'KSampler'), 'id' (por n.id) o 'title' (por titulo visible). Default 'type'."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||||
|
- name: server_url_substr
|
||||||
|
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||||
|
output: "dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}. Con match='type' y varios matches, actua sobre el primero y reporta cuantos coincidieron."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/browser/comfyui_set_node_widget_ui.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
|
||||||
|
|
||||||
|
# Cambiar el prompt positivo (widget de texto del CLIPTextEncode) ...
|
||||||
|
print(comfyui_set_node_widget_ui(
|
||||||
|
"CLIPTextEncode", "text", "a blue ceramic mug, studio light", match="type"))
|
||||||
|
# ... y los pasos del sampler (widget numerico).
|
||||||
|
print(comfyui_set_node_widget_ui("KSampler", "steps", 25, match="type"))
|
||||||
|
# -> {'ok': True, 'matched_nodes': 2, 'set': True, 'old_value': 20, 'new_value': 25, 'error': ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Para ajustar parametros de un workflow ya cargado en la UI sin reconstruirlo:
|
||||||
|
cambiar el prompt, los steps, la seed, el cfg o el sampler en vivo antes de
|
||||||
|
encolar con `comfyui_queue_prompt_ui`. Es el paso de "tuning" entre
|
||||||
|
`comfyui_load_workflow_ui` y la cola.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Con `match="type"` y un workflow txt2img hay DOS `CLIPTextEncode` (positivo y
|
||||||
|
negativo): `matched_nodes=2` y solo se edita el primero (el positivo en el grafo
|
||||||
|
por defecto). Para apuntar al negativo usa `match="id"` o `match="title"`.
|
||||||
|
- Nodo o widget inexistente NO lanza: devuelve `ok=False`, `set=False` y un
|
||||||
|
`error` claro ("sin nodo que matchee ..." / "el nodo no tiene widget ...").
|
||||||
|
- `widget.callback` se invoca con el nuevo valor para propagar el cambio (combos,
|
||||||
|
derivados); si el callback de un widget concreto espera mas argumentos, el fallo
|
||||||
|
se traga (try/catch) y el `value` ya queda asignado igualmente.
|
||||||
|
- El cambio vive en el grafo de la UI; para persistirlo a un archivo exportalo con
|
||||||
|
`comfyui_export_workflow_ui` o encolalo.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP.
|
||||||
|
|
||||||
|
Localiza un nodo en `app.graph._nodes` por su tipo (comfyClass), su id o su
|
||||||
|
titulo, y asigna el valor del widget cuyo `name` coincide. Cubre tanto widgets
|
||||||
|
numericos (steps, cfg, seed del KSampler) como de texto (el `text` de un
|
||||||
|
CLIPTextEncode). Tras asignar `widget.value` invoca `widget.callback` si existe
|
||||||
|
para propagar el cambio y marca el canvas dirty. Compone cdp_eval.
|
||||||
|
|
||||||
|
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||||
|
from cdp_eval import cdp_eval
|
||||||
|
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_set_node_widget_ui(
|
||||||
|
node: str,
|
||||||
|
widget_name: str,
|
||||||
|
value,
|
||||||
|
*,
|
||||||
|
match: str = "type",
|
||||||
|
port: int = 9222,
|
||||||
|
server_url_substr: str = "8188",
|
||||||
|
timeout_s: float = 15.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Asigna el valor de un widget de un nodo del grafo en vivo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: identificador del nodo a localizar, interpretado segun `match`.
|
||||||
|
widget_name: nombre del widget a editar (ej. "text", "steps", "seed",
|
||||||
|
"cfg", "sampler_name").
|
||||||
|
value: nuevo valor (str, int, float o bool). Se serializa a JSON.
|
||||||
|
match: criterio de busqueda del nodo. "type" (por comfyClass/type, ej.
|
||||||
|
"CLIPTextEncode" o "KSampler"), "id" (por n.id) o "title" (por el
|
||||||
|
titulo visible del nodo). Default "type".
|
||||||
|
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||||
|
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||||
|
timeout_s: timeout de la conexion CDP en segundos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}.
|
||||||
|
Si `match="type"` produce varios nodos, actua sobre el primero y reporta
|
||||||
|
cuantos coincidieron en matched_nodes.
|
||||||
|
"""
|
||||||
|
expr = (
|
||||||
|
"(function(){"
|
||||||
|
" if(!window.app || !app.graph) return {matched_nodes:0, set:false, error:'window.app.graph no disponible'};"
|
||||||
|
" var nodes = app.graph._nodes || [];"
|
||||||
|
f" var key = {json.dumps(match)};"
|
||||||
|
f" var target = {json.dumps(node)};"
|
||||||
|
f" var wname = {json.dumps(widget_name)};"
|
||||||
|
f" var nval = {json.dumps(value)};"
|
||||||
|
" var matches = nodes.filter(function(n){"
|
||||||
|
" if(key==='id') return String(n.id)===String(target);"
|
||||||
|
" if(key==='title') return n.title===target;"
|
||||||
|
" return (n.comfyClass||n.type)===target;"
|
||||||
|
" });"
|
||||||
|
" if(matches.length===0) return {matched_nodes:0, set:false, error:'sin nodo que matchee '+key+'='+target};"
|
||||||
|
" var n = matches[0];"
|
||||||
|
" var w = (n.widgets||[]).find(function(x){return x.name===wname;});"
|
||||||
|
" if(!w) return {matched_nodes:matches.length, set:false, error:'el nodo no tiene widget \"'+wname+'\"'};"
|
||||||
|
" var old = w.value;"
|
||||||
|
" w.value = nval;"
|
||||||
|
" if(typeof w.callback==='function'){ try{ w.callback(nval); }catch(e){} }"
|
||||||
|
" if(typeof app.graph.setDirtyCanvas==='function') app.graph.setDirtyCanvas(true,true);"
|
||||||
|
" return {matched_nodes:matches.length, set:true, old_value:old, new_value:w.value, error:''};"
|
||||||
|
"})()"
|
||||||
|
)
|
||||||
|
r = cdp_eval(
|
||||||
|
expr,
|
||||||
|
port=port,
|
||||||
|
target_url_substr=server_url_substr,
|
||||||
|
await_promise=False,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
|
if not r["ok"]:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"matched_nodes": 0,
|
||||||
|
"set": False,
|
||||||
|
"old_value": None,
|
||||||
|
"new_value": None,
|
||||||
|
"error": r["error"],
|
||||||
|
}
|
||||||
|
val = r["value"] or {}
|
||||||
|
return {
|
||||||
|
"ok": bool(val.get("set")),
|
||||||
|
"matched_nodes": val.get("matched_nodes", 0),
|
||||||
|
"set": bool(val.get("set")),
|
||||||
|
"old_value": val.get("old_value"),
|
||||||
|
"new_value": val.get("new_value"),
|
||||||
|
"error": val.get("error", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
out = comfyui_set_node_widget_ui(
|
||||||
|
"KSampler", "steps", 25, match="type"
|
||||||
|
)
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -5,7 +5,7 @@ lang: py
|
|||||||
domain: browser
|
domain: browser
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9222, timeout_s: float = 20.0) -> dict"
|
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9334, timeout_s: float = 20.0) -> dict"
|
||||||
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
|
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
|
||||||
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
|
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
|
||||||
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
|
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
|
||||||
@@ -24,7 +24,7 @@ params:
|
|||||||
- name: pages
|
- name: pages
|
||||||
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
|
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
|
||||||
- name: port
|
- name: port
|
||||||
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal de produccion). Para un Chrome aislado (smoke / recon sin mezclar sesion personal) apuntar a 9333 (el del browser_mcp)."
|
desc: "Puerto de remote debugging del Chrome a usar. Default 9334 (perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA 9222 por defecto: ese es el chromium-personal del usuario y el scraping no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke/recon) tambien sirve 9333 (browser_mcp)."
|
||||||
- name: timeout_s
|
- name: timeout_s
|
||||||
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
|
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
|
||||||
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
|
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
|
||||||
@@ -40,17 +40,17 @@ file_path: "python/functions/browser/scrape_workana_projects.py"
|
|||||||
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
|
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
|
||||||
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
|
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
|
||||||
|
|
||||||
# Smoke contra el Chrome aislado del browser_mcp (port 9333, sin login):
|
# Perfil headless dedicado (port 9334, lo levanta el wrapper monitor_freelance_projects_headless):
|
||||||
fn run scrape_workana_projects it-programming es "" 1 9333 25
|
fn run scrape_workana_projects it-programming es "" 1 9334 25
|
||||||
|
|
||||||
# Produccion (chromium-personal, port 9222 por defecto):
|
# Smoke contra el Chrome aislado interactivo del browser_mcp (port 9333, sin login):
|
||||||
fn run scrape_workana_projects it-programming es "" 1 9222 20
|
fn run scrape_workana_projects it-programming es "" 1 9333 25
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
|
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
|
||||||
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
|
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
|
||||||
--category it-programming --language es --port 9222
|
--category it-programming --language es --port 9334
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -78,9 +78,12 @@ porque la pagina es una SPA Vue que monta los cards en runtime.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal
|
- **Requiere un Chrome con remote debugging vivo en `port`**: por defecto 9334 (el
|
||||||
de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin
|
perfil headless dedicado del scraping, que levanta/cierra el wrapper
|
||||||
Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
|
`monitor_freelance_projects_headless`). NO usa 9222 (chromium-personal del usuario)
|
||||||
|
por defecto: el scraping no abre pestanas en el navegador diario. 9333 (browser_mcp)
|
||||||
|
sirve para smoke interactivo. Sin Chrome escuchando devuelve
|
||||||
|
`{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
|
||||||
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
|
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
|
||||||
event NO garantiza que esten en el DOM, por eso la funcion hace polling de
|
event NO garantiza que esten en el DOM, por eso la funcion hace polling de
|
||||||
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
|
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ def scrape_workana_projects(
|
|||||||
language: str = "es",
|
language: str = "es",
|
||||||
extra_query: str = "",
|
extra_query: str = "",
|
||||||
pages: int = 1,
|
pages: int = 1,
|
||||||
port: int = 9222,
|
port: int = 9334,
|
||||||
timeout_s: float = 20.0,
|
timeout_s: float = 20.0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
|
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
|
||||||
@@ -217,9 +217,12 @@ def scrape_workana_projects(
|
|||||||
filtrar por palabra clave (ej. "python", "scraping").
|
filtrar por palabra clave (ej. "python", "scraping").
|
||||||
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
|
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
|
||||||
adicional se navega con &page=N.
|
adicional se navega con &page=N.
|
||||||
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
|
port: Puerto de remote debugging del Chrome a usar. Default 9334 (el
|
||||||
chromium-personal de produccion). Para un Chrome aislado (smoke / recon
|
perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que
|
||||||
sin mezclar sesion personal) apunta a 9333 (el del browser_mcp).
|
levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA
|
||||||
|
9222 por defecto: ese es el chromium-personal del usuario y el scraping
|
||||||
|
no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke /
|
||||||
|
recon) tambien sirve 9333 (el del browser_mcp).
|
||||||
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
|
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
|
||||||
el polling de aparicion de cards. Default 20.0.
|
el polling de aparicion de cards. Default 20.0.
|
||||||
|
|
||||||
@@ -293,7 +296,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--language", default="es")
|
parser.add_argument("--language", default="es")
|
||||||
parser.add_argument("--extra-query", default="")
|
parser.add_argument("--extra-query", default="")
|
||||||
parser.add_argument("--pages", type=int, default=1)
|
parser.add_argument("--pages", type=int, default=1)
|
||||||
parser.add_argument("--port", type=int, default=9222)
|
parser.add_argument("--port", type=int, default=9334)
|
||||||
parser.add_argument("--timeout-s", type=float, default=20.0)
|
parser.add_argument("--timeout-s", type=float, default=20.0)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
name: whatsapp_send_image
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def whatsapp_send_image(name: str, image_path: str, *, caption: str = '', port: int = 9222, target_url_substr: str = 'whatsapp', open_first: bool = True) -> dict"
|
||||||
|
description: "Envia una imagen (con caption opcional) a un chat de WhatsApp Web en una pestana ya logueada del navegador diario via CDP, sin abrir ventana nueva ni darle foco. Abre el chat por nombre exacto (whatsapp_open_chat) y verifica el destinatario (salvaguarda anti-envio-equivocado), hace click real en 'Adjuntar' para exponer el <input type=file> vivo, asigna la imagen con cdp_set_file_input (DOM.setFileInputFiles), espera la bandeja inline y hace click en el boton enviar (icono wds-ic-send-filled) verificando que la bandeja se cerro. Si hay caption, lo envia como mensaje de texto de seguimiento via whatsapp_send_message (en la WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de forma fiable, asi que viaja como segunda burbuja [imagen][caption]). Accion con efecto: envia la imagen DE VERDAD, no reversible."
|
||||||
|
tags: [whatsapp, cdp, browser, automation, image, upload, python, navegator]
|
||||||
|
uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_py_browser, cdp_click_xy_py_browser, cdp_set_file_input_py_browser, whatsapp_send_message_py_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["os", "sys", "time", "json"]
|
||||||
|
params_schema:
|
||||||
|
params:
|
||||||
|
- name: name
|
||||||
|
desc: "Nombre EXACTO del chat o grupo destinatario tal y como aparece en la lista lateral. Se usa para abrir el chat y como salvaguarda de que el composer apunta al destinatario correcto antes de adjuntar."
|
||||||
|
- name: image_path
|
||||||
|
desc: "Ruta de la imagen a enviar. Se expande (~) y se convierte a ruta ABSOLUTA; debe existir en disco o aborta con error sin abrir el chat."
|
||||||
|
- name: caption
|
||||||
|
desc: "Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento (segunda burbuja [imagen][caption]) via whatsapp_send_message; '' (default) envia solo la imagen. La WhatsApp Web compacta actual no permite automatizar el caption embebido en la imagen de forma fiable."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging de Chrome. Default 9222."
|
||||||
|
- name: target_url_substr
|
||||||
|
desc: "Substring que debe contener la URL del target (pestana). Default 'whatsapp'."
|
||||||
|
- name: open_first
|
||||||
|
desc: "Si True (default), abre el chat por su nombre antes de adjuntar. Si False, asume el chat ya abierto pero verifica el aria-label del composer contra name (aborta si no coincide)."
|
||||||
|
output: "dict {ok: bool (imagen + caption enviados), sent: bool (imagen enviada), caption_sent: bool (caption de seguimiento enviado, False si no habia o fallo), recipient: str, image: str (ruta absoluta), caption: str, error: str (motivo del fallo, vacio si todo ok)}. sent=True solo si la imagen se adjunto y se envio dejando la bandeja vacia. Nunca lanza: los fallos se reportan en 'sent'/'ok' + 'error'."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_envia_imagen_y_caption_de_seguimiento", "test_envia_sin_caption_no_manda_texto", "test_edge_imagen_no_existe_error_sin_abrir", "test_edge_open_fallido_error_sin_adjuntar", "test_seguridad_open_first_false_label_no_coincide_aborta", "test_error_set_file_input_falla_no_envia"]
|
||||||
|
test_file_path: "python/functions/browser/whatsapp_send_image_test.py"
|
||||||
|
file_path: "python/functions/browser/whatsapp_send_image.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.whatsapp_send_image import whatsapp_send_image
|
||||||
|
|
||||||
|
# Requiere WhatsApp Web abierto y logueado (y DESBLOQUEADO si tiene app-lock) en un
|
||||||
|
# Chrome lanzado con --remote-debugging-port=9222.
|
||||||
|
res = whatsapp_send_image(
|
||||||
|
"NOTAS WASAP",
|
||||||
|
"/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png",
|
||||||
|
caption="item icon: potion",
|
||||||
|
)
|
||||||
|
print(res)
|
||||||
|
# -> {"ok": True, "sent": True, "recipient": "NOTAS WASAP",
|
||||||
|
# "image": ".../item_icon_potion_00001_.png", "caption": "item icon: potion", "error": ""}
|
||||||
|
```
|
||||||
|
|
||||||
|
O directo por CLI: `python3 python/functions/browser/whatsapp_send_image.py "NOTAS WASAP" /ruta/abs.png "mi caption"`.
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites **enviar una imagen (foto, captura, asset generado) a un contacto o grupo
|
||||||
|
por su nombre exacto** en WhatsApp Web, sin abrir ventana nueva ni robar el foco al usuario.
|
||||||
|
Es la version "imagen" de `whatsapp_send_message_py_browser`: usala cuando ya tienes el
|
||||||
|
nombre exacto del destinatario y la ruta de un archivo de imagen en disco. Para texto plano,
|
||||||
|
usa `whatsapp_send_message`; para leer/confirmar lo enviado, `whatsapp_read_chat`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Accion con efecto: envia la imagen DE VERDAD.** No es reversible. Verifica que `name` es
|
||||||
|
EXACTO antes de llamar (la salvaguarda abre el chat y comprueba el composer, pero el nombre
|
||||||
|
debe coincidir con el `title` de la lista lateral).
|
||||||
|
- **App-lock de WhatsApp Web.** Si la cuenta tiene el "bloqueo de la aplicacion" activo, el DOM
|
||||||
|
de chats no se renderiza (solo la pantalla de password) y la funcion fallara al abrir el chat.
|
||||||
|
Hay que desbloquearlo primero (teclear el password en `input[type=password]` + boton
|
||||||
|
"Desbloquear"). Sintoma: `whatsapp_open_chat` devuelve `opened: False` y la lista lateral sale
|
||||||
|
vacia aunque la sesion siga logueada.
|
||||||
|
- **El menu "Adjuntar" es un TOGGLE (`aria-expanded`).** El `<input type=file>` solo queda "vivo"
|
||||||
|
mientras el menu esta ABIERTO; asignar al input con el menu cerrado es un decoy (no abre preview).
|
||||||
|
Clickar "Adjuntar" cuando YA esta abierto lo CIERRA. Por eso la funcion clicka solo si
|
||||||
|
`aria-expanded != "true"` y reintenta hasta verlo abierto (no un click ciego). La WhatsApp Web
|
||||||
|
actual usa una **bandeja de medios INLINE compacta** sobre el composer (no un drawer a pantalla
|
||||||
|
completa).
|
||||||
|
- **El envio se verifica por la ultima fila de `#main`, NO por contar filas.** Las filas de `#main`
|
||||||
|
se VIRTUALIZAN (las antiguas se desmontan al llegar nuevas), asi que el total se mantiene casi
|
||||||
|
constante. La funcion confirma el envio comprobando que la bandeja se vacio (adjuntos=0) Y que la
|
||||||
|
ultima fila renderizada es ya una imagen (`img[src^="blob:"]`).
|
||||||
|
- **El caption NO se embebe en la imagen: viaja como mensaje de texto de seguimiento.** En esta
|
||||||
|
WhatsApp Web compacta hay dos botones de envio cuando hay media: "Enviar N seleccionados" (envia
|
||||||
|
la bandeja, IGNORA el texto del composer) y "Enviar"/Enter (envia el texto como burbuja aparte,
|
||||||
|
descartando la media en cola). No hay un campo de caption por-imagen automatizable de forma
|
||||||
|
fiable. Por eso la funcion envia primero la imagen (boton de la bandeja) y, si hay `caption`, lo
|
||||||
|
manda despues como mensaje de texto via `whatsapp_send_message` (`open_first=False`): el resultado
|
||||||
|
es [imagen][caption] como dos burbujas. `caption_sent` indica si esa segunda burbuja salio.
|
||||||
|
- **Selector de aria-label en espanol.** El preview se detecta por `[aria-label="Quitar archivo
|
||||||
|
adjunto"]` y el boton de adjuntar por `[aria-label="Adjuntar"]`: dependen del idioma de la UI
|
||||||
|
(espanol). En otro locale habria que ajustar los aria-labels.
|
||||||
|
- **Las imagenes se ACUMULAN en la bandeja.** Cada `setFileInputFiles` anade una miniatura; si un
|
||||||
|
envio queda a medias, la siguiente llamada podria sumar a las pendientes. La funcion verifica que
|
||||||
|
la bandeja queda vacia tras enviar (adjuntos=0) para confirmar; si no se cierra, devuelve
|
||||||
|
`sent=False` con "envio incierto".
|
||||||
|
- **Salvaguarda anti-destinatario-equivocado**: con `open_first=True` abre y verifica el chat; con
|
||||||
|
`open_first=False` lee el aria-label del composer y aborta si no contiene `name`.
|
||||||
|
- **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin traerla a primer plano.
|
||||||
|
- **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero personal. Usar
|
||||||
|
con cautela y bajo tu responsabilidad.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Envia una imagen (con caption opcional) a un chat de WhatsApp Web via Chrome DevTools Protocol.
|
||||||
|
|
||||||
|
Compone `whatsapp_open_chat` (abrir + verificar destinatario) con primitivas CDP del
|
||||||
|
registry (`cdp_eval`, `cdp_click_xy`, `cdp_set_file_input`) y `whatsapp_send_message` para
|
||||||
|
adjuntar y enviar una imagen a un contacto/grupo SIN abrir ventana nueva ni darle foco al
|
||||||
|
sistema.
|
||||||
|
|
||||||
|
Flujo (modelo de bandeja de medios INLINE de la WhatsApp Web actual), con salvaguarda
|
||||||
|
anti-envio-al-contacto-equivocado:
|
||||||
|
|
||||||
|
1. Abre el chat por su nombre exacto (`open_first=True`). Si no abre, aborta. Con
|
||||||
|
`open_first=False`, asume el chat abierto pero VERIFICA que el aria-label del composer
|
||||||
|
contiene el nombre; si no, aborta por seguridad.
|
||||||
|
2. ASEGURA que el menu "Adjuntar" esta ABIERTO (es un toggle `aria-expanded`: clickar
|
||||||
|
cuando ya esta abierto lo cierra). Solo entonces el `<input type=file>` queda "vivo".
|
||||||
|
3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la
|
||||||
|
imagen aparece como miniatura en la bandeja inline.
|
||||||
|
4. Espera a que la bandeja aparezca (boton "Quitar archivo adjunto" presente) y hace click
|
||||||
|
real en el boton de enviar la bandeja (icono `wds-ic-send-filled`).
|
||||||
|
5. Verifica el envio comprobando que la bandeja se cerro Y que la ultima fila de `#main`
|
||||||
|
es ahora una imagen (las filas se virtualizan, asi que NO sirve contar filas).
|
||||||
|
6. Si `caption` no esta vacio, lo envia como un MENSAJE DE TEXTO de seguimiento via
|
||||||
|
`whatsapp_send_message` (con `open_first=False`, el chat ya esta abierto). En la
|
||||||
|
WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de
|
||||||
|
forma fiable, asi que la descripcion viaja como una segunda burbuja: [imagen][caption].
|
||||||
|
|
||||||
|
Validado contra WhatsApp Web real. Accion CON EFECTO REAL E IRREVERSIBLE: envia la imagen
|
||||||
|
(y el caption) de verdad.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from browser.cdp_click_xy import cdp_click_xy
|
||||||
|
from browser.cdp_set_file_input import cdp_set_file_input
|
||||||
|
from browser.whatsapp_open_chat import whatsapp_open_chat
|
||||||
|
from browser.whatsapp_send_message import whatsapp_send_message
|
||||||
|
|
||||||
|
|
||||||
|
def _center(expr: str, port: int, substr: str):
|
||||||
|
"""Evalua una expresion que devuelve JSON {x,y} (o null) y la parsea a dict/None."""
|
||||||
|
r = cdp_eval(expr, port=port, target_url_substr=substr)
|
||||||
|
val = r.get("value")
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except Exception: # noqa: BLE001 — value no-JSON
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _adj_expanded(port: int, substr: str) -> str:
|
||||||
|
"""Estado aria-expanded del boton 'Adjuntar' ('true'/'false'/'no-btn')."""
|
||||||
|
r = cdp_eval(
|
||||||
|
'/*ADJEXP*/var e=document.querySelector(\'button[aria-label="Adjuntar"]\'); '
|
||||||
|
"e?e.getAttribute('aria-expanded'):'no-btn'",
|
||||||
|
port=port, target_url_substr=substr,
|
||||||
|
)
|
||||||
|
return r.get("value") or "no-btn"
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_count(port: int, substr: str) -> int:
|
||||||
|
"""Numero de adjuntos en la bandeja inline (botones 'Quitar archivo adjunto')."""
|
||||||
|
r = cdp_eval(
|
||||||
|
'/*PREVIEW*/document.querySelectorAll(\'[aria-label="Quitar archivo adjunto"]\').length',
|
||||||
|
port=port, target_url_substr=substr,
|
||||||
|
)
|
||||||
|
v = r.get("value")
|
||||||
|
return v if isinstance(v, int) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _last_row_is_image(port: int, substr: str) -> bool:
|
||||||
|
"""True si la ultima fila renderizada de #main contiene una imagen (blob)."""
|
||||||
|
r = cdp_eval(
|
||||||
|
'/*LASTIMG*/(() => {const r=[...document.querySelectorAll(\'#main [role="row"]\')]'
|
||||||
|
".slice(-1)[0]; return r?!!r.querySelector('img[src^=\"blob:\"]'):false;})()",
|
||||||
|
port=port, target_url_substr=substr,
|
||||||
|
)
|
||||||
|
return bool(r.get("value"))
|
||||||
|
|
||||||
|
|
||||||
|
def whatsapp_send_image(
|
||||||
|
name: str,
|
||||||
|
image_path: str,
|
||||||
|
*,
|
||||||
|
caption: str = "",
|
||||||
|
port: int = 9222,
|
||||||
|
target_url_substr: str = "whatsapp",
|
||||||
|
open_first: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Envia una imagen (con caption opcional de seguimiento) a un chat de WhatsApp Web.
|
||||||
|
|
||||||
|
Accion CON EFECTO: envia la imagen DE VERDAD (no reversible). Verifica `name`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Nombre EXACTO del chat/grupo destinatario, tal y como aparece en la lista
|
||||||
|
lateral. Se usa para abrir el chat y como salvaguarda de que el composer apunta
|
||||||
|
al destinatario correcto antes de adjuntar.
|
||||||
|
image_path: Ruta de la imagen a enviar. Se expande (`~`) y se convierte a ruta
|
||||||
|
ABSOLUTA; debe existir en disco.
|
||||||
|
caption: Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento
|
||||||
|
(segunda burbuja [imagen][caption]) via `whatsapp_send_message`; "" (default)
|
||||||
|
envia solo la imagen.
|
||||||
|
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||||
|
target_url_substr: Substring que debe contener la URL del target (pestana). Default
|
||||||
|
"whatsapp".
|
||||||
|
open_first: Si True (default), abre el chat por su nombre antes de adjuntar. Si
|
||||||
|
False, asume el chat ya abierto pero verifica el aria-label del composer contra
|
||||||
|
`name` (aborta si no coincide).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con claves:
|
||||||
|
ok: bool — True si la imagen se envio y (si habia caption) el caption tambien.
|
||||||
|
sent: bool — True si la IMAGEN se envio.
|
||||||
|
caption_sent: bool — True si el caption de seguimiento se envio (False si no
|
||||||
|
habia caption o si fallo).
|
||||||
|
recipient: str — el nombre solicitado.
|
||||||
|
image: str — ruta absoluta de la imagen.
|
||||||
|
caption: str — caption solicitado.
|
||||||
|
error: str — motivo del fallo (vacio si todo ok).
|
||||||
|
|
||||||
|
Nunca lanza: los fallos se reportan en "sent"/"ok" + "error".
|
||||||
|
"""
|
||||||
|
S = target_url_substr
|
||||||
|
abs_img = os.path.abspath(os.path.expanduser(image_path))
|
||||||
|
|
||||||
|
def fail(error: str) -> dict:
|
||||||
|
return {"ok": False, "sent": False, "caption_sent": False, "recipient": name,
|
||||||
|
"image": abs_img, "caption": caption, "error": error}
|
||||||
|
|
||||||
|
# 0. La imagen debe existir.
|
||||||
|
if not os.path.isfile(abs_img):
|
||||||
|
return fail(f"imagen no encontrada: {abs_img}")
|
||||||
|
|
||||||
|
# 1. Abrir + verificar destinatario correcto (salvaguarda anti-equivocacion).
|
||||||
|
if open_first:
|
||||||
|
o = whatsapp_open_chat(name, port=port, target_url_substr=S)
|
||||||
|
if not o.get("opened"):
|
||||||
|
return fail(o.get("reason", "no se pudo abrir el chat"))
|
||||||
|
else:
|
||||||
|
chk = cdp_eval(
|
||||||
|
'/*LABEL*/var b=document.querySelector(\'footer div[contenteditable="true"]\'); '
|
||||||
|
"b?b.getAttribute('aria-label'):null",
|
||||||
|
port=port, target_url_substr=S,
|
||||||
|
)
|
||||||
|
if name not in (chk.get("value") or ""):
|
||||||
|
return fail("el chat abierto no coincide con el destinatario; abortado por seguridad")
|
||||||
|
|
||||||
|
# 2. Asegurar el menu "Adjuntar" ABIERTO. Es un TOGGLE (aria-expanded): clickar cuando
|
||||||
|
# ya esta abierto lo cierra y el input vivo desaparece. Por eso clickamos SOLO si no
|
||||||
|
# esta expandido y reintentamos hasta verlo abierto.
|
||||||
|
adj = _center(
|
||||||
|
'/*ADJUNTAR*/(() => {const e=document.querySelector(\'button[aria-label="Adjuntar"]\');'
|
||||||
|
"if(!e)return null;const b=e.getBoundingClientRect();"
|
||||||
|
"return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()",
|
||||||
|
port, S,
|
||||||
|
)
|
||||||
|
if not adj:
|
||||||
|
return fail("boton 'Adjuntar' no encontrado en el footer")
|
||||||
|
for _ in range(3):
|
||||||
|
if _adj_expanded(port, S) == "true":
|
||||||
|
break
|
||||||
|
cdp_click_xy(adj["x"], adj["y"], port=port, target_url_substr=S)
|
||||||
|
time.sleep(0.6)
|
||||||
|
if _adj_expanded(port, S) != "true":
|
||||||
|
return fail("no se pudo abrir el menu 'Adjuntar' (aria-expanded sigue en false)")
|
||||||
|
|
||||||
|
# 3. Asignar la imagen al primer <input type=file> (el "vivo" mientras el menu esta
|
||||||
|
# abierto). El composer queda VACIO, asi que luego el unico wds-ic-send-filled es el
|
||||||
|
# de enviar la bandeja.
|
||||||
|
r = cdp_set_file_input('input[type="file"]', abs_img,
|
||||||
|
port=port, target_url_substr=S)
|
||||||
|
if not r.get("ok"):
|
||||||
|
return fail("no se pudo adjuntar la imagen: " + r.get("error", ""))
|
||||||
|
|
||||||
|
# 4. Esperar a que la bandeja aparezca (adjunto presente).
|
||||||
|
attached = False
|
||||||
|
for _ in range(15):
|
||||||
|
time.sleep(0.2)
|
||||||
|
if _attachment_count(port, S) > 0:
|
||||||
|
attached = True
|
||||||
|
break
|
||||||
|
if not attached:
|
||||||
|
return fail("el preview no aparecio tras adjuntar la imagen")
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 5. Click real en el boton de enviar la bandeja (icono wds-ic-send-filled).
|
||||||
|
snd = _center(
|
||||||
|
'/*SEND*/(() => {const e=document.querySelector(\'span[data-icon="wds-ic-send-filled"]\');'
|
||||||
|
"if(!e)return null;const b=e.getBoundingClientRect();"
|
||||||
|
"if(b.width===0)return null;"
|
||||||
|
"return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()",
|
||||||
|
port, S,
|
||||||
|
)
|
||||||
|
if not snd:
|
||||||
|
return fail("boton de enviar (wds-ic-send-filled) no encontrado")
|
||||||
|
cdp_click_xy(snd["x"], snd["y"], port=port, target_url_substr=S)
|
||||||
|
|
||||||
|
# 6. Confirmar envio: la bandeja se cierra (adjuntos=0) Y la ultima fila de #main es ya
|
||||||
|
# una imagen. Las filas de #main se VIRTUALIZAN, asi que contar filas no sirve.
|
||||||
|
image_sent = False
|
||||||
|
for _ in range(20):
|
||||||
|
time.sleep(0.2)
|
||||||
|
if _attachment_count(port, S) == 0 and _last_row_is_image(port, S):
|
||||||
|
image_sent = True
|
||||||
|
break
|
||||||
|
if not image_sent:
|
||||||
|
return fail("no se confirmo la imagen en el chat tras pulsar enviar; envio incierto")
|
||||||
|
|
||||||
|
# 7. Caption opcional como mensaje de texto de seguimiento (segunda burbuja).
|
||||||
|
caption_sent = False
|
||||||
|
if caption:
|
||||||
|
m = whatsapp_send_message(name, caption, port=port, target_url_substr=S,
|
||||||
|
open_first=False)
|
||||||
|
caption_sent = bool(m.get("sent"))
|
||||||
|
if not caption_sent:
|
||||||
|
return {"ok": False, "sent": True, "caption_sent": False, "recipient": name,
|
||||||
|
"image": abs_img, "caption": caption,
|
||||||
|
"error": "imagen enviada pero el caption fallo: " + m.get("reason", "")}
|
||||||
|
|
||||||
|
return {"ok": True, "sent": True, "caption_sent": caption_sent, "recipient": name,
|
||||||
|
"image": abs_img, "caption": caption, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
chat = sys.argv[1] if len(sys.argv) > 1 else "NOTAS WASAP"
|
||||||
|
img = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
|
cap = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||||
|
out = whatsapp_send_image(chat, img, caption=cap,
|
||||||
|
port=9222, target_url_substr="whatsapp")
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tests para whatsapp_send_image.
|
||||||
|
|
||||||
|
whatsapp_send_image compone whatsapp_open_chat con tres primitivas CDP (cdp_eval,
|
||||||
|
cdp_click_xy, cdp_set_file_input) y `whatsapp_send_message` (para el caption de
|
||||||
|
seguimiento), y requiere un Chrome vivo. Aqui se mockean todas con monkeypatch sobre el
|
||||||
|
modulo `browser.whatsapp_send_image` (donde quedan ligados los nombres por el
|
||||||
|
`from browser.X import Y`), de modo que NO hace falta Chrome.
|
||||||
|
|
||||||
|
El fake de cdp_eval distingue cada expresion por un marcador de comentario JS embebido en
|
||||||
|
ella (`/*ADJUNTAR*/`, `/*ADJEXP*/`, `/*PREVIEW*/`, `/*SEND*/`, `/*LASTIMG*/`, `/*LABEL*/`).
|
||||||
|
El estado simula el ciclo real: el menu Adjuntar arranca cerrado y se abre tras un click;
|
||||||
|
tras pulsar enviar (/*SEND*/) la bandeja se vacia (/*PREVIEW*/ -> 0) y la ultima fila pasa a
|
||||||
|
ser una imagen (/*LASTIMG*/ -> True).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
import browser.whatsapp_send_image as wsi # noqa: E402
|
||||||
|
from browser.whatsapp_send_image import whatsapp_send_image # noqa: E402
|
||||||
|
|
||||||
|
# Imagen real para pasar la validacion de existencia (este propio archivo de test).
|
||||||
|
_IMG = os.path.abspath(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class _Spy:
|
||||||
|
def __init__(self, ret=None):
|
||||||
|
self.calls = []
|
||||||
|
self.ret = ret if ret is not None else {"ok": True}
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
self.calls.append((args, kwargs))
|
||||||
|
return self.ret
|
||||||
|
|
||||||
|
|
||||||
|
def _make_eval(label="Escribir un mensaje para el grupo NOTAS WASAP", state=None):
|
||||||
|
state = state if state is not None else {}
|
||||||
|
|
||||||
|
def fake(expr, *, port=9222, target_url_substr=""):
|
||||||
|
if "/*ADJUNTAR*/" in expr:
|
||||||
|
return {"ok": True, "value": json.dumps({"x": 575, "y": 589}), "error": ""}
|
||||||
|
if "/*ADJEXP*/" in expr:
|
||||||
|
n = state.get("adjexp_n", 0)
|
||||||
|
state["adjexp_n"] = n + 1
|
||||||
|
return {"ok": True, "value": ("false" if n == 0 else "true"), "error": ""}
|
||||||
|
if "/*SEND*/" in expr:
|
||||||
|
state["after_send"] = True
|
||||||
|
return {"ok": True, "value": json.dumps({"x": 1136, "y": 578}), "error": ""}
|
||||||
|
if "/*PREVIEW*/" in expr:
|
||||||
|
return {"ok": True, "value": 0 if state.get("after_send") else 1, "error": ""}
|
||||||
|
if "/*LASTIMG*/" in expr:
|
||||||
|
return {"ok": True, "value": bool(state.get("after_send")), "error": ""}
|
||||||
|
if "/*LABEL*/" in expr:
|
||||||
|
return {"ok": True, "value": label, "error": ""}
|
||||||
|
return {"ok": True, "value": None, "error": ""}
|
||||||
|
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_common(monkeypatch, *, eval_fn, set_ret={"ok": True}, open_ret=None,
|
||||||
|
send_msg_ret={"sent": True}):
|
||||||
|
open_spy = _Spy(ret=open_ret if open_ret is not None else {"opened": True, "name": "x"})
|
||||||
|
click_spy = _Spy(ret={"ok": True})
|
||||||
|
set_spy = _Spy(ret=set_ret)
|
||||||
|
sendmsg_spy = _Spy(ret=send_msg_ret)
|
||||||
|
monkeypatch.setattr(wsi, "whatsapp_open_chat", open_spy)
|
||||||
|
monkeypatch.setattr(wsi, "cdp_eval", eval_fn)
|
||||||
|
monkeypatch.setattr(wsi, "cdp_click_xy", click_spy)
|
||||||
|
monkeypatch.setattr(wsi, "cdp_set_file_input", set_spy)
|
||||||
|
monkeypatch.setattr(wsi, "whatsapp_send_message", sendmsg_spy)
|
||||||
|
monkeypatch.setattr(wsi.time, "sleep", lambda *a, **k: None)
|
||||||
|
return open_spy, click_spy, set_spy, sendmsg_spy
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_envia_imagen_y_caption_de_seguimiento(monkeypatch):
|
||||||
|
cap = "item icon: potion"
|
||||||
|
state = {}
|
||||||
|
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||||
|
monkeypatch, eval_fn=_make_eval(state=state))
|
||||||
|
|
||||||
|
res = whatsapp_send_image("NOTAS WASAP", _IMG, caption=cap,
|
||||||
|
port=9222, target_url_substr="whatsapp")
|
||||||
|
|
||||||
|
assert res["sent"] is True and res["ok"] is True
|
||||||
|
assert res["caption_sent"] is True
|
||||||
|
assert res["recipient"] == "NOTAS WASAP"
|
||||||
|
assert res["image"] == _IMG
|
||||||
|
assert res["caption"] == cap
|
||||||
|
assert res["error"] == ""
|
||||||
|
# Se adjunto la imagen (ruta absoluta) por setFileInputFiles, una sola vez.
|
||||||
|
assert len(set_spy.calls) == 1
|
||||||
|
assert set_spy.calls[0][0][0] == 'input[type="file"]'
|
||||||
|
assert set_spy.calls[0][0][1] == _IMG
|
||||||
|
# Dos clicks reales: abrir Adjuntar (menu cerrado al inicio) y Enviar.
|
||||||
|
assert len(click_spy.calls) == 2
|
||||||
|
# El caption viaja como mensaje de texto de seguimiento, open_first=False.
|
||||||
|
assert len(sendmsg_spy.calls) == 1
|
||||||
|
assert sendmsg_spy.calls[0][0][0] == "NOTAS WASAP"
|
||||||
|
assert sendmsg_spy.calls[0][0][1] == cap
|
||||||
|
assert sendmsg_spy.calls[0][1].get("open_first") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_envia_sin_caption_no_manda_texto(monkeypatch):
|
||||||
|
state = {}
|
||||||
|
_, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||||
|
monkeypatch, eval_fn=_make_eval(state=state))
|
||||||
|
|
||||||
|
res = whatsapp_send_image("NOTAS WASAP", _IMG, caption="",
|
||||||
|
port=9222, target_url_substr="whatsapp")
|
||||||
|
|
||||||
|
assert res["sent"] is True
|
||||||
|
assert res["caption_sent"] is False
|
||||||
|
# Sin caption: NO se manda mensaje de texto de seguimiento.
|
||||||
|
assert len(sendmsg_spy.calls) == 0
|
||||||
|
assert len(set_spy.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch):
|
||||||
|
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||||
|
monkeypatch, eval_fn=_make_eval())
|
||||||
|
|
||||||
|
res = whatsapp_send_image("NOTAS WASAP", "/no/existe/foo.png",
|
||||||
|
port=9222, target_url_substr="whatsapp")
|
||||||
|
|
||||||
|
assert res["sent"] is False and res["ok"] is False
|
||||||
|
assert "no encontrada" in res["error"]
|
||||||
|
# Ni siquiera se intento abrir el chat ni adjuntar.
|
||||||
|
assert len(open_spy.calls) == 0
|
||||||
|
assert len(set_spy.calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_open_fallido_error_sin_adjuntar(monkeypatch):
|
||||||
|
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||||
|
monkeypatch, eval_fn=_make_eval(),
|
||||||
|
open_ret={"opened": False, "name": "x",
|
||||||
|
"reason": "chat no encontrado en la lista (no cargado o nombre inexacto)"})
|
||||||
|
|
||||||
|
res = whatsapp_send_image("Inexistente", _IMG,
|
||||||
|
port=9222, target_url_substr="whatsapp")
|
||||||
|
|
||||||
|
assert res["sent"] is False
|
||||||
|
assert "no encontrado" in res["error"]
|
||||||
|
# No se adjunto ni se hizo click cuando el chat no abrio.
|
||||||
|
assert len(set_spy.calls) == 0
|
||||||
|
assert len(click_spy.calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_seguridad_open_first_false_label_no_coincide_aborta(monkeypatch):
|
||||||
|
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||||
|
monkeypatch,
|
||||||
|
eval_fn=_make_eval(label="Escribir un mensaje para el grupo OTRO CHAT"))
|
||||||
|
|
||||||
|
res = whatsapp_send_image("NOTAS WASAP", _IMG,
|
||||||
|
port=9222, target_url_substr="whatsapp",
|
||||||
|
open_first=False)
|
||||||
|
|
||||||
|
assert res["sent"] is False
|
||||||
|
assert "abortado por seguridad" in res["error"]
|
||||||
|
# SEGURIDAD: no se adjunto ni se clicko nada.
|
||||||
|
assert len(set_spy.calls) == 0
|
||||||
|
assert len(click_spy.calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_set_file_input_falla_no_envia(monkeypatch):
|
||||||
|
state = {}
|
||||||
|
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||||
|
monkeypatch, eval_fn=_make_eval(state=state),
|
||||||
|
set_ret={"ok": False, "error": "no element matches selector"})
|
||||||
|
|
||||||
|
res = whatsapp_send_image("NOTAS WASAP", _IMG,
|
||||||
|
port=9222, target_url_substr="whatsapp")
|
||||||
|
|
||||||
|
assert res["sent"] is False
|
||||||
|
assert "no se pudo adjuntar" in res["error"]
|
||||||
|
# Se abrio Adjuntar (1 click) e intento adjuntar (1 set), pero NO se envio nada.
|
||||||
|
assert len(set_spy.calls) == 1
|
||||||
|
assert len(click_spy.calls) == 1
|
||||||
|
assert len(sendmsg_spy.calls) == 0
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: ask_llm_vision
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def ask_llm_vision(prompt: str, image_path: str = '', *, image_b64: str = '', media_type: str = '', model: str = 'claude-opus-4-8', system: str = '', max_tokens: int = 4096, echo: bool = False, token: str = '') -> dict"
|
||||||
|
description: "Pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic con el token OAuth de Claude Max. Construye un content block [imagen base64, texto] y devuelve dict {ok, text, model, error}. Wrapper sobre stream_anthropic_messages (grupo claude-direct, arranque 0, sin proceso claude). Util para describir/puntuar/clasificar imagenes (p.ej. evaluar una generacion ComfyUI)."
|
||||||
|
error_type: error_go_core
|
||||||
|
tags: ["claude-direct", "llm", "anthropic", "vision", "multimodal", "image", "oauth"]
|
||||||
|
uses_functions:
|
||||||
|
- stream_anthropic_messages_py_core
|
||||||
|
uses_types: []
|
||||||
|
params:
|
||||||
|
- name: prompt
|
||||||
|
desc: "Pregunta o instruccion de texto sobre la imagen."
|
||||||
|
- name: image_path
|
||||||
|
desc: "Ruta a la imagen en disco; se lee y codifica a base64. El media_type se deduce de la extension si no se pasa. Ignorado si se pasa image_b64."
|
||||||
|
- name: image_b64
|
||||||
|
desc: "Alternativa a image_path: la imagen ya en base64 (sin prefijo data:). Requiere media_type explicito. keyword-only."
|
||||||
|
- name: media_type
|
||||||
|
desc: "Tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif). Obligatorio con image_b64; deducido del path si se omite. keyword-only."
|
||||||
|
- name: model
|
||||||
|
desc: "Id del modelo Anthropic con vision. Default claude-opus-4-8. keyword-only."
|
||||||
|
- name: system
|
||||||
|
desc: "System prompt opcional (string vacio = ninguno). keyword-only."
|
||||||
|
- name: max_tokens
|
||||||
|
desc: "Maximo de tokens de salida. Default 4096. keyword-only."
|
||||||
|
- name: echo
|
||||||
|
desc: "Si True, vuelca el texto a stdout segun llega (streaming). keyword-only."
|
||||||
|
- name: token
|
||||||
|
desc: "Token OAuth; si vacio lo carga stream_anthropic_messages automaticamente. keyword-only."
|
||||||
|
output: "dict {ok, text, model, error}. En exito ok=True y text lleva la respuesta completa; en error ok=False, text vacio y error describe la causa. Nunca lanza excepcion."
|
||||||
|
file_path: python/functions/core/ask_llm_vision.py
|
||||||
|
---
|
||||||
|
|
||||||
|
# ask_llm_vision
|
||||||
|
|
||||||
|
Versión multimodal de [`ask_llm`](ask_llm.md): adjunta una imagen al prompt y devuelve la
|
||||||
|
respuesta del modelo. Usa la API directa de Anthropic con el token OAuth de Claude Max (sin
|
||||||
|
proceso `claude`, arranque 0), reutilizando [`stream_anthropic_messages`](stream_anthropic_messages.md),
|
||||||
|
que ya acepta content blocks multimodales. Cumple la regla `llm_invocation`: SIEMPRE claude-direct,
|
||||||
|
NUNCA `claude -p`.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI (fn run): describe una imagen
|
||||||
|
fn run ask_llm_vision "describe esta imagen en una frase" --image ~/ComfyUI/output/demo_00001_.png
|
||||||
|
|
||||||
|
# Directo con el venv
|
||||||
|
python/.venv/bin/python3 python/functions/core/ask_llm_vision.py \
|
||||||
|
"que defectos ves en esta cara?" --image /tmp/render.png --model claude-opus-4-8
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from core.ask_llm_vision import ask_llm_vision
|
||||||
|
|
||||||
|
res = ask_llm_vision(
|
||||||
|
"Puntua de 0 a 10 el realismo de este retrato y justifica en una frase.",
|
||||||
|
image_path="/tmp/portrait.png",
|
||||||
|
model="claude-opus-4-8",
|
||||||
|
)
|
||||||
|
if res["ok"]:
|
||||||
|
print(res["text"])
|
||||||
|
else:
|
||||||
|
print("error:", res["error"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando necesites el juicio del modelo **sobre una imagen** (describir, puntuar, clasificar,
|
||||||
|
comparar, detectar defectos). Caso típico: cerrar el bucle de scoring de una skill ComfyUI —
|
||||||
|
generas un PNG y `ask_llm_vision` lo evalúa para alimentar `score_mean`.
|
||||||
|
- Si solo mandas texto, usa `ask_llm`. Si necesitas tools / loop agéntico, `run_claude_tool_loop_py_core`.
|
||||||
|
Si necesitas los eventos crudos (deltas, tool_use), `stream_anthropic_messages_py_core`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Es impura y hace red**: una request HTTP a `api.anthropic.com` por llamada. Sujeta a los
|
||||||
|
rate limits del plan (`HTTP 429` en ráfagas → espacia o reporta el error del dict).
|
||||||
|
- **media_type obligatorio con `image_b64`**: sin extensión de archivo no se puede deducir; pásalo
|
||||||
|
explícito (`image/png`, `image/jpeg`, `image/webp`, `image/gif`).
|
||||||
|
- **No lanza excepción**: errores (imagen inexistente, fallo HTTP) salen como `{ok: False, error: ...}`,
|
||||||
|
no como `raise`. Comprueba siempre `res["ok"]` antes de usar `res["text"]`.
|
||||||
|
- **Tamaño de la imagen**: imágenes muy grandes inflan los tokens de entrada (la API escala/limita
|
||||||
|
internamente). Para puntuar en lote conviene reducir la resolución antes.
|
||||||
|
- **Modelo con visión**: usa un modelo multimodal (`claude-opus-4-8` por defecto). Un id inexistente
|
||||||
|
da `404 not_found_error` propagado en `error`.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""ask_llm_vision — pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic.
|
||||||
|
|
||||||
|
Wrapper sobre `stream_anthropic_messages` (grupo claude-direct) que construye un mensaje
|
||||||
|
multimodal `[bloque de imagen base64, bloque de texto]` y devuelve la respuesta del modelo.
|
||||||
|
Respeta la regla `llm_invocation`: SIEMPRE API directa (claude-direct, arranque 0 con el token
|
||||||
|
OAuth de Claude Max), NUNCA `claude -p`.
|
||||||
|
|
||||||
|
A diferencia de `ask_llm`, que solo manda texto, esta funcion adjunta una imagen — util para
|
||||||
|
describir, puntuar o clasificar imagenes (p.ej. evaluar el resultado de una generacion ComfyUI).
|
||||||
|
|
||||||
|
Impura: lee la imagen de disco y hace una request HTTP a api.anthropic.com.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "claude-opus-4-8"
|
||||||
|
|
||||||
|
_MEDIA_TYPES = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".jpe": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _media_type_for(path: str) -> str:
|
||||||
|
"""Deduce el media_type MIME a partir de la extension del archivo (o "" si no se reconoce)."""
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
return _MEDIA_TYPES.get(ext, "")
|
||||||
|
|
||||||
|
|
||||||
|
def ask_llm_vision(
|
||||||
|
prompt: str,
|
||||||
|
image_path: str = "",
|
||||||
|
*,
|
||||||
|
image_b64: str = "",
|
||||||
|
media_type: str = "",
|
||||||
|
model: str = DEFAULT_MODEL,
|
||||||
|
system: str = "",
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
echo: bool = False,
|
||||||
|
token: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Pregunta al modelo sobre una imagen y devuelve su respuesta de texto.
|
||||||
|
|
||||||
|
Construye un mensaje multimodal con un bloque de imagen (base64) seguido del bloque de
|
||||||
|
texto del prompt, y lo envia a la API Messages de Anthropic via
|
||||||
|
`stream_anthropic_messages` (token OAuth de Claude Max, sin proceso `claude`).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: pregunta/instruccion de texto sobre la imagen.
|
||||||
|
image_path: ruta a la imagen en disco. Se lee y codifica a base64; el media_type se
|
||||||
|
deduce de la extension si no se pasa. Ignorado si se pasa image_b64.
|
||||||
|
image_b64: alternativa a image_path: la imagen ya en base64 (sin prefijo `data:`).
|
||||||
|
Requiere `media_type` explicito. keyword-only.
|
||||||
|
media_type: tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif).
|
||||||
|
Obligatorio con image_b64; deducido del path si se omite. keyword-only.
|
||||||
|
model: id del modelo Anthropic con vision. Default "claude-opus-4-8". keyword-only.
|
||||||
|
system: system prompt opcional. keyword-only.
|
||||||
|
max_tokens: maximo de tokens de salida. keyword-only.
|
||||||
|
echo: si True, vuelca el texto a stdout segun llega (streaming). keyword-only.
|
||||||
|
token: token OAuth; si vacio, lo carga `stream_anthropic_messages` automaticamente.
|
||||||
|
keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict ``{ok, text, model, error}``. En exito ``ok=True`` y ``text`` lleva la respuesta
|
||||||
|
completa del modelo. En error ``ok=False``, ``text=""`` y ``error`` describe la causa
|
||||||
|
(imagen inexistente, falta media_type, error HTTP/API). Nunca lanza excepcion.
|
||||||
|
"""
|
||||||
|
if not image_path and not image_b64:
|
||||||
|
return {"ok": False, "text": "", "model": model,
|
||||||
|
"error": "falta la imagen: pasa image_path o image_b64"}
|
||||||
|
|
||||||
|
if image_b64:
|
||||||
|
b64 = image_b64
|
||||||
|
mt = media_type
|
||||||
|
if not mt:
|
||||||
|
return {"ok": False, "text": "", "model": model,
|
||||||
|
"error": "image_b64 requiere media_type explicito (ej. image/png)"}
|
||||||
|
else:
|
||||||
|
path = os.path.expanduser(image_path)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return {"ok": False, "text": "", "model": model,
|
||||||
|
"error": f"imagen no encontrada: {path}"}
|
||||||
|
mt = media_type or _media_type_for(path)
|
||||||
|
if not mt:
|
||||||
|
return {"ok": False, "text": "", "model": model,
|
||||||
|
"error": f"no se pudo deducir media_type de {path!r}; pasa media_type explicito"}
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
b64 = base64.standard_b64encode(fh.read()).decode("ascii")
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "text": "", "model": model,
|
||||||
|
"error": f"no se pudo leer la imagen: {exc}"}
|
||||||
|
|
||||||
|
content = [
|
||||||
|
{"type": "image", "source": {"type": "base64", "media_type": mt, "data": b64}},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
]
|
||||||
|
messages = [{"role": "user", "content": content}]
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for ev in stream_anthropic_messages(
|
||||||
|
messages=messages,
|
||||||
|
model=model,
|
||||||
|
system=system,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
token=token,
|
||||||
|
):
|
||||||
|
t = ev.get("type")
|
||||||
|
if t == "text":
|
||||||
|
parts.append(ev["text"])
|
||||||
|
if echo:
|
||||||
|
sys.stdout.write(ev["text"])
|
||||||
|
sys.stdout.flush()
|
||||||
|
elif t == "error":
|
||||||
|
return {"ok": False, "text": "", "model": model,
|
||||||
|
"error": ev.get("message", "error desconocido")}
|
||||||
|
|
||||||
|
if echo:
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
return {"ok": True, "text": "".join(parts), "model": model, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def _main(argv):
|
||||||
|
image_path = ""
|
||||||
|
model = DEFAULT_MODEL
|
||||||
|
system = ""
|
||||||
|
prompt_parts = []
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
a = argv[i]
|
||||||
|
if a in ("--image", "-i") and i + 1 < len(argv):
|
||||||
|
image_path = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif a in ("--model", "-m") and i + 1 < len(argv):
|
||||||
|
model = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif a in ("--system", "-s") and i + 1 < len(argv):
|
||||||
|
system = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
prompt_parts.append(a)
|
||||||
|
i += 1
|
||||||
|
prompt = " ".join(prompt_parts).strip()
|
||||||
|
if not image_path:
|
||||||
|
sys.stderr.write('uso: ask_llm_vision "prompt" --image RUTA [--model M] [--system S]\n')
|
||||||
|
return 2
|
||||||
|
if not prompt:
|
||||||
|
prompt = "Describe esta imagen."
|
||||||
|
res = ask_llm_vision(prompt, image_path, model=model, system=system, echo=True)
|
||||||
|
if not res["ok"]:
|
||||||
|
sys.stderr.write("ask_llm_vision error: " + str(res["error"]) + "\n")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(_main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: godot_clean_asset_name
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str"
|
||||||
|
description: "Normaliza el nombre de archivo de un asset salido de ComfyUI (patron <prefijo>_NNNNN_.<ext>) a un nombre limpio y seguro para res://: snake_case, minusculas, sin el sufijo numerico _NNNNN_, sin espacios ni caracteres raros, conservando la extension. Pura: solo manipula el string, no toca disco. Pensada para el puente ComfyUI -> Godot."
|
||||||
|
tags: [godot, gamedev-2d, core, assets, naming]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: filename
|
||||||
|
desc: "nombre o ruta del asset de origen (se toma solo el basename)."
|
||||||
|
- name: override
|
||||||
|
desc: "nombre base deseado sin extension; si se pasa se usa en lugar del nombre de origen (igual se normaliza a snake_case y se le anade la extension del origen). keyword-only."
|
||||||
|
output: "nombre de archivo limpio 'snake_case.ext' (ext en minuscula; sin punto si el origen no tenia extension)."
|
||||||
|
tested: true
|
||||||
|
tests: [test_strips_comfyui_suffix, test_normalizes_spaces_and_case, test_takes_basename_from_path, test_override_name, test_empty_fallback]
|
||||||
|
test_file_path: "python/functions/core/godot_clean_asset_name_test.py"
|
||||||
|
file_path: "python/functions/core/godot_clean_asset_name.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from core.godot_clean_asset_name import godot_clean_asset_name
|
||||||
|
|
||||||
|
godot_clean_asset_name("svd_motion_hi_00001_.webp") # 'svd_motion_hi.webp'
|
||||||
|
godot_clean_asset_name("/x/ComfyUI/output/bench_00042_.png") # 'bench.png'
|
||||||
|
godot_clean_asset_name("x.png", override="explosion loop") # 'explosion_loop.png'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Al exportar un asset de ComfyUI a Godot, para renombrar el `<prefijo>_NNNNN_.<ext>`
|
||||||
|
a un nombre semántico y seguro (lo usa `comfyui_export_asset_to_godot`). También
|
||||||
|
para limpiar cualquier nombre de archivo antes de meterlo en `res://`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Solo quita el sufijo numérico `_NNNNN_` del **nombre de origen**; con `override`
|
||||||
|
se respetan los dígitos finales (p.ej. `hero_v2` no pierde el `2`).
|
||||||
|
- Reduce cualquier carácter no `[a-z0-9_]` a `_` y colapsa repetidos; un nombre que
|
||||||
|
quede vacío cae a `"asset"`.
|
||||||
|
- Conserva la extensión en minúscula; si el origen no tiene extensión, devuelve solo
|
||||||
|
el nombre sin punto.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""godot_clean_asset_name — normaliza el nombre de archivo de un asset para Godot.
|
||||||
|
|
||||||
|
Funcion pura: toma el nombre de un asset salido de ComfyUI (patron
|
||||||
|
`<prefijo>_NNNNN_.<ext>`, p.ej. `svd_motion_hi_00001_.webp`) y devuelve un nombre
|
||||||
|
limpio, semantico y seguro para `res://`: snake_case, minusculas, sin el sufijo
|
||||||
|
numerico `_NNNNN_`, sin espacios ni caracteres raros, conservando la extension.
|
||||||
|
No toca disco. Pensada para el puente ComfyUI -> Godot (renombrar al exportar).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
_NNNNN_SUFFIX = re.compile(r"_\d{3,}_?$") # sufijo numerico de ComfyUI: _00001_ / _00001
|
||||||
|
_NON_SAFE = re.compile(r"[^a-z0-9_]+") # cualquier cosa que no sea snake_case
|
||||||
|
_MULTI_US = re.compile(r"_{2,}")
|
||||||
|
|
||||||
|
|
||||||
|
def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str:
|
||||||
|
"""Limpia el nombre de un asset a snake_case seguro conservando la extension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: nombre o ruta del asset de origen (se toma solo el basename).
|
||||||
|
override: nombre base deseado (sin extension); si se pasa, se usa en lugar
|
||||||
|
del nombre de origen (igual se normaliza a snake_case y se le anade la
|
||||||
|
extension del origen). keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nombre de archivo limpio "snake_case.ext" (ext en minuscula, sin punto si
|
||||||
|
el origen no tenia extension).
|
||||||
|
"""
|
||||||
|
base = os.path.basename(filename or "")
|
||||||
|
stem, ext = os.path.splitext(base)
|
||||||
|
ext = ext.lower()
|
||||||
|
|
||||||
|
raw = override if override is not None else stem
|
||||||
|
name = raw.strip().lower().replace(" ", "_").replace("-", "_")
|
||||||
|
if override is None:
|
||||||
|
name = _NNNNN_SUFFIX.sub("", name) # quita _00001_ solo del nombre de origen
|
||||||
|
name = _NON_SAFE.sub("_", name)
|
||||||
|
name = _MULTI_US.sub("_", name).strip("_")
|
||||||
|
if not name:
|
||||||
|
name = "asset"
|
||||||
|
return f"{name}{ext}" if ext else name
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print(godot_clean_asset_name(sys.argv[1] if len(sys.argv) > 1 else "svd_motion_hi_00001_.webp"))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Tests de godot_clean_asset_name (pura, offline)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from core.godot_clean_asset_name import godot_clean_asset_name # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_comfyui_suffix():
|
||||||
|
assert godot_clean_asset_name("svd_motion_hi_00001_.webp") == "svd_motion_hi.webp"
|
||||||
|
assert godot_clean_asset_name("3d_robot_mesh_00001_.glb") == "3d_robot_mesh.glb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalizes_spaces_and_case():
|
||||||
|
assert godot_clean_asset_name("My Hero Sprite.PNG") == "my_hero_sprite.png"
|
||||||
|
assert godot_clean_asset_name("fire-flare.png") == "fire_flare.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_takes_basename_from_path():
|
||||||
|
assert godot_clean_asset_name("/home/x/ComfyUI/output/bench_00042_.png") == "bench.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_name():
|
||||||
|
assert godot_clean_asset_name("svd_00001_.webp", override="explosion loop") == "explosion_loop.webp"
|
||||||
|
# override conserva digitos finales (no es sufijo ComfyUI a quitar)
|
||||||
|
assert godot_clean_asset_name("x.png", override="hero_v2") == "hero_v2.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_fallback():
|
||||||
|
assert godot_clean_asset_name("_00001_.png") == "asset.png"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: godot_map_asset_dir
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def godot_map_asset_dir(kind: str) -> str"
|
||||||
|
description: "Mapea el tipo de asset (sprite, pixelart, tileset, vfx, sfx, music, model) a su subcarpeta canonica bajo res://assets/ de un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot. Pura: solo resuelve una ruta relativa POSIX, no toca disco. kind desconocido -> ValueError."
|
||||||
|
tags: [godot, gamedev-2d, core, assets, mapping]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: kind
|
||||||
|
desc: "tipo de asset: 'sprite', 'pixelart', 'tileset', 'vfx', 'sfx', 'music' o 'model' (case-insensitive, se ignoran espacios)."
|
||||||
|
output: "subruta relativa POSIX bajo assets/ (p.ej. 'sprites', 'tilesets', 'audio/music', 'models')."
|
||||||
|
tested: true
|
||||||
|
tests: [test_all_kinds, test_case_insensitive, test_unknown_kind_raises]
|
||||||
|
test_file_path: "python/functions/core/godot_map_asset_dir_test.py"
|
||||||
|
file_path: "python/functions/core/godot_map_asset_dir.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from core.godot_map_asset_dir import godot_map_asset_dir
|
||||||
|
|
||||||
|
godot_map_asset_dir("pixelart") # 'sprites'
|
||||||
|
godot_map_asset_dir("music") # 'audio/music'
|
||||||
|
godot_map_asset_dir("model") # 'models'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Al construir la ruta destino de un asset dentro de un proyecto Godot (lo usa el
|
||||||
|
pipeline `comfyui_export_asset_to_godot`). Centraliza la convención tipo -> carpeta
|
||||||
|
para no esparcir el mapeo por varias funciones.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `pixelart` cae en `sprites/` (el pixelart no es una carpeta, es un atributo: lo
|
||||||
|
que cambia es el filtro Nearest, no la ubicación).
|
||||||
|
- `kind` desconocido lanza `ValueError` (no devuelve un default silencioso).
|
||||||
|
- Devuelve siempre subrutas POSIX (`/`), no rutas absolutas: el llamador antepone
|
||||||
|
`<proyecto>/assets/`.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""godot_map_asset_dir — mapea el tipo de asset a su subcarpeta res://assets/.
|
||||||
|
|
||||||
|
Funcion pura: dado el `kind` de un asset (sprite, pixelart, tileset, vfx, sfx,
|
||||||
|
music, model) devuelve el subdirectorio canonico bajo `res://assets/` donde debe
|
||||||
|
vivir en un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot
|
||||||
|
(docs/comfyui-godot-integration.md). No toca disco: solo resuelve una ruta
|
||||||
|
relativa. `kind` desconocido -> ValueError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# kind -> subruta relativa bajo assets/ (convencion del puente ComfyUI->Godot).
|
||||||
|
_KIND_TO_DIR = {
|
||||||
|
"sprite": "sprites",
|
||||||
|
"pixelart": "sprites",
|
||||||
|
"tileset": "tilesets",
|
||||||
|
"vfx": "vfx",
|
||||||
|
"sfx": "audio/sfx",
|
||||||
|
"music": "audio/music",
|
||||||
|
"model": "models",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def godot_map_asset_dir(kind: str) -> str:
|
||||||
|
"""Resuelve la subcarpeta de assets para un tipo de asset Godot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kind: tipo de asset: "sprite", "pixelart", "tileset", "vfx", "sfx",
|
||||||
|
"music" o "model".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Subruta relativa (POSIX) bajo `assets/`, p.ej. "sprites" o "audio/music".
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si `kind` no es uno de los tipos soportados.
|
||||||
|
"""
|
||||||
|
key = (kind or "").strip().lower()
|
||||||
|
if key not in _KIND_TO_DIR:
|
||||||
|
raise ValueError(
|
||||||
|
f"kind desconocido: {kind!r}. Soportados: {sorted(_KIND_TO_DIR)}"
|
||||||
|
)
|
||||||
|
return _KIND_TO_DIR[key]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print(godot_map_asset_dir(sys.argv[1] if len(sys.argv) > 1 else "pixelart"))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Tests de godot_map_asset_dir (pura, offline)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from core.godot_map_asset_dir import godot_map_asset_dir # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_kinds():
|
||||||
|
assert godot_map_asset_dir("sprite") == "sprites"
|
||||||
|
assert godot_map_asset_dir("pixelart") == "sprites"
|
||||||
|
assert godot_map_asset_dir("tileset") == "tilesets"
|
||||||
|
assert godot_map_asset_dir("vfx") == "vfx"
|
||||||
|
assert godot_map_asset_dir("sfx") == "audio/sfx"
|
||||||
|
assert godot_map_asset_dir("music") == "audio/music"
|
||||||
|
assert godot_map_asset_dir("model") == "models"
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_insensitive():
|
||||||
|
assert godot_map_asset_dir("PixelArt") == "sprites"
|
||||||
|
assert godot_map_asset_dir(" MODEL ") == "models"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_kind_raises():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
godot_map_asset_dir("hologram")
|
||||||
@@ -44,8 +44,27 @@ from .trend_slope import trend_slope
|
|||||||
from .run_eda_models import run_eda_models
|
from .run_eda_models import run_eda_models
|
||||||
from .eda_llm_insights import eda_llm_insights
|
from .eda_llm_insights import eda_llm_insights
|
||||||
from .build_eda_notebook import build_eda_notebook
|
from .build_eda_notebook import build_eda_notebook
|
||||||
|
from .decode_qr_image import decode_qr_image
|
||||||
|
from .adf_kpss_stationarity import adf_kpss_stationarity
|
||||||
|
from .acf_pacf import acf_pacf
|
||||||
|
from .stl_decompose import stl_decompose
|
||||||
|
from .to_returns import to_returns
|
||||||
|
from .fdr_correction import fdr_correction
|
||||||
|
from .suggest_reexpression import suggest_reexpression
|
||||||
|
from .exploratory_caveats import exploratory_caveats
|
||||||
|
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"decode_qr_image",
|
||||||
|
"adf_kpss_stationarity",
|
||||||
|
"acf_pacf",
|
||||||
|
"stl_decompose",
|
||||||
|
"to_returns",
|
||||||
|
"fdr_correction",
|
||||||
|
"suggest_reexpression",
|
||||||
|
"exploratory_caveats",
|
||||||
|
"render_eda_pdf",
|
||||||
|
"render_eda_pdf_relational",
|
||||||
"summarize_table_duckdb",
|
"summarize_table_duckdb",
|
||||||
"summarize_table_pg",
|
"summarize_table_pg",
|
||||||
"spearman_corr",
|
"spearman_corr",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: acf_pacf
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict"
|
||||||
|
description: "Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie temporal con sus bandas de confianza (statsmodels), mas el test Ljung-Box de autocorrelacion global. Devuelve listas acf/pacf, sus intervalos, los lags significativos y un flag is_autocorrelated. Clave: una serie autocorrelacionada viola IID, asi que los p-valores de una regresion OLS estandar sobre ella estan inflados (Lopez de Prado). Descarta None/NaN; <8 puntos validos -> nota."
|
||||||
|
tags: [statistics, timeseries, autocorrelation, acf, pacf, ljung-box, arima, eda, forecasting, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math, numpy, statsmodels]
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del calculo."
|
||||||
|
- name: nlags
|
||||||
|
desc: "numero maximo de retardos a calcular (default 40). Se recorta a los limites de statsmodels: n-1 para ACF, (n//2)-1 para PACF."
|
||||||
|
- name: alpha
|
||||||
|
desc: "nivel de significancia para las bandas de confianza y el test de Ljung-Box (default 0.05)."
|
||||||
|
output: "dict con 'acf' y 'pacf' (listas, indice 0 = lag 0), 'acf_confint'/'pacf_confint' (banda por lag), 'significant_acf_lags'/'significant_pacf_lags' (lags >=1 fuera de banda), 'ljung_box' (stat, p_value, lags) e 'is_autocorrelated' (bool: Ljung-Box rechaza independencia). Con <8 puntos: {'n', 'note', 'is_autocorrelated': None}. Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_ruido_blanco_no_autocorrelado", "test_ar1_es_autocorrelado", "test_lag1_significativo_en_ar1", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_recorta_nlags_a_limites", "test_acf_lag0_es_uno"]
|
||||||
|
test_file_path: "python/functions/datascience/acf_pacf_test.py"
|
||||||
|
file_path: "python/functions/datascience/acf_pacf.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import acf_pacf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Ruido blanco: sin autocorrelacion (Ljung-Box no rechaza independencia)
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
ruido = rng.normal(0, 1, 500).tolist()
|
||||||
|
acf_pacf(ruido)["is_autocorrelated"] # -> False
|
||||||
|
|
||||||
|
# Proceso AR(1) fuerte: autocorrelado, lag 1 significativo en PACF
|
||||||
|
ar = [0.0]
|
||||||
|
for _ in range(500):
|
||||||
|
ar.append(0.8 * ar[-1] + rng.normal(0, 1))
|
||||||
|
res = acf_pacf(ar)
|
||||||
|
res["is_autocorrelated"] # -> True
|
||||||
|
res["significant_pacf_lags"][:1] # -> [1]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Para diagnosticar la estructura de dependencia temporal de una serie: identificar
|
||||||
|
el orden de un modelo ARIMA (PACF corta en el orden AR, ACF corta en el orden MA),
|
||||||
|
o detectar estacionalidad (picos en lags estacionales). Y, critico para EDA: antes
|
||||||
|
de meter una variable temporal en una regresion, comprueba `is_autocorrelated`. Si
|
||||||
|
es `True`, la serie no es IID y los p-valores de OLS estandar estan inflados — hay
|
||||||
|
que usar errores estandar robustos (Newey-West) o modelar la dinamica
|
||||||
|
explicitamente (Lopez de Prado).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es pura pero importa `statsmodels` y `numpy` (ambos en `python/.venv`).
|
||||||
|
- `acf[0]` y `pacf[0]` valen siempre 1.0 (autocorrelacion de la serie consigo
|
||||||
|
misma en lag 0). Los lags interesantes empiezan en el indice 1.
|
||||||
|
- `nlags` se recorta automaticamente: PACF exige `nlags < n/2`. Si pides 40 lags
|
||||||
|
sobre una serie de 30 puntos, `nlags` efectivo baja — mira el campo `nlags`
|
||||||
|
del resultado para saber cuantos se calcularon.
|
||||||
|
- Las bandas de confianza asumen ruido blanco bajo H0; en una serie con
|
||||||
|
tendencia muchos lags saldran "significativos" por la propia tendencia, no por
|
||||||
|
estructura ARMA. Estaciona primero (ver adf_kpss_stationarity / to_returns).
|
||||||
|
- Ljung-Box es un test global (todos los lags juntos); los lags individuales
|
||||||
|
significativos te dicen DONDE esta la autocorrelacion.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie (grupo eda).
|
||||||
|
|
||||||
|
Funcion pura y determinista que calcula la funcion de autocorrelacion y la
|
||||||
|
parcial con sus bandas de confianza, mas el test de Ljung-Box de autocorrelacion
|
||||||
|
global. Motivada por Hyndman ("Forecasting") para identificar el orden de un
|
||||||
|
modelo ARIMA, y por Lopez de Prado ("Advances in Financial ML"): una serie
|
||||||
|
autocorrelacionada viola el supuesto IID, de modo que los p-valores de una
|
||||||
|
regresion OLS estandar sobre ella estan inflados (falsos positivos).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from statsmodels.stats.diagnostic import acorr_ljungbox
|
||||||
|
from statsmodels.tsa.stattools import acf, pacf
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(values: list) -> list[float]:
|
||||||
|
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
|
||||||
|
|
||||||
|
Los booleanos se excluyen explicitamente (en Python ``bool`` es subclase de
|
||||||
|
``int``, pero no es un valor de serie temporal valido).
|
||||||
|
"""
|
||||||
|
out: list[float] = []
|
||||||
|
for v in values:
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
continue
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict:
|
||||||
|
"""Calcula ACF, PACF y el test Ljung-Box de una serie temporal.
|
||||||
|
|
||||||
|
Computa la funcion de autocorrelacion (ACF) y la autocorrelacion parcial
|
||||||
|
(PACF) hasta ``nlags`` retardos, con sus bandas de confianza al nivel
|
||||||
|
``1 - alpha``, e identifica que retardos son significativos (cuyo intervalo
|
||||||
|
de confianza no contiene 0). Ademas corre el test de **Ljung-Box** sobre el
|
||||||
|
conjunto de retardos: H0 = "los datos son independientes" (sin
|
||||||
|
autocorrelacion); si ``p < alpha`` se rechaza -> la serie esta
|
||||||
|
autocorrelacionada.
|
||||||
|
|
||||||
|
Funcion pura y determinista: no hace I/O, no muta los inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: serie temporal de valores numericos en orden cronologico.
|
||||||
|
None/NaN/infinitos/no-numericos se descartan antes del calculo.
|
||||||
|
nlags: numero maximo de retardos a calcular (default 40). Se recorta
|
||||||
|
automaticamente a ``n // 2`` para PACF (statsmodels exige
|
||||||
|
``nlags < n/2``) y a ``n - 1`` para ACF.
|
||||||
|
alpha: nivel de significancia para las bandas de confianza y para el
|
||||||
|
test de Ljung-Box (default 0.05).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Con menos de 8 puntos validos devuelve
|
||||||
|
``{"n": n, "note": "datos insuficientes", "is_autocorrelated": None}``.
|
||||||
|
|
||||||
|
En otro caso un dict con::
|
||||||
|
|
||||||
|
{
|
||||||
|
"n": int,
|
||||||
|
"nlags": int, # retardos efectivamente calculados
|
||||||
|
"acf": [float, ...], # incluye lag 0 (=1.0) en el indice 0
|
||||||
|
"pacf": [float, ...],
|
||||||
|
"acf_confint": [[low, high], ...], # banda por lag
|
||||||
|
"pacf_confint": [[low, high], ...],
|
||||||
|
"significant_acf_lags": [int, ...], # lags (>=1) significativos
|
||||||
|
"significant_pacf_lags": [int, ...],
|
||||||
|
"ljung_box": {"stat": float, "p_value": float, "lags": int},
|
||||||
|
"is_autocorrelated": bool, # Ljung-Box rechaza independencia
|
||||||
|
}
|
||||||
|
|
||||||
|
``is_autocorrelated = True`` significa que la serie NO es ruido blanco:
|
||||||
|
cuidado al aplicarle inferencia OLS clasica (p-valores inflados).
|
||||||
|
"""
|
||||||
|
clean = _clean(values)
|
||||||
|
n = len(clean)
|
||||||
|
|
||||||
|
if n < 8:
|
||||||
|
return {"n": n, "note": "datos insuficientes", "is_autocorrelated": None}
|
||||||
|
|
||||||
|
arr = np.asarray(clean, dtype=float)
|
||||||
|
|
||||||
|
# Recorta nlags a los limites de statsmodels: ACF admite hasta n-1, PACF < n/2.
|
||||||
|
eff_lags = min(nlags, n - 1, (n // 2) - 1)
|
||||||
|
eff_lags = max(eff_lags, 1)
|
||||||
|
|
||||||
|
acf_vals, acf_confint = acf(arr, nlags=eff_lags, alpha=alpha, fft=False)
|
||||||
|
pacf_vals, pacf_confint = pacf(arr, nlags=eff_lags, alpha=alpha)
|
||||||
|
|
||||||
|
# Un lag es significativo si su banda de confianza (centrada en el valor) no
|
||||||
|
# contiene 0. statsmodels devuelve confint como intervalos centrados en el
|
||||||
|
# estimador, asi que comparamos el intervalo desplazado al origen.
|
||||||
|
def _significant(vals, confint) -> list[int]:
|
||||||
|
out: list[int] = []
|
||||||
|
for lag in range(1, len(vals)):
|
||||||
|
low = confint[lag][0] - vals[lag]
|
||||||
|
high = confint[lag][1] - vals[lag]
|
||||||
|
if vals[lag] < low or vals[lag] > high:
|
||||||
|
out.append(lag)
|
||||||
|
return out
|
||||||
|
|
||||||
|
significant_acf = _significant(acf_vals, acf_confint)
|
||||||
|
significant_pacf = _significant(pacf_vals, pacf_confint)
|
||||||
|
|
||||||
|
# Ljung-Box sobre el maximo retardo calculado.
|
||||||
|
lb = acorr_ljungbox(arr, lags=[eff_lags], return_df=True)
|
||||||
|
lb_stat = float(lb["lb_stat"].iloc[0])
|
||||||
|
lb_p = float(lb["lb_pvalue"].iloc[0])
|
||||||
|
is_autocorrelated = bool(lb_p < alpha)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"nlags": int(eff_lags),
|
||||||
|
"acf": [float(v) for v in acf_vals],
|
||||||
|
"pacf": [float(v) for v in pacf_vals],
|
||||||
|
"acf_confint": [[float(lo), float(hi)] for lo, hi in acf_confint],
|
||||||
|
"pacf_confint": [[float(lo), float(hi)] for lo, hi in pacf_confint],
|
||||||
|
"significant_acf_lags": significant_acf,
|
||||||
|
"significant_pacf_lags": significant_pacf,
|
||||||
|
"ljung_box": {
|
||||||
|
"stat": lb_stat,
|
||||||
|
"p_value": lb_p,
|
||||||
|
"lags": int(eff_lags),
|
||||||
|
},
|
||||||
|
"is_autocorrelated": is_autocorrelated,
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Tests para acf_pacf."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from acf_pacf import acf_pacf
|
||||||
|
|
||||||
|
|
||||||
|
def _ar1(phi: float, n: int, seed: int) -> list:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
series = [0.0]
|
||||||
|
for _ in range(n):
|
||||||
|
series.append(phi * series[-1] + rng.normal(0, 1))
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
def test_ruido_blanco_no_autocorrelado():
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
ruido = rng.normal(0, 1, 500).tolist()
|
||||||
|
res = acf_pacf(ruido)
|
||||||
|
assert res["is_autocorrelated"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ar1_es_autocorrelado():
|
||||||
|
ar = _ar1(0.8, 500, seed=1)
|
||||||
|
res = acf_pacf(ar)
|
||||||
|
assert res["is_autocorrelated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_lag1_significativo_en_ar1():
|
||||||
|
# En un AR(1) la PACF corta tras el lag 1: lag 1 debe ser significativo.
|
||||||
|
ar = _ar1(0.8, 500, seed=2)
|
||||||
|
res = acf_pacf(ar)
|
||||||
|
assert 1 in res["significant_pacf_lags"]
|
||||||
|
assert 1 in res["significant_acf_lags"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_insuficiente_devuelve_nota():
|
||||||
|
res = acf_pacf([1, 2, 3, 4, 5])
|
||||||
|
assert res["n"] == 5
|
||||||
|
assert res["note"] == "datos insuficientes"
|
||||||
|
assert res["is_autocorrelated"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_descarta_none_y_nan():
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
base = rng.normal(0, 1, 200).tolist()
|
||||||
|
sucio = []
|
||||||
|
for i, v in enumerate(base):
|
||||||
|
sucio.append(v)
|
||||||
|
if i % 25 == 0:
|
||||||
|
sucio.append(None)
|
||||||
|
sucio.append(float("nan"))
|
||||||
|
res = acf_pacf(sucio)
|
||||||
|
assert res["n"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorta_nlags_a_limites():
|
||||||
|
# Serie de 20 puntos con nlags=40: debe recortar a < n/2.
|
||||||
|
rng = np.random.default_rng(4)
|
||||||
|
serie = rng.normal(0, 1, 20).tolist()
|
||||||
|
res = acf_pacf(serie, nlags=40)
|
||||||
|
assert res["nlags"] < 20 // 2
|
||||||
|
assert len(res["acf"]) == res["nlags"] + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_acf_lag0_es_uno():
|
||||||
|
rng = np.random.default_rng(5)
|
||||||
|
serie = rng.normal(0, 1, 100).tolist()
|
||||||
|
res = acf_pacf(serie)
|
||||||
|
assert abs(res["acf"][0] - 1.0) < 1e-9
|
||||||
|
assert abs(res["pacf"][0] - 1.0) < 1e-9
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: adf_kpss_stationarity
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict"
|
||||||
|
description: "Test de estacionariedad de una serie temporal combinando ADF (H0=raiz unitaria/no estacionaria) y KPSS (H0=estacionaria) de statsmodels. Devuelve por test estadistico, p_value, lags y conclusion, mas un veredicto de consenso ('stationary'|'non_stationary'|'inconclusive'). Avisa de correlacion espuria (Granger-Newbold) cuando la serie no es estacionaria. Descarta None/NaN/infinitos; <8 puntos validos -> nota 'datos insuficientes'."
|
||||||
|
tags: [statistics, timeseries, stationarity, adf, kpss, unit-root, eda, forecasting, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math, warnings, statsmodels]
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del test."
|
||||||
|
- name: alpha
|
||||||
|
desc: "nivel de significancia para ambos contrastes (default 0.05). p<alpha rechaza la hipotesis nula del test correspondiente."
|
||||||
|
output: "dict con 'adf' y 'kpss' (cada uno: stat, p_value, lags, stationary bool, conclusion), un 'verdict' de consenso ('stationary'|'non_stationary'|'inconclusive'), y 'warning' (texto sobre correlacion espuria si el veredicto no es stationary, si no None). Con <8 puntos validos: {'n', 'note': 'datos insuficientes', 'verdict': None}. Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_random_walk_es_no_estacionario", "test_ruido_blanco_es_estacionario", "test_serie_con_tendencia_no_es_estacionaria", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_warning_presente_si_no_estacionaria", "test_estructura_basica_del_dict"]
|
||||||
|
test_file_path: "python/functions/datascience/adf_kpss_stationarity_test.py"
|
||||||
|
file_path: "python/functions/datascience/adf_kpss_stationarity.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import adf_kpss_stationarity
|
||||||
|
|
||||||
|
# Ruido blanco: estacionario (ADF rechaza raiz unitaria, KPSS no rechaza estacionariedad)
|
||||||
|
import numpy as np
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
ruido = rng.normal(0, 1, 300).tolist()
|
||||||
|
adf_kpss_stationarity(ruido)["verdict"] # -> "stationary"
|
||||||
|
|
||||||
|
# Random walk (suma acumulada): NO estacionario
|
||||||
|
paseo = np.cumsum(rng.normal(0, 1, 300)).tolist()
|
||||||
|
res = adf_kpss_stationarity(paseo)
|
||||||
|
res["verdict"] # -> "non_stationary"
|
||||||
|
res["warning"] # -> aviso de correlacion espuria
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de correlacionar, regresionar o modelar (ARIMA, VAR) una serie temporal,
|
||||||
|
para saber si es estacionaria. Es el primer paso obligatorio del analisis de
|
||||||
|
series: una serie no estacionaria (con tendencia o raiz unitaria) rompe los
|
||||||
|
supuestos de la regresion OLS clasica y, si la correlacionas con otra serie no
|
||||||
|
estacionaria, obtienes una correlacion alta pero **espuria** (Granger-Newbold).
|
||||||
|
Si el veredicto no es `"stationary"`, diferencia la serie o pasala a retornos
|
||||||
|
(`to_returns`) y vuelve a testear.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es pura pero importa `statsmodels.tsa.stattools` (instalado en `python/.venv`).
|
||||||
|
- ADF y KPSS tienen hipotesis nulas OPUESTAS: en ADF `p<alpha` significa
|
||||||
|
estacionaria; en KPSS `p<alpha` significa NO estacionaria. La funcion ya
|
||||||
|
normaliza ambos a un campo `stationary` coherente — no inviertas tu la logica.
|
||||||
|
- KPSS interpola el p-valor sobre una tabla acotada `[0.01, 0.10]`: si el
|
||||||
|
estadistico cae fuera, statsmodels recorta el p-valor al extremo y lo marca en
|
||||||
|
`kpss.p_value_clipped = True`. Un p recortado a 0.01 o 0.10 es un limite, no un
|
||||||
|
valor exacto.
|
||||||
|
- El veredicto `"inconclusive"` suele indicar serie estacionaria-en-tendencia o
|
||||||
|
que necesita diferenciacion; no es un fallo, es informacion.
|
||||||
|
- Necesita al menos 8 puntos validos tras limpiar; con menos devuelve una nota.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests de estacionariedad de una serie temporal: ADF + KPSS (grupo eda).
|
||||||
|
|
||||||
|
Funcion pura y determinista que combina dos contrastes de estacionariedad con
|
||||||
|
hipotesis nulas opuestas y emite un veredicto de consenso. Motivada por la
|
||||||
|
necesidad (Hyndman "Forecasting", Hamilton "Time Series Analysis") de saber si
|
||||||
|
una serie es estacionaria ANTES de correlacionarla o modelarla: correlacionar
|
||||||
|
niveles no estacionarios produce correlacion espuria (Granger-Newbold 1974).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from statsmodels.tsa.stattools import adfuller, kpss
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(values: list) -> list[float]:
|
||||||
|
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
|
||||||
|
|
||||||
|
Los booleanos se excluyen explicitamente: en Python ``bool`` es subclase de
|
||||||
|
``int``, pero tratar True/False como numeros en una serie temporal es casi
|
||||||
|
siempre un error de tipado.
|
||||||
|
"""
|
||||||
|
out: list[float] = []
|
||||||
|
for v in values:
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
continue
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict:
|
||||||
|
"""Evalua la estacionariedad de una serie combinando ADF y KPSS.
|
||||||
|
|
||||||
|
Aplica dos contrastes con hipotesis nulas opuestas:
|
||||||
|
|
||||||
|
- **ADF** (Augmented Dickey-Fuller): H0 = "la serie tiene raiz unitaria"
|
||||||
|
(es NO estacionaria). Si ``p < alpha`` se rechaza H0 -> evidencia de
|
||||||
|
estacionariedad.
|
||||||
|
- **KPSS** (Kwiatkowski-Phillips-Schmidt-Shin): H0 = "la serie es
|
||||||
|
estacionaria (en torno a una tendencia)". Si ``p < alpha`` se rechaza H0
|
||||||
|
-> evidencia de NO estacionariedad.
|
||||||
|
|
||||||
|
Combinar ambos da mas robustez que cualquiera por separado, porque sus
|
||||||
|
hipotesis nulas son contrarias. El veredicto de consenso sigue la
|
||||||
|
interpretacion estandar (Hyndman, "Forecasting: Principles and Practice"):
|
||||||
|
|
||||||
|
- ADF rechaza H0 **y** KPSS no rechaza H0 -> ``"stationary"``.
|
||||||
|
- ADF no rechaza H0 **y** KPSS rechaza H0 -> ``"non_stationary"``.
|
||||||
|
- Ambos coinciden en lo contrario o se contradicen -> ``"inconclusive"``
|
||||||
|
(a menudo indica serie diferenciable o estacionaria en tendencia).
|
||||||
|
|
||||||
|
Funcion pura y determinista: no hace I/O, no muta los inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: serie temporal de valores numericos en orden cronologico.
|
||||||
|
None/NaN/infinitos/no-numericos se descartan antes del test.
|
||||||
|
alpha: nivel de significancia para ambos contrastes (default 0.05).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Con menos de 8 puntos validos (muestra insuficiente para un test de
|
||||||
|
raiz unitaria fiable) devuelve
|
||||||
|
``{"n": n, "note": "datos insuficientes", "verdict": None}``.
|
||||||
|
|
||||||
|
En otro caso un dict con::
|
||||||
|
|
||||||
|
{
|
||||||
|
"n": int,
|
||||||
|
"alpha": float,
|
||||||
|
"adf": {"stat": float, "p_value": float, "lags": int,
|
||||||
|
"stationary": bool, # rechaza H0 de raiz unitaria
|
||||||
|
"conclusion": str},
|
||||||
|
"kpss": {"stat": float, "p_value": float, "lags": int,
|
||||||
|
"stationary": bool, # NO rechaza H0 de estacionariedad
|
||||||
|
"conclusion": str,
|
||||||
|
"p_value_clipped": bool}, # p en limite de tabla KPSS
|
||||||
|
"verdict": "stationary" | "non_stationary" | "inconclusive",
|
||||||
|
"warning": str | None, # aviso de correlacion espuria si procede
|
||||||
|
}
|
||||||
|
|
||||||
|
``warning`` se rellena cuando el veredicto NO es ``"stationary"`` para
|
||||||
|
recordar que correlacionar/regresionar niveles no estacionarios produce
|
||||||
|
relaciones espurias; conviene pasar a retornos o diferencias.
|
||||||
|
"""
|
||||||
|
clean = _clean(values)
|
||||||
|
n = len(clean)
|
||||||
|
|
||||||
|
if n < 8:
|
||||||
|
return {"n": n, "note": "datos insuficientes", "verdict": None}
|
||||||
|
|
||||||
|
# ADF: H0 = raiz unitaria (no estacionaria). p < alpha => estacionaria.
|
||||||
|
adf_stat, adf_p, adf_lags, _adf_nobs, _adf_crit, _adf_icbest = adfuller(
|
||||||
|
clean, autolag="AIC"
|
||||||
|
)
|
||||||
|
adf_stationary = bool(adf_p < alpha)
|
||||||
|
adf = {
|
||||||
|
"stat": float(adf_stat),
|
||||||
|
"p_value": float(adf_p),
|
||||||
|
"lags": int(adf_lags),
|
||||||
|
"stationary": adf_stationary,
|
||||||
|
"conclusion": (
|
||||||
|
"rechaza H0 de raiz unitaria: evidencia de estacionariedad"
|
||||||
|
if adf_stationary
|
||||||
|
else "no rechaza H0 de raiz unitaria: posible no estacionaria"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# KPSS: H0 = estacionaria en torno a tendencia. p < alpha => NO estacionaria.
|
||||||
|
# statsmodels emite InterpolationWarning cuando el p-valor cae fuera de la
|
||||||
|
# tabla [0.01, 0.10]; lo capturamos para saber si quedo recortado.
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
kpss_stat, kpss_p, kpss_lags, _kpss_crit = kpss(
|
||||||
|
clean, regression="c", nlags="auto"
|
||||||
|
)
|
||||||
|
p_clipped = any("InterpolationWarning" in str(w.category) for w in caught) or any(
|
||||||
|
"p-value" in str(w.message).lower() for w in caught
|
||||||
|
)
|
||||||
|
kpss_stationary = bool(kpss_p >= alpha) # NO rechaza H0 => estacionaria
|
||||||
|
kpss_result = {
|
||||||
|
"stat": float(kpss_stat),
|
||||||
|
"p_value": float(kpss_p),
|
||||||
|
"lags": int(kpss_lags),
|
||||||
|
"stationary": kpss_stationary,
|
||||||
|
"conclusion": (
|
||||||
|
"no rechaza H0 de estacionariedad: evidencia de estacionariedad"
|
||||||
|
if kpss_stationary
|
||||||
|
else "rechaza H0 de estacionariedad: posible no estacionaria"
|
||||||
|
),
|
||||||
|
"p_value_clipped": bool(p_clipped),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Consenso de los dos contrastes.
|
||||||
|
if adf_stationary and kpss_stationary:
|
||||||
|
verdict = "stationary"
|
||||||
|
elif (not adf_stationary) and (not kpss_stationary):
|
||||||
|
verdict = "non_stationary"
|
||||||
|
else:
|
||||||
|
verdict = "inconclusive"
|
||||||
|
|
||||||
|
warning: str | None = None
|
||||||
|
if verdict != "stationary":
|
||||||
|
warning = (
|
||||||
|
"serie no claramente estacionaria: correlacionar o regresionar sus "
|
||||||
|
"niveles puede dar relaciones espurias (Granger-Newbold). Considera "
|
||||||
|
"trabajar sobre retornos o diferencias (ver to_returns)."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"adf": adf,
|
||||||
|
"kpss": kpss_result,
|
||||||
|
"verdict": verdict,
|
||||||
|
"warning": warning,
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests para adf_kpss_stationarity."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from adf_kpss_stationarity import adf_kpss_stationarity
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_walk_es_no_estacionario():
|
||||||
|
# Random walk = suma acumulada de ruido: tiene raiz unitaria.
|
||||||
|
rng = np.random.default_rng(123)
|
||||||
|
paseo = np.cumsum(rng.normal(0.0, 1.0, 400)).tolist()
|
||||||
|
res = adf_kpss_stationarity(paseo)
|
||||||
|
assert res["verdict"] == "non_stationary"
|
||||||
|
assert res["adf"]["stationary"] is False
|
||||||
|
assert res["kpss"]["stationary"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ruido_blanco_es_estacionario():
|
||||||
|
# Ruido blanco gaussiano: estacionario por construccion.
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
ruido = rng.normal(0.0, 1.0, 400).tolist()
|
||||||
|
res = adf_kpss_stationarity(ruido)
|
||||||
|
assert res["verdict"] == "stationary"
|
||||||
|
assert res["adf"]["stationary"] is True
|
||||||
|
assert res["kpss"]["stationary"] is True
|
||||||
|
assert res["warning"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_serie_con_tendencia_no_es_estacionaria():
|
||||||
|
# Tendencia lineal determinista + ruido pequeno: KPSS la marca no estacionaria.
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
serie = [0.1 * i + rng.normal(0, 0.5) for i in range(300)]
|
||||||
|
res = adf_kpss_stationarity(serie)
|
||||||
|
assert res["verdict"] != "stationary"
|
||||||
|
assert res["warning"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_insuficiente_devuelve_nota():
|
||||||
|
res = adf_kpss_stationarity([1, 2, 3, 4, 5])
|
||||||
|
assert res["n"] == 5
|
||||||
|
assert res["note"] == "datos insuficientes"
|
||||||
|
assert res["verdict"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_descarta_none_y_nan():
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
base = rng.normal(0, 1, 200).tolist()
|
||||||
|
sucio = []
|
||||||
|
for i, v in enumerate(base):
|
||||||
|
sucio.append(v)
|
||||||
|
if i % 20 == 0:
|
||||||
|
sucio.append(None)
|
||||||
|
sucio.append(float("nan"))
|
||||||
|
res = adf_kpss_stationarity(sucio)
|
||||||
|
assert res["n"] == 200 # las None/NaN no cuentan
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_presente_si_no_estacionaria():
|
||||||
|
# Tendencia lineal fuerte: garantiza no estacionariedad (verdict != stationary).
|
||||||
|
rng = np.random.default_rng(99)
|
||||||
|
serie = [0.5 * i + rng.normal(0, 0.3) for i in range(300)]
|
||||||
|
res = adf_kpss_stationarity(serie)
|
||||||
|
assert res["verdict"] != "stationary"
|
||||||
|
assert res["warning"] is not None
|
||||||
|
assert "espuria" in res["warning"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_basica_del_dict():
|
||||||
|
rng = np.random.default_rng(5)
|
||||||
|
ruido = rng.normal(0, 1, 100).tolist()
|
||||||
|
res = adf_kpss_stationarity(ruido)
|
||||||
|
for key in ("n", "alpha", "adf", "kpss", "verdict"):
|
||||||
|
assert key in res
|
||||||
|
for sub in ("stat", "p_value", "lags", "stationary", "conclusion"):
|
||||||
|
assert sub in res["adf"]
|
||||||
|
assert sub in res["kpss"]
|
||||||
@@ -3,19 +3,23 @@ name: association_matrix
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict"
|
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20, alpha: float = 0.05, fdr_method: str = \"bh\") -> dict"
|
||||||
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Devuelve pares evaluados, pares fuertes y leyenda de metodos."
|
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Cada par lleva su p-valor (test de correlacion / chi-cuadrado / ANOVA) y se corrige por comparaciones multiples (FDR) para combatir el sesgo de mineria de datos: el subconjunto fuerte se basa en la significancia corregida, no solo en superar el umbral de magnitud."
|
||||||
tags: [eda, correlation, association, statistics, mixed-types, mutual-information]
|
tags: [eda, correlation, association, statistics, mixed-types, mutual-information, multiple-testing, p-value, fdr]
|
||||||
params:
|
params:
|
||||||
- name: columns
|
- name: columns
|
||||||
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
|
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
|
||||||
- name: strong_threshold
|
- name: strong_threshold
|
||||||
desc: "Umbral en [0, 1]. Un par es fuerte si abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
|
desc: "Umbral de magnitud en [0, 1]. Condicion necesaria (ya no suficiente) para ser fuerte: abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
|
||||||
- name: top_n
|
- name: top_n
|
||||||
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
|
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
|
||||||
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra}; strong: subconjunto fuerte ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
|
- name: alpha
|
||||||
|
desc: "Nivel de significancia tras la correccion FDR (default 0.05). Un par con p-valor disponible solo es fuerte si ademas su p-valor ajustado <= alpha."
|
||||||
|
- name: fdr_method
|
||||||
|
desc: "Metodo de correccion de comparaciones multiples: 'bh' (Benjamini-Hochberg, FDR; default) o 'bonferroni' (FWER, mas conservador)."
|
||||||
|
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra, p_value, p_value_adjusted, significant}; strong: subconjunto con magnitud >= umbral Y significativo tras FDR (pares sin test se admiten por magnitud), ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion; n_tests: nº total de pares evaluados (== len(pairs)); multiple_testing: {method, alpha, n_tests, n_rejected}}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- pearson_py_datascience
|
- pearson_py_datascience
|
||||||
- spearman_corr_py_datascience
|
- spearman_corr_py_datascience
|
||||||
@@ -23,13 +27,14 @@ uses_functions:
|
|||||||
- theils_u_py_datascience
|
- theils_u_py_datascience
|
||||||
- correlation_ratio_py_datascience
|
- correlation_ratio_py_datascience
|
||||||
- mutual_info_columns_py_datascience
|
- mutual_info_columns_py_datascience
|
||||||
|
- fdr_correction_py_datascience
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
error_type: ""
|
error_type: ""
|
||||||
imports: []
|
imports: [scipy]
|
||||||
tested: true
|
tested: true
|
||||||
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty"]
|
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty", "test_pairs_carry_significance_fields", "test_result_reports_multiple_testing_summary", "test_strong_requires_corrected_significance", "test_bonferroni_method_is_accepted"]
|
||||||
test_file_path: "python/functions/datascience/association_matrix_test.py"
|
test_file_path: "python/functions/datascience/association_matrix_test.py"
|
||||||
file_path: "python/functions/datascience/association_matrix.py"
|
file_path: "python/functions/datascience/association_matrix.py"
|
||||||
---
|
---
|
||||||
@@ -84,3 +89,36 @@ no-lineal a todos los pares.
|
|||||||
categorica como primer argumento y la numerica como segundo.
|
categorica como primer argumento y la numerica como segundo.
|
||||||
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
||||||
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
|
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Ahora corrige multiple-testing (v1.1.0).** El subconjunto `strong` ya no
|
||||||
|
depende solo de la magnitud: un par con magnitud alta pero p-valor ajustado
|
||||||
|
> `alpha` NO entra en `strong`. Esto combate el sesgo de mineria de datos
|
||||||
|
(data-mining bias, Aronson cap. 6): al evaluar todos los pares a la vez, el
|
||||||
|
azar produce correlaciones espurias que el umbral de magnitud por si solo
|
||||||
|
dejaria pasar.
|
||||||
|
- Cada par lleva `p_value` (test del metodo principal: correlacion de
|
||||||
|
Pearson/Spearman, chi-cuadrado de independencia para Cramer's V, ANOVA de una
|
||||||
|
via para correlation ratio) y `p_value_adjusted` (tras `fdr_correction`). La
|
||||||
|
informacion mutua no tiene test asociado, por lo que un par cuyo metodo
|
||||||
|
principal sea degenerado puede tener `p_value = None`; esos pares se admiten en
|
||||||
|
`strong` por magnitud (no hay p-valor que corregir).
|
||||||
|
- `n_tests` (top-level) es el numero total de pares evaluados (`len(pairs)`),
|
||||||
|
mientras que `multiple_testing.n_tests` es el numero de p-valores **validos**
|
||||||
|
que entraron en la correccion (puede ser menor si algun par no tiene test).
|
||||||
|
- Sigue siendo pura, pero ahora importa `scipy.stats` (`pearsonr`, `spearmanr`,
|
||||||
|
`chi2_contingency`, `f_oneway`) para los p-valores; scipy ya vive en
|
||||||
|
`python/.venv`.
|
||||||
|
- Sube `alpha` o usa `fdr_method="bonferroni"` segun lo costoso que sea un falso
|
||||||
|
positivo: BH controla la tasa de falsos descubrimientos (mas potencia),
|
||||||
|
Bonferroni la probabilidad de cualquier falso positivo (mas cautela).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (28/06/2026) — anade p-valor por par (Pearson/Spearman, chi-cuadrado,
|
||||||
|
ANOVA) + correccion de comparaciones multiples via `fdr_correction` (BH /
|
||||||
|
Bonferroni). `strong` pasa a basarse en la significancia corregida, no solo en
|
||||||
|
el umbral de magnitud. Nuevos parametros `alpha` y `fdr_method`; nuevas claves
|
||||||
|
`p_value`/`p_value_adjusted`/`significant` por par y `n_tests`/
|
||||||
|
`multiple_testing` en el resultado. Retrocompatible: no quita claves previas.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr
|
||||||
|
|
||||||
from datascience import (
|
from datascience import (
|
||||||
correlation_ratio,
|
correlation_ratio,
|
||||||
@@ -19,6 +22,10 @@ from datascience import (
|
|||||||
theils_u,
|
theils_u,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Modulo hoja directo: no depende de que el paquete reexporte la funcion en su
|
||||||
|
# __init__ (lo integra el orquestador al cerrar el grupo eda).
|
||||||
|
from datascience.fdr_correction import fdr_correction
|
||||||
|
|
||||||
# Tipos que, para efectos de asociacion, se tratan como categoricos.
|
# Tipos que, para efectos de asociacion, se tratan como categoricos.
|
||||||
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
||||||
|
|
||||||
@@ -59,10 +66,83 @@ def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
|
|||||||
return cx, cy
|
return cx, cy
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_pvalue(value) -> float | None:
|
||||||
|
"""Convierte un p-valor de scipy a float, devolviendo None si es NaN/invalido."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pv = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if math.isnan(pv) or math.isinf(pv):
|
||||||
|
return None
|
||||||
|
return pv
|
||||||
|
|
||||||
|
|
||||||
|
def _pearson_pvalue(cx: list, cy: list) -> float | None:
|
||||||
|
"""p-valor del test de correlacion de Pearson (H0: r == 0). None si degenerado."""
|
||||||
|
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(pearsonr(cx, cy).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _spearman_pvalue(cx: list, cy: list) -> float | None:
|
||||||
|
"""p-valor del test de correlacion de Spearman (H0: rho == 0). None si degenerado."""
|
||||||
|
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(spearmanr(cx, cy).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _chi2_pvalue(a_vals: list, b_vals: list) -> float | None:
|
||||||
|
"""p-valor del test chi-cuadrado de independencia (cat-cat). None si degenerado."""
|
||||||
|
pairs = [(x, y) for x, y in zip(a_vals, b_vals) if x is not None and y is not None]
|
||||||
|
if len(pairs) < 2:
|
||||||
|
return None
|
||||||
|
rows = sorted({x for x, _ in pairs}, key=repr)
|
||||||
|
cols = sorted({y for _, y in pairs}, key=repr)
|
||||||
|
if len(rows) < 2 or len(cols) < 2:
|
||||||
|
return None
|
||||||
|
row_idx = {v: i for i, v in enumerate(rows)}
|
||||||
|
col_idx = {v: j for j, v in enumerate(cols)}
|
||||||
|
counts = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
|
||||||
|
table = [
|
||||||
|
[counts.get((i, j), 0) for j in range(len(cols))]
|
||||||
|
for i in range(len(rows))
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(chi2_contingency(table).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _anova_pvalue(cat_vals: list, num_vals: list) -> float | None:
|
||||||
|
"""p-valor del ANOVA de una via (H0: misma media numerica por categoria). None si degenerado."""
|
||||||
|
groups: dict = defaultdict(list)
|
||||||
|
for c, x in zip(cat_vals, num_vals):
|
||||||
|
if c is None or not _is_num(x):
|
||||||
|
continue
|
||||||
|
groups[c].append(float(x))
|
||||||
|
valid = [g for g in groups.values() if len(g) >= 2]
|
||||||
|
if len(valid) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(f_oneway(*valid).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def association_matrix(
|
def association_matrix(
|
||||||
columns: dict,
|
columns: dict,
|
||||||
strong_threshold: float = 0.5,
|
strong_threshold: float = 0.5,
|
||||||
top_n: int = 20,
|
top_n: int = 20,
|
||||||
|
alpha: float = 0.05,
|
||||||
|
fdr_method: str = "bh",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
|
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
|
||||||
|
|
||||||
@@ -81,22 +161,48 @@ def association_matrix(
|
|||||||
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
|
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
|
||||||
columna (devuelve `pairs=[]`, `strong=[]`).
|
columna (devuelve `pairs=[]`, `strong=[]`).
|
||||||
|
|
||||||
|
Ademas de la magnitud de la asociacion, cada par evaluado lleva un p-valor
|
||||||
|
del test de hipotesis adecuado a su metodo (Pearson/Spearman: test de
|
||||||
|
correlacion; Cramer's V: chi-cuadrado de independencia; correlation ratio:
|
||||||
|
ANOVA de una via; informacion mutua: sin test, p-valor None). Como se evaluan
|
||||||
|
todos los pares a la vez, esos p-valores se corrigen por comparaciones
|
||||||
|
multiples con `fdr_correction` (data-mining bias, Aronson cap. 6) y el
|
||||||
|
subconjunto `strong` se basa en la **significancia corregida**, no solo en
|
||||||
|
superar el umbral de magnitud: un par con magnitud alta pero p-valor ajustado
|
||||||
|
> alpha NO entra en `strong`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
||||||
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
||||||
Los tipos datetime/boolean/text se tratan como categoricos.
|
Los tipos datetime/boolean/text se tratan como categoricos.
|
||||||
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
|
strong_threshold: umbral en [0, 1]. Condicion de magnitud para ser
|
||||||
abs(value) >= umbral o extra["mi"] >= umbral.
|
"fuerte": abs(value) >= umbral o extra["mi"] >= umbral. Necesaria pero
|
||||||
|
ya no suficiente (ver alpha).
|
||||||
top_n: numero maximo de pares fuertes a devolver, ordenados por
|
top_n: numero maximo de pares fuertes a devolver, ordenados por
|
||||||
relevancia (max(abs(value), mi)) descendente.
|
relevancia (max(abs(value), mi)) descendente.
|
||||||
|
alpha: nivel de significancia tras la correccion FDR (default 0.05). Un
|
||||||
|
par con p-valor disponible solo es fuerte si ademas su p-valor
|
||||||
|
ajustado <= alpha.
|
||||||
|
fdr_method: metodo de correccion de comparaciones multiples,
|
||||||
|
"bh" (Benjamini-Hochberg, FDR; default) o "bonferroni" (FWER).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con claves:
|
dict con claves:
|
||||||
pairs: lista de todos los pares evaluados, cada uno
|
pairs: lista de todos los pares evaluados, cada uno
|
||||||
{a, b, a_type, b_type, method, value, extra}.
|
{a, b, a_type, b_type, method, value, extra, p_value,
|
||||||
strong: subconjunto de pairs por encima del umbral, ordenado por
|
p_value_adjusted, significant}. `p_value` es el del test del
|
||||||
relevancia descendente y truncado a top_n.
|
metodo principal (None si no aplica / degenerado);
|
||||||
|
`p_value_adjusted` el p-valor tras FDR; `significant` True si
|
||||||
|
p_value_adjusted <= alpha.
|
||||||
|
strong: subconjunto de pairs que cumplen magnitud >= umbral Y son
|
||||||
|
significativos tras la correccion (los pares sin test disponible
|
||||||
|
se admiten por magnitud), ordenado por relevancia descendente y
|
||||||
|
truncado a top_n.
|
||||||
methods_legend: dict {metodo: descripcion}.
|
methods_legend: dict {metodo: descripcion}.
|
||||||
|
n_tests: numero total de pares evaluados (== len(pairs)).
|
||||||
|
multiple_testing: dict {method, alpha, n_tests, n_rejected} con el
|
||||||
|
resumen de la correccion (n_tests aqui = p-valores validos
|
||||||
|
corregidos, puede ser < len(pairs) si algun par no tiene test).
|
||||||
"""
|
"""
|
||||||
legend = {
|
legend = {
|
||||||
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
|
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
|
||||||
@@ -117,15 +223,30 @@ def association_matrix(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _skip(name: str) -> bool:
|
def _skip(name: str) -> bool:
|
||||||
"""True si la columna no aporta asociacion util (pocos validos o text ruidoso)."""
|
"""True si la columna no aporta asociacion util (pocos validos, datetime o cat casi-unica)."""
|
||||||
col = columns[name]
|
col = columns[name]
|
||||||
vals = col.get("values", [])
|
vals = col.get("values", [])
|
||||||
ctype = col.get("type", "categorical")
|
ctype = col.get("type", "categorical")
|
||||||
numeric = _is_numeric_type(ctype)
|
numeric = _is_numeric_type(ctype)
|
||||||
if _valid_count(vals, numeric) < 3:
|
nvalid = _valid_count(vals, numeric)
|
||||||
|
if nvalid < 3:
|
||||||
return True
|
return True
|
||||||
# Texto de cardinalidad ~ n: identificadores/free-text, sin asociacion util.
|
if numeric:
|
||||||
if ctype == "text" and n_rows > 0 and _cardinality(vals) >= 0.9 * n_rows:
|
return False
|
||||||
|
# Datetime: indice temporal unico-ish por fila. Como categorica da
|
||||||
|
# correlation_ratio (eta) ~= 1 trivial frente a cualquier numerica (cada
|
||||||
|
# fecha es su propio grupo de un solo valor) y Cramer's V / MI inflados.
|
||||||
|
# La estacionalidad/tendencia se analizan en el bloque de series, no aqui.
|
||||||
|
if ctype == "datetime":
|
||||||
|
return True
|
||||||
|
# Grupos casi singleton: si el tamano medio de grupo (valores presentes /
|
||||||
|
# cardinalidad) es < 1.5, la varianza intra-grupo ~= 0 y correlation_ratio
|
||||||
|
# sale ~= 1 por artefacto determinista (no por azar: el FDR no protege).
|
||||||
|
# Cubre ids/free-text (Ticket: 681 distintos sobre 891) y categoricas
|
||||||
|
# dispersas con muchos nulos (Cabin: 147 distintos sobre 204 presentes).
|
||||||
|
# Se mide sobre valores PRESENTES, no sobre n_rows, para captar las dispersas.
|
||||||
|
card = _cardinality(vals)
|
||||||
|
if card >= 2 and (nvalid / card) < 1.5:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -168,20 +289,32 @@ def association_matrix(
|
|||||||
s = spearman_corr(a_vals, b_vals)
|
s = spearman_corr(a_vals, b_vals)
|
||||||
extra["pearson"] = p
|
extra["pearson"] = p
|
||||||
extra["spearman"] = s
|
extra["spearman"] = s
|
||||||
value = p if abs(p) >= abs(s) else s
|
pearson_p = _pearson_pvalue(cx, cy)
|
||||||
|
spearman_p = _spearman_pvalue(cx, cy)
|
||||||
|
extra["pearson_p"] = pearson_p
|
||||||
|
extra["spearman_p"] = spearman_p
|
||||||
|
if abs(p) >= abs(s):
|
||||||
|
value = p
|
||||||
|
p_value = pearson_p
|
||||||
|
else:
|
||||||
|
value = s
|
||||||
|
p_value = spearman_p
|
||||||
elif (not a_numeric) and (not b_numeric):
|
elif (not a_numeric) and (not b_numeric):
|
||||||
method = "cramers_v"
|
method = "cramers_v"
|
||||||
value = cramers_v(a_vals, b_vals)
|
value = cramers_v(a_vals, b_vals)
|
||||||
extra["u_ab"] = theils_u(a_vals, b_vals)
|
extra["u_ab"] = theils_u(a_vals, b_vals)
|
||||||
extra["u_ba"] = theils_u(b_vals, a_vals)
|
extra["u_ba"] = theils_u(b_vals, a_vals)
|
||||||
|
p_value = _chi2_pvalue(a_vals, b_vals)
|
||||||
else:
|
else:
|
||||||
method = "correlation_ratio"
|
method = "correlation_ratio"
|
||||||
if a_numeric:
|
if a_numeric:
|
||||||
# a numerica, b categorica.
|
# a numerica, b categorica.
|
||||||
value = correlation_ratio(b_vals, a_vals)
|
value = correlation_ratio(b_vals, a_vals)
|
||||||
|
p_value = _anova_pvalue(b_vals, a_vals)
|
||||||
else:
|
else:
|
||||||
# a categorica, b numerica.
|
# a categorica, b numerica.
|
||||||
value = correlation_ratio(a_vals, b_vals)
|
value = correlation_ratio(a_vals, b_vals)
|
||||||
|
p_value = _anova_pvalue(a_vals, b_vals)
|
||||||
|
|
||||||
pairs.append(
|
pairs.append(
|
||||||
{
|
{
|
||||||
@@ -192,19 +325,55 @@ def association_matrix(
|
|||||||
"method": method,
|
"method": method,
|
||||||
"value": value,
|
"value": value,
|
||||||
"extra": extra,
|
"extra": extra,
|
||||||
|
"p_value": p_value,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Correccion de comparaciones multiples sobre los p-valores disponibles.
|
||||||
|
# Se pasa la lista completa (incluidos los None de pares sin test): la
|
||||||
|
# correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba.
|
||||||
|
fdr = fdr_correction(
|
||||||
|
[pair["p_value"] for pair in pairs],
|
||||||
|
alpha=alpha,
|
||||||
|
method=fdr_method,
|
||||||
|
)
|
||||||
|
for pair, padj, rej in zip(
|
||||||
|
pairs, fdr["p_values_adjusted"], fdr["reject"]
|
||||||
|
):
|
||||||
|
pair["p_value_adjusted"] = padj
|
||||||
|
pair["significant"] = bool(rej)
|
||||||
|
|
||||||
def _relevance(pair: dict) -> float:
|
def _relevance(pair: dict) -> float:
|
||||||
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
||||||
|
|
||||||
strong = [
|
def _is_strong(pair: dict) -> bool:
|
||||||
pair
|
# Condicion 1: magnitud por encima del umbral (necesaria).
|
||||||
for pair in pairs
|
magnitude_ok = (
|
||||||
if abs(pair["value"]) >= strong_threshold
|
abs(pair["value"]) >= strong_threshold
|
||||||
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
||||||
]
|
)
|
||||||
|
if not magnitude_ok:
|
||||||
|
return False
|
||||||
|
# Condicion 2: significancia tras la correccion FDR. Los pares sin test
|
||||||
|
# disponible (p_value None, p.ej. informacion mutua o caso degenerado) se
|
||||||
|
# admiten por magnitud, ya que no hay p-valor que corregir.
|
||||||
|
if pair["p_value"] is None:
|
||||||
|
return True
|
||||||
|
return pair["significant"]
|
||||||
|
|
||||||
|
strong = [pair for pair in pairs if _is_strong(pair)]
|
||||||
strong.sort(key=_relevance, reverse=True)
|
strong.sort(key=_relevance, reverse=True)
|
||||||
strong = strong[:top_n]
|
strong = strong[:top_n]
|
||||||
|
|
||||||
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
|
return {
|
||||||
|
"pairs": pairs,
|
||||||
|
"strong": strong,
|
||||||
|
"methods_legend": legend,
|
||||||
|
"n_tests": len(pairs),
|
||||||
|
"multiple_testing": {
|
||||||
|
"method": fdr_method,
|
||||||
|
"alpha": alpha,
|
||||||
|
"n_tests": fdr["n_tests"],
|
||||||
|
"n_rejected": fdr["n_rejected"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,141 @@ def test_single_column_returns_empty():
|
|||||||
result = association_matrix(columns)
|
result = association_matrix(columns)
|
||||||
assert result["pairs"] == []
|
assert result["pairs"] == []
|
||||||
assert result["strong"] == []
|
assert result["strong"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairs_carry_significance_fields():
|
||||||
|
# Tras la correccion FDR cada par evaluado lleva p_value, p_value_adjusted y
|
||||||
|
# significant. Un par num-num fuertemente correlado es significativo.
|
||||||
|
columns = {
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {
|
||||||
|
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns, strong_threshold=0.5)
|
||||||
|
pair = _find_pair(result["pairs"], "size", "price")
|
||||||
|
assert "p_value" in pair and "p_value_adjusted" in pair and "significant" in pair
|
||||||
|
assert pair["p_value"] is not None and pair["p_value"] < 0.05
|
||||||
|
assert pair["significant"] is True
|
||||||
|
# p ajustado nunca por debajo del crudo.
|
||||||
|
assert pair["p_value_adjusted"] >= pair["p_value"] - 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_reports_multiple_testing_summary():
|
||||||
|
columns = {
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {
|
||||||
|
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns)
|
||||||
|
# n_tests = total de pares evaluados.
|
||||||
|
assert result["n_tests"] == len(result["pairs"])
|
||||||
|
mt = result["multiple_testing"]
|
||||||
|
assert mt["method"] == "bh"
|
||||||
|
assert mt["alpha"] == 0.05
|
||||||
|
assert mt["n_rejected"] >= 1
|
||||||
|
assert mt["n_tests"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_strong_requires_corrected_significance():
|
||||||
|
# Par num-num con magnitud alta pero p-valor no diminuto. Con alpha normal es
|
||||||
|
# fuerte; con un alpha mas estricto que su p-valor, deja de ser significativo
|
||||||
|
# y sale de strong AUNQUE la magnitud siga por encima del umbral. Esto prueba
|
||||||
|
# que strong se basa en la significancia corregida, no solo en el umbral.
|
||||||
|
columns = {
|
||||||
|
"a": {"values": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "type": "numeric"},
|
||||||
|
"b": {"values": [2, 1, 3, 4, 6, 5, 7, 8, 10, 9, 11, 12], "type": "numeric"},
|
||||||
|
}
|
||||||
|
relaxed = association_matrix(columns, strong_threshold=0.5, alpha=0.05)
|
||||||
|
pair = _find_pair(relaxed["pairs"], "a", "b")
|
||||||
|
assert pair["p_value"] is not None and pair["p_value"] < 0.05
|
||||||
|
assert abs(pair["value"]) >= 0.5
|
||||||
|
assert _find_pair(relaxed["strong"], "a", "b") is not None
|
||||||
|
|
||||||
|
# alpha mas estricto que el p-valor del par -> ya no significativo.
|
||||||
|
strict = association_matrix(
|
||||||
|
columns, strong_threshold=0.5, alpha=pair["p_value"] / 10.0
|
||||||
|
)
|
||||||
|
sp = _find_pair(strict["pairs"], "a", "b")
|
||||||
|
assert abs(sp["value"]) >= 0.5 # magnitud intacta
|
||||||
|
assert sp["significant"] is False
|
||||||
|
assert _find_pair(strict["strong"], "a", "b") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonferroni_method_is_accepted():
|
||||||
|
columns = {
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {
|
||||||
|
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns, fdr_method="bonferroni")
|
||||||
|
assert result["multiple_testing"]["method"] == "bonferroni"
|
||||||
|
pair = _find_pair(result["pairs"], "size", "price")
|
||||||
|
assert pair["p_value_adjusted"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- H6: correlation_ratio espurio por cardinalidad casi-unica ---------------
|
||||||
|
|
||||||
|
def test_h6_categorica_casi_unica_excluida():
|
||||||
|
# Una categorica con cardinalidad ~ n (id/free-text como Ticket) hace que cada
|
||||||
|
# grupo tenga un solo valor -> varianza intra-grupo ~= 0 -> correlation_ratio
|
||||||
|
# = 1 trivial. No debe aparecer ni evaluado ni como par fuerte.
|
||||||
|
n = 60
|
||||||
|
columns = {
|
||||||
|
"ticket": {"values": [f"T{i}" for i in range(n)], "type": "categorical"},
|
||||||
|
"fare": {"values": [float(i) * 1.3 for i in range(n)], "type": "numeric"},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns)
|
||||||
|
assert _find_pair(result["pairs"], "ticket", "fare") is None
|
||||||
|
assert _find_pair(result["strong"], "ticket", "fare") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_h6_categorica_dispersa_con_nulos_excluida():
|
||||||
|
# Categorica dispersa con muchos None (como Cabin: 147 distintos sobre 204
|
||||||
|
# presentes): los pocos presentes son casi todos distintos -> grupos singleton.
|
||||||
|
# Se mide sobre valores PRESENTES, no sobre n filas, para captarla.
|
||||||
|
vals = [f"C{i}" if i % 4 == 0 else None for i in range(80)] # ~20 presentes, distintos
|
||||||
|
columns = {
|
||||||
|
"cabin": {"values": vals, "type": "categorical"},
|
||||||
|
"fare": {"values": [float(i) for i in range(80)], "type": "numeric"},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns)
|
||||||
|
assert _find_pair(result["pairs"], "cabin", "fare") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_h6_datetime_excluido_de_pares():
|
||||||
|
# Datetime es indice unico-ish por fila -> correlation_ratio = 1 espurio contra
|
||||||
|
# cualquier numerica. Se excluye de los pares de asociacion (las series se
|
||||||
|
# analizan aparte, no aqui).
|
||||||
|
columns = {
|
||||||
|
"date": {
|
||||||
|
"values": [f"2020-01-{i + 1:02d}" for i in range(10)],
|
||||||
|
"type": "datetime",
|
||||||
|
},
|
||||||
|
"value": {"values": [float(i) for i in range(10)], "type": "numeric"},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns)
|
||||||
|
assert _find_pair(result["pairs"], "date", "value") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_h6_categorica_legitima_se_conserva():
|
||||||
|
# Edge anti-sobrefiltrado: una categorica de baja cardinalidad (grupos grandes,
|
||||||
|
# tamano medio >= 1.5) SIGUE evaluandose y su asociacion fuerte se conserva.
|
||||||
|
columns = {
|
||||||
|
"region": {
|
||||||
|
"values": ["N", "N", "S", "S", "E", "E", "W", "W"],
|
||||||
|
"type": "categorical",
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"values": [10.0, 11.0, 50.0, 49.0, 90.0, 91.0, 30.0, 31.0],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns)
|
||||||
|
assert _find_pair(result["pairs"], "region", "score") is not None
|
||||||
|
assert _find_pair(result["strong"], "region", "score") is not None
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Decodificación robusta de códigos QR desde una imagen en disco.
|
||||||
|
|
||||||
|
Función del registry (grupo de capacidad `qr`, dominio `datascience`). Pensada para el caso real
|
||||||
|
en el que un lector básico (pyzbar, `cv2.QRCodeDetector` sobre la imagen cruda) NO capta el QR:
|
||||||
|
screenshots de pantalla con QR pálidos (bajo contraste) o pequeños. En vez de un único intento,
|
||||||
|
genera varias variantes preprocesadas de la imagen y prueba cada detector disponible sobre cada
|
||||||
|
variante, parando al primer acierto.
|
||||||
|
|
||||||
|
Impura: lee un archivo de disco y depende de OpenCV (`opencv-contrib-python-headless`). Degrada
|
||||||
|
limpio (devuelve `[]`) si la imagen no se puede leer o si ningún QR se decodifica; no lanza.
|
||||||
|
|
||||||
|
Detectores (se usan los que estén instalados; el import se envuelve en try/except para degradar):
|
||||||
|
- `cv2.QRCodeDetectorAruco` (preferido — OpenCV puro, sin libs de sistema)
|
||||||
|
- `cv2.QRCodeDetector` (fallback OpenCV puro)
|
||||||
|
- `cv2.wechat_qrcode.WeChatQRCode` (excelente con bajo contraste; SOLO si los modelos cargan)
|
||||||
|
- `pyzbar` (bonus opcional; requiere la lib de sistema `libzbar0`)
|
||||||
|
|
||||||
|
Cero dependencias de sistema obligatorias: con solo OpenCV la función ya funciona.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def _make_opencv_runner(detector):
|
||||||
|
"""Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
out: list[str] = []
|
||||||
|
# detectAndDecodeMulti: capta varios QR en la misma imagen.
|
||||||
|
try:
|
||||||
|
ok, decoded, _points, _ = detector.detectAndDecodeMulti(img)
|
||||||
|
if ok and decoded:
|
||||||
|
out = [s for s in decoded if s]
|
||||||
|
except cv2.error:
|
||||||
|
pass
|
||||||
|
if not out:
|
||||||
|
# Fallback al decodificador de un solo QR.
|
||||||
|
try:
|
||||||
|
s, _pts, _ = detector.detectAndDecode(img)
|
||||||
|
if s:
|
||||||
|
out = [s]
|
||||||
|
except cv2.error:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _make_wechat_runner(wd):
|
||||||
|
"""Envuelve un cv2.wechat_qrcode.WeChatQRCode en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
try:
|
||||||
|
texts, _points = wd.detectAndDecode(img)
|
||||||
|
return [t for t in texts if t]
|
||||||
|
except Exception:
|
||||||
|
# Si los modelos no están cargados o el detector falla, degradar sin romper.
|
||||||
|
return []
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pyzbar_runner(zbar_decode):
|
||||||
|
"""Envuelve pyzbar.decode en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
out: list[str] = []
|
||||||
|
try:
|
||||||
|
for sym in zbar_decode(img):
|
||||||
|
try:
|
||||||
|
out.append(sym.data.decode("utf-8", "replace"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return out
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _build_detectors(debug=False):
|
||||||
|
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
|
||||||
|
detectors = []
|
||||||
|
|
||||||
|
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos.
|
||||||
|
if hasattr(cv2, "QRCodeDetectorAruco"):
|
||||||
|
try:
|
||||||
|
detectors.append(("opencv_aruco", _make_opencv_runner(cv2.QRCodeDetectorAruco())))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# OpenCV clásico (fallback puro).
|
||||||
|
if hasattr(cv2, "QRCodeDetector"):
|
||||||
|
try:
|
||||||
|
detectors.append(("opencv", _make_opencv_runner(cv2.QRCodeDetector())))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# WeChat QR (excelente con bajo contraste) — SOLO si los modelos cargan; opcional.
|
||||||
|
if hasattr(cv2, "wechat_qrcode"):
|
||||||
|
try:
|
||||||
|
wd = cv2.wechat_qrcode.WeChatQRCode()
|
||||||
|
detectors.append(("wechat", _make_wechat_runner(wd)))
|
||||||
|
except Exception:
|
||||||
|
# Modelos no presentes / build sin soporte → saltar sin romper.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# pyzbar (bonus): requiere libzbar0 (lib de sistema). Degrada si falta.
|
||||||
|
try:
|
||||||
|
from pyzbar.pyzbar import decode as _zbar_decode # type: ignore
|
||||||
|
|
||||||
|
detectors.append(("pyzbar", _make_pyzbar_runner(_zbar_decode)))
|
||||||
|
except (ImportError, OSError, Exception): # noqa: B014 - OSError = libzbar0 ausente
|
||||||
|
pass
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(
|
||||||
|
f"[decode_qr_image] detectores disponibles: {[n for n, _ in detectors]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return detectors
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# Variantes preprocesadas de la imagen. Orden = prioridad; se para en el primer acierto.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def _load_bgr(image_path):
|
||||||
|
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
|
||||||
|
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
|
||||||
|
if bgr is not None:
|
||||||
|
return bgr
|
||||||
|
# Fallback PIL para formatos que cv2.imread no maneja en esta build.
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
pil = Image.open(image_path).convert("RGB")
|
||||||
|
return cv2.cvtColor(np.asarray(pil), cv2.COLOR_RGB2BGR)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_variants(image_path, upscale):
|
||||||
|
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
|
||||||
|
bgr = _load_bgr(image_path)
|
||||||
|
if bgr is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Contrast stretch (NORM_MINMAX): clave para QR de bajo contraste (gris sobre gris).
|
||||||
|
stretch = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||||
|
|
||||||
|
# CLAHE: realce de contraste local.
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
|
||||||
|
|
||||||
|
# Upscale del stretch: QR pequeño es la causa #1 de fallo.
|
||||||
|
if upscale and upscale > 1:
|
||||||
|
up = cv2.resize(stretch, None, fx=upscale, fy=upscale, interpolation=cv2.INTER_CUBIC)
|
||||||
|
else:
|
||||||
|
up = stretch
|
||||||
|
|
||||||
|
# Binarizaciones sobre el stretch (mejor base que el gris crudo).
|
||||||
|
_, otsu = cv2.threshold(stretch, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||||
|
adaptive = cv2.adaptiveThreshold(
|
||||||
|
stretch, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 5
|
||||||
|
)
|
||||||
|
|
||||||
|
variants = [
|
||||||
|
("original", bgr),
|
||||||
|
("gray", gray),
|
||||||
|
("contrast_stretch", stretch),
|
||||||
|
("clahe", clahe),
|
||||||
|
("upscale", up),
|
||||||
|
("otsu", otsu),
|
||||||
|
("adaptive_gaussian", adaptive),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Rotaciones sobre la mejor variante binarizada (Otsu).
|
||||||
|
for name, rot in (
|
||||||
|
("rot90", cv2.ROTATE_90_CLOCKWISE),
|
||||||
|
("rot180", cv2.ROTATE_180),
|
||||||
|
("rot270", cv2.ROTATE_90_COUNTERCLOCKWISE),
|
||||||
|
):
|
||||||
|
variants.append((f"otsu_{name}", cv2.rotate(otsu, rot)))
|
||||||
|
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# API pública.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def decode_qr_image(image_path: str, upscale: int = 2, debug: bool = False) -> list[str]:
|
||||||
|
"""Decodifica los códigos QR de una imagen, robusto a bajo contraste y QR pequeños.
|
||||||
|
|
||||||
|
Genera varias variantes preprocesadas de la imagen (escala de grises, contrast stretch,
|
||||||
|
CLAHE, upscale, binarización Otsu/adaptativa, rotaciones) y prueba cada detector disponible
|
||||||
|
(OpenCV Aruco/clásico, WeChat si hay modelos, pyzbar si hay libzbar0) sobre cada variante,
|
||||||
|
parando al primer acierto.
|
||||||
|
|
||||||
|
Parámetros (`upscale` y `debug` pensados como opciones keyword):
|
||||||
|
image_path: ruta del archivo de imagen a leer (png/jpg/...).
|
||||||
|
upscale: factor de ampliación (INTER_CUBIC) aplicado a la variante de contraste estirado
|
||||||
|
para rescatar QR pequeños. Default 2. <=1 desactiva el upscale.
|
||||||
|
debug: si True, imprime a stderr qué variante/detector acertó (o que no se detectó nada).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de payloads de texto de los QR detectados (deduplicada, preservando orden). Lista
|
||||||
|
vacía si no se detecta ninguno o si la imagen no se puede leer. No lanza.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
variants = _build_variants(image_path, upscale)
|
||||||
|
except Exception as exc: # pragma: no cover - defensa ante imágenes corruptas
|
||||||
|
if debug:
|
||||||
|
print(f"[decode_qr_image] fallo construyendo variantes: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not variants:
|
||||||
|
if debug:
|
||||||
|
print(f"[decode_qr_image] no se pudo leer la imagen: {image_path}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
detectors = _build_detectors(debug=debug)
|
||||||
|
if not detectors:
|
||||||
|
if debug:
|
||||||
|
print("[decode_qr_image] ningún detector QR disponible", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
for vname, vimg in variants:
|
||||||
|
for dname, drun in detectors:
|
||||||
|
payloads = drun(vimg)
|
||||||
|
uniq = list(dict.fromkeys(p for p in payloads if p))
|
||||||
|
if uniq:
|
||||||
|
if debug:
|
||||||
|
print(
|
||||||
|
f"[decode_qr_image] acierto variante={vname} detector={dname} "
|
||||||
|
f"n={len(uniq)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return uniq
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print("[decode_qr_image] ningún QR decodificado en ninguna variante", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Demo CLI para `python3 decode_qr_image.py <image_path> [upscale] [debug]`.
|
||||||
|
# (fn run usa su propio runner generado; este bloque es para invocación manual directa.)
|
||||||
|
import json
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({"error": "uso: <image_path> [upscale] [debug]"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
_path = sys.argv[1]
|
||||||
|
_upscale = int(sys.argv[2]) if len(sys.argv) > 2 else 2
|
||||||
|
_debug = (sys.argv[3].lower() in ("1", "true", "yes")) if len(sys.argv) > 3 else False
|
||||||
|
|
||||||
|
_result = decode_qr_image(_path, upscale=_upscale, debug=_debug)
|
||||||
|
print(json.dumps(_result))
|
||||||
@@ -3,10 +3,10 @@ name: depth_to_relief_glb
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
|
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220, mask: np.ndarray | None = None) -> dict"
|
||||||
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
|
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Con mask opcional recorta la malla al objeto (descarta las caras del fondo). Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth y, opcionalmente, la mask de remove_background."
|
||||||
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
|
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -25,7 +25,9 @@ params:
|
|||||||
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
|
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
|
||||||
- name: max_dim
|
- name: max_dim
|
||||||
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
|
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
|
||||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
- name: mask
|
||||||
|
desc: "Mascara opcional HxW (0..255, 255=objeto), tipicamente la 'mask' de remove_background. Si se pasa, se reescala al grid (NEAREST), el fondo se aplana a Z=0 y las caras cuyos tres vertices caen en el fondo se descartan: la malla queda recortada al objeto. None (default) = malla del frame completo (relieve incluido el fondo)."
|
||||||
|
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Con mask, 'faces' es menor (solo caras del objeto); 'vertices' no cambia (el grid completo se conserva). Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
||||||
tested: false
|
tested: false
|
||||||
tests: []
|
tests: []
|
||||||
test_file_path: ""
|
test_file_path: ""
|
||||||
@@ -81,3 +83,14 @@ suavizar el relieve.
|
|||||||
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
|
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
|
||||||
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
|
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
|
||||||
`estimate_image_depth`.
|
`estimate_image_depth`.
|
||||||
|
- **mask opcional (v1.1.0)**: pasa la `mask` de `remove_background` para recortar la malla al
|
||||||
|
objeto. Se reescala con NEAREST (sin interpolar, preserva el borde binario), el fondo se aplana
|
||||||
|
a Z=0 y sus caras se eliminan. El nº de `vertices` no baja (el grid completo se conserva para no
|
||||||
|
romper el mapeo UV 1:1); solo baja `faces`. Una mask degenerada (todo objeto) deja la malla
|
||||||
|
intacta; una mask vacia (todo fondo) deja la malla sin caras (glb valido pero vacio).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-21) — anade parametro opcional `mask` para recortar la malla al objeto
|
||||||
|
(descarta las caras del fondo), cerrando la cadena con `remove_background` del grupo img-to-3d.
|
||||||
|
Aditivo: `mask=None` mantiene el comportamiento previo. Fiel al original de `backend/depth.py`.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def depth_to_relief_glb(
|
|||||||
out_glb_path: str,
|
out_glb_path: str,
|
||||||
z_scale: float = 0.35,
|
z_scale: float = 0.35,
|
||||||
max_dim: int = 220,
|
max_dim: int = 220,
|
||||||
|
mask: "np.ndarray | None" = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Construye una malla de relieve texturizada y la exporta como .glb.
|
Construye una malla de relieve texturizada y la exporta como .glb.
|
||||||
@@ -33,6 +34,9 @@ def depth_to_relief_glb(
|
|||||||
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
|
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
|
||||||
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
|
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
|
||||||
Default 220 (~48k vértices, ~96k caras).
|
Default 220 (~48k vértices, ~96k caras).
|
||||||
|
mask: máscara opcional HxW (0..255, 255 = objeto), típicamente la "mask" devuelta por
|
||||||
|
remove_background. Si se pasa, el fondo se aplana y las caras cuyos vértices caigan
|
||||||
|
en el fondo se descartan: la malla contiene solo el objeto, sin el plano de fondo.
|
||||||
|
|
||||||
Devuelve (dict, nunca lanza):
|
Devuelve (dict, nunca lanza):
|
||||||
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
|
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
|
||||||
@@ -58,6 +62,14 @@ def depth_to_relief_glb(
|
|||||||
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
|
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
|
||||||
H, W = depth.shape
|
H, W = depth.shape
|
||||||
|
|
||||||
|
# Si se pasó máscara (objeto vs fondo), reescalarla al grid ya downsampleado: el fondo
|
||||||
|
# no aporta relieve (se aplana a 0) y luego sus caras se descartan, dejando solo el objeto.
|
||||||
|
fg = None
|
||||||
|
if mask is not None:
|
||||||
|
mask_img = Image.fromarray(np.asarray(mask).astype(np.uint8)).resize((W, H), Image.NEAREST)
|
||||||
|
fg = np.asarray(mask_img) >= 128
|
||||||
|
depth = np.where(fg, depth, 0.0).astype(np.float32)
|
||||||
|
|
||||||
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
|
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
|
||||||
aspect = W / float(H)
|
aspect = W / float(H)
|
||||||
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
|
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
|
||||||
@@ -79,6 +91,12 @@ def depth_to_relief_glb(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Con máscara: conservar solo las caras cuyos tres vértices son objeto. La malla queda
|
||||||
|
# recortada al objeto, sin el plano de fondo que deformaría el relieve.
|
||||||
|
if fg is not None:
|
||||||
|
keep = fg.ravel()[faces].all(axis=1)
|
||||||
|
faces = faces[keep]
|
||||||
|
|
||||||
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
|
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
|
||||||
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
||||||
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ name: detect_distribution_type
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def detect_distribution_type(values: list[float]) -> dict"
|
signature: "def detect_distribution_type(values: list[float]) -> dict"
|
||||||
description: "Classifies the shape of a numeric distribution using skewness, excess kurtosis, tail ratio and log-skewness. Returns a type label and raw stats."
|
description: "Classifies the shape of a numeric distribution using cardinality (distinct values), number of prominent modes, skewness, excess kurtosis, tail ratio and log-skewness. Returns a type label and raw stats. Discrete/ordinal and multimodal columns are detected before the symmetric normal-ish test so they are never mislabeled normal."
|
||||||
tags: [statistics, distribution, classification, skewness, kurtosis, pendiente-usar]
|
tags: [statistics, distribution, classification, skewness, kurtosis, multimodal, cardinality, eda]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -27,15 +27,21 @@ tests:
|
|||||||
- "test_detect_right_skewed"
|
- "test_detect_right_skewed"
|
||||||
- "test_detect_stats_keys"
|
- "test_detect_stats_keys"
|
||||||
- "test_detect_exactly_30"
|
- "test_detect_exactly_30"
|
||||||
|
- "test_detect_discrete_low_cardinality"
|
||||||
|
- "test_detect_multimodal"
|
||||||
|
- "test_detect_normal_still_normal_after_fix"
|
||||||
|
- "test_detect_stats_has_new_keys"
|
||||||
|
- "test_detect_unimodal_skewed_not_multimodal"
|
||||||
test_file_path: "python/functions/datascience/tests/test_detect_distribution_type.py"
|
test_file_path: "python/functions/datascience/tests/test_detect_distribution_type.py"
|
||||||
file_path: "python/functions/datascience/detect_distribution_type.py"
|
file_path: "python/functions/datascience/detect_distribution_type.py"
|
||||||
params:
|
params:
|
||||||
- name: values
|
- name: values
|
||||||
desc: "List of numeric values to classify. Minimum 30 for meaningful classification."
|
desc: "List of numeric values to classify. Minimum 30 for meaningful classification."
|
||||||
output: >
|
output: >
|
||||||
Dict with "type" (str) and "stats" (dict). Type is one of: normal-ish,
|
Dict with "type" (str) and "stats" (dict). Type is one of: discrete,
|
||||||
lognormal-ish, heavy-tail, right-skewed, left-skewed, other, too_few_samples.
|
multimodal, heavy-tail, normal-ish, lognormal-ish, right-skewed, left-skewed,
|
||||||
Stats contains: n, skew, kurtosis, tail_ratio, log_skew.
|
other, too_few_samples. Stats contains: n, skew, kurtosis, tail_ratio,
|
||||||
|
log_skew, n_unique, n_modes, jb_stat, jb_pvalue.
|
||||||
source_repo: "internal:footprint_aurgi"
|
source_repo: "internal:footprint_aurgi"
|
||||||
source_license: "internal-aurgi"
|
source_license: "internal-aurgi"
|
||||||
source_file: "aurgi_mapas/generar_pdf_reporte.py:133"
|
source_file: "aurgi_mapas/generar_pdf_reporte.py:133"
|
||||||
@@ -56,8 +62,14 @@ detect_distribution_type([1]*5)
|
|||||||
|
|
||||||
## Logica de clasificacion
|
## Logica de clasificacion
|
||||||
|
|
||||||
|
El orden importa: cardinalidad y modalidad se evaluan **antes** del test simetrico
|
||||||
|
`normal-ish`, para que una columna discreta/ordinal o multimodal nunca se etiquete
|
||||||
|
"normal" solo porque su skewness sea pequena.
|
||||||
|
|
||||||
- n < 30 → too_few_samples
|
- n < 30 → too_few_samples
|
||||||
|
- n_unique <= 15 → discrete (ordinal / counts de pocos niveles)
|
||||||
- excess kurtosis > 3 → heavy-tail
|
- excess kurtosis > 3 → heavy-tail
|
||||||
|
- n >= 100 AND n_modes >= 2 → multimodal
|
||||||
- |skew| <= 0.5 AND |kurt| <= 1 → normal-ish
|
- |skew| <= 0.5 AND |kurt| <= 1 → normal-ish
|
||||||
- skew > 0.5 AND log_skew cerca de 0 AND tail_ratio > 2 → lognormal-ish
|
- skew > 0.5 AND log_skew cerca de 0 AND tail_ratio > 2 → lognormal-ish
|
||||||
- skew > 0.5 → right-skewed
|
- skew > 0.5 → right-skewed
|
||||||
@@ -65,3 +77,35 @@ detect_distribution_type([1]*5)
|
|||||||
- default → other
|
- default → other
|
||||||
|
|
||||||
tail_ratio = p99/p50; log_skew calculado solo si hay >= 30 positivos.
|
tail_ratio = p99/p50; log_skew calculado solo si hay >= 30 positivos.
|
||||||
|
|
||||||
|
`n_modes` cuenta picos prominentes de un histograma suavizado (~sqrt(n) bins,
|
||||||
|
suavizado triangular) separados por un valle profundo (cae por debajo del 60% del
|
||||||
|
pico menor). Esto evita modos espurios por ruido en continuas unimodales sesgadas.
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando perfiles una columna numerica y quieras saber su forma para elegir el
|
||||||
|
estadistico/visualizacion adecuados (media+desv vs mediana+IQR, histograma vs
|
||||||
|
boxplot). Distingue discretas/ordinales y multimodales que un criterio por-skew
|
||||||
|
confunde con normales.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Jarque-Bera NO es gate de `normal-ish`.** `jb_stat`/`jb_pvalue` se reportan en
|
||||||
|
`stats` como senal diagnostica, pero con n grande Jarque-Bera rechaza normalidad
|
||||||
|
para columnas perfectamente acampanadas (p.ej. pH o density del vino, n~1600,
|
||||||
|
jb_p≈0 pese a ser normal-ish). Usarlo como umbral duro produce falsos negativos
|
||||||
|
masivos. La robustez ante el tamano muestral la dan cardinalidad y modalidad.
|
||||||
|
- El umbral `n_unique <= 15` etiqueta como `discrete` cualquier continua con muy
|
||||||
|
pocos valores distintos: eso es correcto (es discreta/ordinal de facto), no un
|
||||||
|
falso positivo.
|
||||||
|
- `multimodal` solo se evalua con `n >= 100`; por debajo el histograma es demasiado
|
||||||
|
ruidoso para afirmar multimodalidad y se cae a la logica de skew/kurt.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-29) — H11: anade deteccion de cardinalidad (`discrete`) y
|
||||||
|
modalidad (`multimodal`) antes del test `normal-ish`, mas `n_unique`, `n_modes`,
|
||||||
|
`jb_stat`, `jb_pvalue` en stats. Corrige falsos "normal-ish" en discretas/ordinales
|
||||||
|
(wine `quality`) y multimodales (precios BTC). Retrocompatible: continuas normales,
|
||||||
|
sesgadas y heavy-tail no cambian.
|
||||||
|
|||||||
@@ -7,9 +7,26 @@ import numpy as np
|
|||||||
def detect_distribution_type(values: list[float]) -> dict:
|
def detect_distribution_type(values: list[float]) -> dict:
|
||||||
"""Classify the distribution shape of a numeric sample.
|
"""Classify the distribution shape of a numeric sample.
|
||||||
|
|
||||||
Uses skewness, excess kurtosis, tail ratio (p99/p50), and log-skewness
|
Uses cardinality (number of distinct values), number of prominent modes,
|
||||||
to assign one of: normal-ish, lognormal-ish, heavy-tail, right-skewed,
|
skewness, excess kurtosis, tail ratio (p99/p50) and log-skewness to assign
|
||||||
left-skewed, other, or too_few_samples (n < 30).
|
one of: discrete, multimodal, heavy-tail, normal-ish, lognormal-ish,
|
||||||
|
right-skewed, left-skewed, other, or too_few_samples (n < 30).
|
||||||
|
|
||||||
|
A skew-only criterion mislabels discrete/ordinal and multimodal columns as
|
||||||
|
"normal-ish" (e.g. a 6-level rating, or multimodal asset prices whose
|
||||||
|
skewness happens to be small). To avoid that, cardinality and modality are
|
||||||
|
checked *before* the symmetric normal-ish test:
|
||||||
|
|
||||||
|
* ``n_unique <= 15`` -> "discrete" (ordinal / low-cardinality counts).
|
||||||
|
* ``n_modes >= 2`` (with ``n >= 100``) -> "multimodal".
|
||||||
|
|
||||||
|
The Jarque-Bera statistic and its p-value are computed from the already
|
||||||
|
available skewness and excess kurtosis and reported in ``stats`` as a
|
||||||
|
diagnostic signal. It is deliberately NOT used as a hard gate for the
|
||||||
|
"normal-ish" label: with large samples Jarque-Bera rejects normality for
|
||||||
|
trivially non-normal but perfectly bell-shaped columns, which would produce
|
||||||
|
massive false negatives. Cardinality and modality, by contrast, are robust
|
||||||
|
to sample size.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
values: List of numeric values.
|
values: List of numeric values.
|
||||||
@@ -17,7 +34,8 @@ def detect_distribution_type(values: list[float]) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with keys:
|
Dict with keys:
|
||||||
"type" (str): distribution label.
|
"type" (str): distribution label.
|
||||||
"stats" (dict): {"n", "skew", "kurtosis", "tail_ratio", "log_skew"}.
|
"stats" (dict): {"n", "skew", "kurtosis", "tail_ratio", "log_skew",
|
||||||
|
"n_unique", "n_modes", "jb_stat", "jb_pvalue"}.
|
||||||
"""
|
"""
|
||||||
n = len(values)
|
n = len(values)
|
||||||
if n < 30:
|
if n < 30:
|
||||||
@@ -58,17 +76,37 @@ def detect_distribution_type(values: list[float]) -> dict:
|
|||||||
else:
|
else:
|
||||||
log_skew = math.nan
|
log_skew = math.nan
|
||||||
|
|
||||||
|
# Cardinality and modality (robust to sample size).
|
||||||
|
n_unique = int(np.unique(arr).size)
|
||||||
|
n_modes = _count_modes(arr)
|
||||||
|
|
||||||
|
# Jarque-Bera statistic from the moments already computed. Under the null
|
||||||
|
# of normality it follows a chi-squared distribution with 2 degrees of
|
||||||
|
# freedom, whose survival function is exp(-x / 2).
|
||||||
|
jb_stat = n / 6.0 * (skew ** 2 + (kurt ** 2) / 4.0)
|
||||||
|
jb_pvalue = math.exp(-jb_stat / 2.0)
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"n": n,
|
"n": n,
|
||||||
"skew": skew,
|
"skew": skew,
|
||||||
"kurtosis": kurt,
|
"kurtosis": kurt,
|
||||||
"tail_ratio": tail_ratio,
|
"tail_ratio": tail_ratio,
|
||||||
"log_skew": log_skew,
|
"log_skew": log_skew,
|
||||||
|
"n_unique": n_unique,
|
||||||
|
"n_modes": n_modes,
|
||||||
|
"jb_stat": jb_stat,
|
||||||
|
"jb_pvalue": jb_pvalue,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Classification logic
|
# Classification logic. Cardinality and modality come first so a discrete or
|
||||||
if kurt > 3.0:
|
# multimodal column is never mislabeled "normal-ish" on the basis of a small
|
||||||
|
# skewness alone.
|
||||||
|
if n_unique <= 15:
|
||||||
|
dist_type = "discrete"
|
||||||
|
elif kurt > 3.0:
|
||||||
dist_type = "heavy-tail"
|
dist_type = "heavy-tail"
|
||||||
|
elif n >= 100 and n_modes >= 2:
|
||||||
|
dist_type = "multimodal"
|
||||||
elif abs(skew) <= 0.5 and abs(kurt) <= 1.0:
|
elif abs(skew) <= 0.5 and abs(kurt) <= 1.0:
|
||||||
dist_type = "normal-ish"
|
dist_type = "normal-ish"
|
||||||
elif (
|
elif (
|
||||||
@@ -87,3 +125,58 @@ def detect_distribution_type(values: list[float]) -> dict:
|
|||||||
dist_type = "other"
|
dist_type = "other"
|
||||||
|
|
||||||
return {"type": dist_type, "stats": stats}
|
return {"type": dist_type, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def _count_modes(values, prom_frac: float = 0.15, valley_frac: float = 0.6) -> int:
|
||||||
|
"""Count prominent modes separated by deep valleys in a histogram.
|
||||||
|
|
||||||
|
A naive local-maximum count over a raw histogram is dominated by sampling
|
||||||
|
noise, so this:
|
||||||
|
|
||||||
|
1. Bins the data into ~sqrt(n) bins and applies a light triangular smooth.
|
||||||
|
2. Keeps local maxima taller than ``prom_frac`` of the global peak.
|
||||||
|
3. Merges two adjacent peaks unless the lowest point between them falls
|
||||||
|
below ``valley_frac`` of the smaller peak (a genuine separating valley).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: Numeric numpy array.
|
||||||
|
prom_frac: Minimum peak height as a fraction of the tallest peak.
|
||||||
|
valley_frac: Two peaks count as distinct modes only if the valley
|
||||||
|
between them dips below this fraction of the smaller peak.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of distinct modes (0 for an empty/degenerate sample).
|
||||||
|
"""
|
||||||
|
arr = np.asarray(values, dtype=float)
|
||||||
|
n = arr.size
|
||||||
|
if n == 0:
|
||||||
|
return 0
|
||||||
|
n_bins = max(10, min(50, int(round(math.sqrt(n)))))
|
||||||
|
counts, _ = np.histogram(arr, bins=n_bins)
|
||||||
|
kernel = np.array([1.0, 2.0, 1.0])
|
||||||
|
kernel /= kernel.sum()
|
||||||
|
smooth = np.convolve(counts.astype(float), kernel, mode="same")
|
||||||
|
|
||||||
|
peak_global = float(smooth.max())
|
||||||
|
if peak_global <= 0:
|
||||||
|
return 0
|
||||||
|
threshold = peak_global * prom_frac
|
||||||
|
|
||||||
|
peaks = []
|
||||||
|
for i in range(len(smooth)):
|
||||||
|
left = smooth[i - 1] if i > 0 else -1.0
|
||||||
|
right = smooth[i + 1] if i < len(smooth) - 1 else -1.0
|
||||||
|
if smooth[i] >= threshold and smooth[i] > left and smooth[i] >= right:
|
||||||
|
peaks.append(i)
|
||||||
|
if len(peaks) <= 1:
|
||||||
|
return len(peaks)
|
||||||
|
|
||||||
|
kept = [peaks[0]]
|
||||||
|
for p in peaks[1:]:
|
||||||
|
prev = kept[-1]
|
||||||
|
valley = float(smooth[prev:p + 1].min())
|
||||||
|
if valley <= valley_frac * min(smooth[prev], smooth[p]):
|
||||||
|
kept.append(p) # separated by a deep valley -> distinct mode
|
||||||
|
elif smooth[p] > smooth[prev]:
|
||||||
|
kept[-1] = p # same mode, keep the taller peak
|
||||||
|
return len(kept)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
name: exploratory_caveats
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def exploratory_caveats(profile: dict) -> dict"
|
||||||
|
description: "Genera las advertencias que recuerdan que un EDA es EXPLORATORIO (genera hipotesis), no confirmatorio. Inspecciona un TableProfile del grupo eda y devuelve solo los caveats que aplican a lo calculado: correlacion!=causalidad, overfitting in-sample, p-values no son confirmacion, comparaciones multiples, outliers!=errores, muestra pequena, datos faltantes. El caveat general va siempre. Pura."
|
||||||
|
tags: [eda, exploratory, caveats, hypotheses, overfitting, correlation-causation, p-values, tukey, lopez-de-prado, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: profile
|
||||||
|
desc: "TableProfile dict del grupo eda. Se leen defensivamente `correlations` (pares), `models` (pca/kmeans/outliers/normality), `columns` (sub-bloques `numeric` con n_outliers/outlier_pct y `trend` con p_value), `n_rows`, `null_cell_pct` y `all_null_cols`. Cualquier clave puede faltar."
|
||||||
|
output: "dict con `n` (numero de caveats), `caveats` (lista de {id, topic, message, reference} empezando por el general `exploratory_nature`) y `note` (vacio en caso normal; mensaje si el perfil esta vacio y solo se devuelve el caveat general). Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_perfil_vacio_solo_caveat_general", "test_none_no_lanza_y_da_general", "test_caveat_general_siempre_primero", "test_correlaciones_disparan_causalidad_y_overfitting", "test_dos_o_mas_pares_disparan_comparaciones_multiples", "test_modelos_disparan_overfitting_y_pvalues", "test_outliers_por_columna_disparan_caveat", "test_outliers_multivariantes_disparan_caveat", "test_trend_pvalue_dispara_caveat_pvalues", "test_muestra_pequena_dispara_caveat", "test_muestra_grande_no_dispara_small_sample", "test_muchos_faltantes_disparan_missing_data", "test_columnas_all_null_disparan_missing_data", "test_pocos_faltantes_no_disparan_missing_data", "test_estructura_de_cada_caveat"]
|
||||||
|
test_file_path: "python/functions/datascience/exploratory_caveats_test.py"
|
||||||
|
file_path: "python/functions/datascience/exploratory_caveats.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import exploratory_caveats
|
||||||
|
|
||||||
|
profile = {
|
||||||
|
"n_rows": 5000,
|
||||||
|
"correlations": {"pairs": [
|
||||||
|
{"a": "precio", "b": "ventas", "value": 0.82},
|
||||||
|
{"a": "precio", "b": "margen", "value": -0.61},
|
||||||
|
]},
|
||||||
|
"models": {"pca": {"explained": [0.6, 0.3]}, "normality": {"precio": {"is_normal": False}}},
|
||||||
|
"columns": [{"name": "precio", "numeric": {"n_outliers": 4, "outlier_pct": 0.8}}],
|
||||||
|
}
|
||||||
|
out = exploratory_caveats(profile)
|
||||||
|
out["n"] # -> 6
|
||||||
|
[c["id"] for c in out["caveats"]]
|
||||||
|
# -> ['exploratory_nature', 'correlation_not_causation', 'in_sample_overfitting',
|
||||||
|
# 'p_values_not_confirmation', 'multiple_comparisons', 'outliers_not_errors']
|
||||||
|
|
||||||
|
# Perfil vacio -> solo la advertencia general.
|
||||||
|
exploratory_caveats({})["caveats"][0]["id"] # -> "exploratory_nature"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Al cerrar un EDA, antes de entregar el reporte o de tomar decisiones sobre lo que
|
||||||
|
muestra. Convierte la disciplina exploratoria (Tukey: el EDA da hipotesis, no
|
||||||
|
conclusiones) en una lista accionable de advertencias adaptada a lo que realmente se
|
||||||
|
calculo en ese perfil. Pensada para inyectar una seccion "Advertencias / esto es
|
||||||
|
exploratorio" en el markdown de un reporte EDA, o para que un agente recuerde no
|
||||||
|
tratar una correlacion o una "significancia" como confirmacion. NO la uses para
|
||||||
|
calcular estadisticos: solo razona sobre el contenido de un TableProfile ya hecho.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es **pura**: no recalcula nada, solo decide que advertencias aplican a partir de
|
||||||
|
las claves presentes en el `profile`. Si una fase del EDA no se corrio (p.ej. sin
|
||||||
|
`models`), su caveat no aparece — es deliberado.
|
||||||
|
- El caveat `exploratory_nature` (general) va SIEMPRE, incluso con perfil vacio o
|
||||||
|
`None` (en ese caso `note` lo avisa). No lanza excepcion ante entradas raras.
|
||||||
|
- `correlations` se tolera como lista de pares o como dict con `pairs`/`strongest`
|
||||||
|
(mismo shape que consume `render_eda_markdown`). Un solo par dispara
|
||||||
|
`correlation_not_causation` + `in_sample_overfitting`; >=2 anaden ademas
|
||||||
|
`multiple_comparisons`.
|
||||||
|
- Umbrales: muestra pequena si `n_rows < 30`; faltantes notables si
|
||||||
|
`null_cell_pct > 0.2` (fraccion) o si hay `all_null_cols`. Son convenciones
|
||||||
|
prudentes, ajustables si el caller lo necesita (recomputando sobre el mismo
|
||||||
|
profile).
|
||||||
|
- `null_cell_pct` se asume fraccion 0-1 (como en el resto del grupo eda). Si tu
|
||||||
|
pipeline lo guarda como porcentaje 0-100, el umbral se dispara casi siempre.
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Genera las advertencias que recuerdan que un EDA es EXPLORATORIO, no confirmatorio.
|
||||||
|
|
||||||
|
Funcion pura y determinista: dict (TableProfile del grupo ``eda``) entra, dict con
|
||||||
|
una lista de caveats sale. No hace I/O, no muta el input, no lanza excepciones.
|
||||||
|
|
||||||
|
Doctrina (Tukey, *EDA* 1977; Aronson; López de Prado 2018): el análisis exploratorio
|
||||||
|
sirve para GENERAR hipótesis, no para confirmarlas. Lo que se ve mirando todo el
|
||||||
|
dataset a la vez —correlaciones, clusters, "significancias", outliers— es un punto de
|
||||||
|
partida, no una conclusión: hay que validarlo fuera de muestra con un análisis dirigido.
|
||||||
|
Esta función inspecciona qué contiene el perfil y devuelve solo las advertencias que
|
||||||
|
aplican a lo que realmente se ha calculado (si hay correlaciones → caveat de
|
||||||
|
causalidad; si hay modelos → caveat de overfitting; etc.), además de una advertencia
|
||||||
|
general que siempre acompaña a un EDA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Umbrales para disparar caveats dependientes de magnitud.
|
||||||
|
_SMALL_SAMPLE_ROWS = 30 # n_rows por debajo de esto -> baja potencia.
|
||||||
|
_HIGH_MISSING_FRACTION = 0.2 # null_cell_pct (fracción) por encima -> sesgo MNAR.
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(v):
|
||||||
|
"""Parsea a float; None si es None/bool/no parseable (NaN incluido)."""
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if f != f: # NaN
|
||||||
|
return None
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _correlation_pairs(profile: dict) -> list:
|
||||||
|
"""Extrae la lista de pares de correlación del perfil, tolerando varios shapes.
|
||||||
|
|
||||||
|
``correlations`` puede ser una lista de pares o un dict con ``pairs`` /
|
||||||
|
``strongest``. Devuelve siempre una lista (vacía si no hay nada usable).
|
||||||
|
"""
|
||||||
|
correlations = profile.get("correlations")
|
||||||
|
if not correlations:
|
||||||
|
return []
|
||||||
|
if isinstance(correlations, dict):
|
||||||
|
pairs = correlations.get("pairs") or correlations.get("strongest") or []
|
||||||
|
else:
|
||||||
|
pairs = correlations
|
||||||
|
return list(pairs) if isinstance(pairs, (list, tuple)) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _has_models(profile: dict) -> bool:
|
||||||
|
"""True si el perfil contiene un bloque de modelos multivariantes ajustados."""
|
||||||
|
models = profile.get("models")
|
||||||
|
if not isinstance(models, dict):
|
||||||
|
return False
|
||||||
|
return any(models.get(k) for k in ("pca", "kmeans", "outliers"))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_pvalues(profile: dict) -> bool:
|
||||||
|
"""True si el perfil contiene p-values (tests de normalidad o de tendencia)."""
|
||||||
|
models = profile.get("models")
|
||||||
|
if isinstance(models, dict) and models.get("normality"):
|
||||||
|
return True
|
||||||
|
# Tests de tendencia adjuntados por columna (trend_slope) también traen p-value.
|
||||||
|
for col in profile.get("columns") or []:
|
||||||
|
if isinstance(col, dict) and isinstance(col.get("trend"), dict):
|
||||||
|
if col["trend"].get("p_value") is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_outliers(profile: dict) -> bool:
|
||||||
|
"""True si se han detectado outliers (multivariantes o por columna numérica)."""
|
||||||
|
models = profile.get("models")
|
||||||
|
if isinstance(models, dict) and models.get("outliers"):
|
||||||
|
return True
|
||||||
|
for col in profile.get("columns") or []:
|
||||||
|
if not isinstance(col, dict):
|
||||||
|
continue
|
||||||
|
num = col.get("numeric")
|
||||||
|
if isinstance(num, dict):
|
||||||
|
n_out = _to_float(num.get("n_outliers"))
|
||||||
|
opct = _to_float(num.get("outlier_pct"))
|
||||||
|
if (n_out is not None and n_out > 0) or (opct is not None and opct > 0):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def exploratory_caveats(profile: dict) -> dict:
|
||||||
|
"""Devuelve las advertencias de que el EDA es exploratorio según lo que contiene.
|
||||||
|
|
||||||
|
Inspecciona un TableProfile (dict del grupo ``eda``) y arma la lista de caveats
|
||||||
|
relevantes. Una advertencia general (la naturaleza exploratoria del EDA) se
|
||||||
|
incluye SIEMPRE; el resto solo se añaden cuando el perfil contiene aquello a lo
|
||||||
|
que aplican:
|
||||||
|
|
||||||
|
- correlaciones presentes -> correlación ≠ causalidad.
|
||||||
|
- modelos / correlaciones -> riesgo de overfitting in-sample (validar OOS).
|
||||||
|
- p-values (normalidad/tendencia) -> no son confirmación sin corregir / IID.
|
||||||
|
- ≥2 pares de correlación -> comparaciones múltiples (falsos positivos).
|
||||||
|
- outliers detectados -> no implican errores.
|
||||||
|
- n_rows pequeño -> baja potencia, estimaciones inestables.
|
||||||
|
- muchos faltantes -> posible sesgo si no son aleatorios (MNAR).
|
||||||
|
|
||||||
|
Es pura, determinista y no lanza excepciones. Un perfil vacío o ``None`` devuelve
|
||||||
|
solo el caveat general con una nota.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: TableProfile dict del grupo ``eda``. Se lee todo defensivamente con
|
||||||
|
``.get(...)`` porque casi cualquier fase puede faltar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ``n``: número de caveats devueltos (int).
|
||||||
|
- ``caveats``: lista de dicts ``{"id", "topic", "message", "reference"}``,
|
||||||
|
empezando por el general ``exploratory_nature``.
|
||||||
|
- ``note``: cadena vacía en el caso normal; mensaje cuando el perfil está
|
||||||
|
vacío y solo se devuelve la advertencia general.
|
||||||
|
"""
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
profile = {}
|
||||||
|
|
||||||
|
caveats: list = []
|
||||||
|
|
||||||
|
# Caveat general: SIEMPRE presente. El EDA genera hipótesis, no conclusiones.
|
||||||
|
caveats.append({
|
||||||
|
"id": "exploratory_nature",
|
||||||
|
"topic": "naturaleza exploratoria",
|
||||||
|
"message": (
|
||||||
|
"El EDA genera HIPÓTESIS, no conclusiones. Cada patrón que veas aquí es un "
|
||||||
|
"punto de partida para confirmarlo con un análisis dirigido sobre datos "
|
||||||
|
"nuevos, no una verdad ya establecida."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), Exploratory Data Analysis; Aronson",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
return {
|
||||||
|
"n": len(caveats),
|
||||||
|
"caveats": caveats,
|
||||||
|
"note": "perfil vacío: solo se devuelve la advertencia general",
|
||||||
|
}
|
||||||
|
|
||||||
|
corr_pairs = _correlation_pairs(profile)
|
||||||
|
has_corr = len(corr_pairs) > 0
|
||||||
|
has_models = _has_models(profile)
|
||||||
|
|
||||||
|
# Correlación ≠ causalidad.
|
||||||
|
if has_corr:
|
||||||
|
caveats.append({
|
||||||
|
"id": "correlation_not_causation",
|
||||||
|
"topic": "correlación vs causalidad",
|
||||||
|
"message": (
|
||||||
|
"Las correlaciones son asociaciones, no relaciones causales. Una "
|
||||||
|
"correlación fuerte puede venir de una variable de confusión o del "
|
||||||
|
"azar; valídala out-of-sample o con un diseño experimental antes de "
|
||||||
|
"actuar sobre ella."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Overfitting in-sample: cualquier patrón ajustado sobre todo el dataset.
|
||||||
|
if has_models or has_corr:
|
||||||
|
caveats.append({
|
||||||
|
"id": "in_sample_overfitting",
|
||||||
|
"topic": "overfitting in-sample",
|
||||||
|
"message": (
|
||||||
|
"Los patrones (modelos, clusters, correlaciones) se han extraído sobre "
|
||||||
|
"TODO el dataset. Lo aprendido in-sample puede no replicar fuera de "
|
||||||
|
"muestra (overfitting / selección por backtest). Valida con holdout o "
|
||||||
|
"walk-forward antes de confiar en ellos."
|
||||||
|
),
|
||||||
|
"reference": "López de Prado (2018), Advances in Financial Machine Learning",
|
||||||
|
})
|
||||||
|
|
||||||
|
# p-values: no son confirmación sin corregir multiplicidad / sobre datos no-IID.
|
||||||
|
if _has_pvalues(profile):
|
||||||
|
caveats.append({
|
||||||
|
"id": "p_values_not_confirmation",
|
||||||
|
"topic": "p-values",
|
||||||
|
"message": (
|
||||||
|
"Los p-values sin corregir por comparaciones múltiples, o calculados "
|
||||||
|
"sobre datos no-IID (series temporales, datos agrupados), no son "
|
||||||
|
"confirmación. Trata cualquier 'significancia' vista en exploración "
|
||||||
|
"como provisional."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Comparaciones múltiples: cuantos más pares/columnas miras, más falsos positivos.
|
||||||
|
if len(corr_pairs) >= 2:
|
||||||
|
caveats.append({
|
||||||
|
"id": "multiple_comparisons",
|
||||||
|
"topic": "comparaciones múltiples",
|
||||||
|
"message": (
|
||||||
|
"Al examinar muchos pares/columnas a la vez, algunos parecerán "
|
||||||
|
"'significativos' solo por azar (problema de comparaciones múltiples). "
|
||||||
|
"Cuantas más combinaciones miras, más falsos positivos esperas."
|
||||||
|
),
|
||||||
|
"reference": "López de Prado (2018), AFML",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Outliers detectados no implican errores.
|
||||||
|
if _has_outliers(profile):
|
||||||
|
caveats.append({
|
||||||
|
"id": "outliers_not_errors",
|
||||||
|
"topic": "outliers",
|
||||||
|
"message": (
|
||||||
|
"Los outliers detectados son puntos estadísticamente atípicos, NO "
|
||||||
|
"necesariamente errores. Pueden ser el dato más interesante (fraude, "
|
||||||
|
"evento raro). Investígalos antes de eliminarlos."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Muestra pequeña: baja potencia, estimaciones inestables.
|
||||||
|
n_rows = _to_float(profile.get("n_rows"))
|
||||||
|
if n_rows is not None and n_rows < _SMALL_SAMPLE_ROWS:
|
||||||
|
caveats.append({
|
||||||
|
"id": "small_sample",
|
||||||
|
"topic": "muestra pequeña",
|
||||||
|
"message": (
|
||||||
|
f"Pocas filas (n={int(n_rows)}): la potencia estadística es baja y las "
|
||||||
|
"estimaciones (media, correlación, forma de la distribución) son "
|
||||||
|
"inestables. Los patrones pueden cambiar con más datos."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Datos faltantes: posible sesgo si no son aleatorios (MNAR).
|
||||||
|
null_frac = _to_float(profile.get("null_cell_pct"))
|
||||||
|
all_null_cols = profile.get("all_null_cols") or []
|
||||||
|
if (null_frac is not None and null_frac > _HIGH_MISSING_FRACTION) or all_null_cols:
|
||||||
|
caveats.append({
|
||||||
|
"id": "missing_data_bias",
|
||||||
|
"topic": "datos faltantes",
|
||||||
|
"message": (
|
||||||
|
"Hay un volumen notable de datos faltantes. Si los ausentes no son "
|
||||||
|
"aleatorios (MNAR), los estadísticos calculados sobre lo presente "
|
||||||
|
"están sesgados; no extrapoles sin entender por qué faltan."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"n": len(caveats), "caveats": caveats, "note": ""}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests para exploratory_caveats."""
|
||||||
|
|
||||||
|
from exploratory_caveats import exploratory_caveats
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(out):
|
||||||
|
return {c["id"] for c in out["caveats"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_perfil_vacio_solo_caveat_general():
|
||||||
|
out = exploratory_caveats({})
|
||||||
|
assert out["n"] == 1
|
||||||
|
assert _ids(out) == {"exploratory_nature"}
|
||||||
|
assert out["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_no_lanza_y_da_general():
|
||||||
|
out = exploratory_caveats(None)
|
||||||
|
assert _ids(out) == {"exploratory_nature"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_caveat_general_siempre_primero():
|
||||||
|
out = exploratory_caveats({"n_rows": 1000, "columns": []})
|
||||||
|
assert out["caveats"][0]["id"] == "exploratory_nature"
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlaciones_disparan_causalidad_y_overfitting():
|
||||||
|
profile = {
|
||||||
|
"n_rows": 5000,
|
||||||
|
"correlations": {"pairs": [{"a": "x", "b": "y", "value": 0.8}]},
|
||||||
|
}
|
||||||
|
ids = _ids(exploratory_caveats(profile))
|
||||||
|
assert "correlation_not_causation" in ids
|
||||||
|
assert "in_sample_overfitting" in ids
|
||||||
|
# un solo par -> NO dispara comparaciones múltiples
|
||||||
|
assert "multiple_comparisons" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_dos_o_mas_pares_disparan_comparaciones_multiples():
|
||||||
|
profile = {
|
||||||
|
"correlations": [
|
||||||
|
{"a": "x", "b": "y", "value": 0.8},
|
||||||
|
{"a": "x", "b": "z", "value": -0.6},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert "multiple_comparisons" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_modelos_disparan_overfitting_y_pvalues():
|
||||||
|
profile = {
|
||||||
|
"models": {
|
||||||
|
"pca": {"explained": [0.6, 0.3]},
|
||||||
|
"normality": {"col_a": {"is_normal": False}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ids = _ids(exploratory_caveats(profile))
|
||||||
|
assert "in_sample_overfitting" in ids
|
||||||
|
assert "p_values_not_confirmation" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_outliers_por_columna_disparan_caveat():
|
||||||
|
profile = {
|
||||||
|
"columns": [
|
||||||
|
{"name": "precio", "numeric": {"n_outliers": 3, "outlier_pct": 1.5}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert "outliers_not_errors" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_outliers_multivariantes_disparan_caveat():
|
||||||
|
profile = {"models": {"outliers": {"flags": [True, False, True]}}}
|
||||||
|
assert "outliers_not_errors" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trend_pvalue_dispara_caveat_pvalues():
|
||||||
|
profile = {
|
||||||
|
"columns": [
|
||||||
|
{"name": "ventas", "trend": {"direction": "up", "p_value": 0.01}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert "p_values_not_confirmation" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_pequena_dispara_caveat():
|
||||||
|
out = exploratory_caveats({"n_rows": 12})
|
||||||
|
assert "small_sample" in _ids(out)
|
||||||
|
msg = next(c["message"] for c in out["caveats"] if c["id"] == "small_sample")
|
||||||
|
assert "12" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_grande_no_dispara_small_sample():
|
||||||
|
assert "small_sample" not in _ids(exploratory_caveats({"n_rows": 5000}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_muchos_faltantes_disparan_missing_data():
|
||||||
|
assert "missing_data_bias" in _ids(exploratory_caveats({"null_cell_pct": 0.35}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_columnas_all_null_disparan_missing_data():
|
||||||
|
assert "missing_data_bias" in _ids(exploratory_caveats({"all_null_cols": ["x"]}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pocos_faltantes_no_disparan_missing_data():
|
||||||
|
assert "missing_data_bias" not in _ids(exploratory_caveats({"null_cell_pct": 0.05}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_de_cada_caveat():
|
||||||
|
out = exploratory_caveats({"correlations": [{"a": "x", "b": "y", "value": 0.9}]})
|
||||||
|
for c in out["caveats"]:
|
||||||
|
assert set(c.keys()) == {"id", "topic", "message", "reference"}
|
||||||
|
assert all(isinstance(c[k], str) and c[k] for k in c)
|
||||||
|
assert out["n"] == len(out["caveats"])
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: fdr_correction
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict"
|
||||||
|
description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh') o Bonferroni (FWER, 'bonferroni'). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)."
|
||||||
|
tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, p-value, data-mining-bias, python]
|
||||||
|
params:
|
||||||
|
- name: pvalues
|
||||||
|
desc: "lista de p-valores (floats en [0, 1]). Se admiten None u otros valores no validos en posiciones sin test disponible; se propagan como None en la salida y no cuentan como prueba (m)."
|
||||||
|
- name: alpha
|
||||||
|
desc: "nivel de significancia objetivo tras la correccion (default 0.05). Para BH es el umbral del FDR; para Bonferroni, del FWER (tasa de error por familia)."
|
||||||
|
- name: method
|
||||||
|
desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador). Cualquier otro valor devuelve un dict con note."
|
||||||
|
output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos"]
|
||||||
|
test_file_path: "python/functions/datascience/fdr_correction_test.py"
|
||||||
|
file_path: "python/functions/datascience/fdr_correction.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import fdr_correction
|
||||||
|
|
||||||
|
# Tres pruebas: dos muy significativas, una claramente no.
|
||||||
|
pvalues = [0.01, 0.02, 0.5]
|
||||||
|
|
||||||
|
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||||
|
print(bh["reject"]) # -> [True, True, False]
|
||||||
|
print(bh["n_rejected"]) # -> 2
|
||||||
|
|
||||||
|
# Bonferroni es mas conservador: solo sobrevive la mas fuerte.
|
||||||
|
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||||
|
print(bon["reject"]) # -> [True, False, False]
|
||||||
|
print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0]
|
||||||
|
|
||||||
|
# Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la
|
||||||
|
# lista completa de pares y recuperar el mapeo 1:1.
|
||||||
|
mix = fdr_correction([0.001, None, 0.9])
|
||||||
|
print(mix["reject"]) # -> [True, False, False]
|
||||||
|
print(mix["n_tests"]) # -> 2 (el None no cuenta como prueba)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando evalues **muchas hipotesis a la vez** y vayas a declarar "significativos"
|
||||||
|
los resultados por debajo de un umbral de p-valor: matriz de asociacion entre
|
||||||
|
todas las columnas, barrido de reglas/senales, cualquier busqueda que pruebe N
|
||||||
|
combinaciones y se quede con las que "pasan". Sin corregir, con N pruebas y
|
||||||
|
alpha=0.05 esperas ~5% de falsos positivos *por azar*: cuantas mas pruebas, mas
|
||||||
|
correlaciones espurias. Llama a `fdr_correction` con todos los p-valores de la
|
||||||
|
familia y usa `reject` (no el umbral crudo) para decidir que es real. Usa `"bh"`
|
||||||
|
por defecto (mejor potencia); `"bonferroni"` cuando un falso positivo sea muy
|
||||||
|
costoso y prefieras maxima cautela.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Pura y sin dependencias externas (solo `math` de la stdlib).
|
||||||
|
- Corrige **dentro de una familia de pruebas**: pasa de una vez todos los
|
||||||
|
p-valores que compiten, no los corrijas por separado o pierdes el control del
|
||||||
|
sesgo.
|
||||||
|
- La salida esta **alineada 1:1** con la entrada. Las posiciones invalidas
|
||||||
|
(`None`, `NaN`, fuera de `[0, 1]`, no numericas) se devuelven como
|
||||||
|
`p_values_adjusted=None` y `reject=False`, y no cuentan en `n_tests` (m). Por
|
||||||
|
eso puedes pasar la lista completa de pares aunque algunos no tengan test.
|
||||||
|
- `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que
|
||||||
|
`len(pvalues)` si hay `None`.
|
||||||
|
- BH y Bonferroni controlan cosas distintas: BH la tasa de falsos
|
||||||
|
descubrimientos (FDR), Bonferroni la probabilidad de *cualquier* falso
|
||||||
|
positivo (FWER). No son intercambiables; elige segun el coste de equivocarte.
|
||||||
|
- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict
|
||||||
|
con `note`.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Correccion de comparaciones multiples (multiple-testing) para una lista de p-valores.
|
||||||
|
|
||||||
|
Funcion pura del grupo eda. Cuando se evaluan muchas hipotesis a la vez (p.ej.
|
||||||
|
todos los pares de una matriz de asociacion), la probabilidad de obtener al menos
|
||||||
|
un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria
|
||||||
|
de datos (data-mining bias) descrito por Aronson en *Evidence-Based Technical
|
||||||
|
Analysis* (cap. 6). Esta funcion ajusta los p-valores para controlar ese sesgo
|
||||||
|
mediante dos metodos clasicos:
|
||||||
|
|
||||||
|
- Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos
|
||||||
|
(False Discovery Rate, FDR). Menos conservador, mas potencia estadistica.
|
||||||
|
- Bonferroni (``"bonferroni"``): controla la tasa de error por familia
|
||||||
|
(Family-Wise Error Rate, FWER). Mas conservador.
|
||||||
|
|
||||||
|
No usa dependencias externas: aritmetica de la libreria estandar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_p(v) -> bool:
|
||||||
|
"""True si v es un p-valor numerico finito dentro de [0, 1]."""
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
return False
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
return False
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
return False
|
||||||
|
return 0.0 <= x <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict:
|
||||||
|
"""Corrige una lista de p-valores por comparaciones multiples.
|
||||||
|
|
||||||
|
Aplica Benjamini-Hochberg (FDR) o Bonferroni (FWER) sobre ``pvalues`` y
|
||||||
|
devuelve, alineado posicion a posicion con la entrada, el p-valor ajustado y
|
||||||
|
si cada hipotesis se rechaza al nivel ``alpha`` tras la correccion. Las
|
||||||
|
posiciones cuyo valor no sea un p-valor valido (``None``, ``NaN``, fuera de
|
||||||
|
``[0, 1]`` o no numerico) se conservan en la salida como ``None`` /
|
||||||
|
``False`` y se excluyen del conteo de pruebas ``m``; asi el llamador puede
|
||||||
|
pasar la lista completa (incluidos pares sin test disponible) y recuperar un
|
||||||
|
mapeo 1:1.
|
||||||
|
|
||||||
|
Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza
|
||||||
|
excepcion ante datos vacios o invalidos; en su lugar devuelve un dict con la
|
||||||
|
clave ``note`` explicando el caso degenerado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pvalues: lista de p-valores (floats en [0, 1]). Se admiten ``None`` u
|
||||||
|
otros valores no validos en posiciones sin test disponible; se
|
||||||
|
propagan como ``None`` en la salida y no cuentan como prueba.
|
||||||
|
alpha: nivel de significancia objetivo tras la correccion (default 0.05).
|
||||||
|
Para BH es el umbral del FDR; para Bonferroni, del FWER.
|
||||||
|
method: ``"bh"`` (Benjamini-Hochberg, FDR) o ``"bonferroni"`` (FWER).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves:
|
||||||
|
p_values_adjusted: lista alineada con ``pvalues``. Cada entrada es el
|
||||||
|
p-valor ajustado (float en [0, 1]) o ``None`` si la posicion no
|
||||||
|
era un p-valor valido.
|
||||||
|
reject: lista de booleanos alineada con ``pvalues``. ``True`` si la
|
||||||
|
hipotesis se rechaza al nivel ``alpha`` tras la correccion
|
||||||
|
(es significativa); ``False`` en caso contrario o si la posicion
|
||||||
|
no era valida.
|
||||||
|
n_tests: numero de p-valores validos usados en la correccion (m).
|
||||||
|
n_rejected: numero de hipotesis rechazadas (significativas).
|
||||||
|
alpha: nivel de significancia aplicado (float).
|
||||||
|
method: metodo aplicado (``"bh"`` o ``"bonferroni"``).
|
||||||
|
|
||||||
|
Casos degenerados (lista vacia, sin p-valores validos o metodo
|
||||||
|
desconocido) anaden ademas una clave ``note`` y devuelven listas
|
||||||
|
coherentes (``reject`` todo ``False``, ``p_values_adjusted`` con ``None``
|
||||||
|
en las posiciones invalidas).
|
||||||
|
"""
|
||||||
|
method_norm = (method or "").strip().lower()
|
||||||
|
if method_norm not in {"bh", "bonferroni"}:
|
||||||
|
n = len(pvalues)
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": [None] * n,
|
||||||
|
"reject": [False] * n,
|
||||||
|
"n_tests": 0,
|
||||||
|
"n_rejected": 0,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"method": method,
|
||||||
|
"note": (
|
||||||
|
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) "
|
||||||
|
"o 'bonferroni'"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
n = len(pvalues)
|
||||||
|
if n == 0:
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": [],
|
||||||
|
"reject": [],
|
||||||
|
"n_tests": 0,
|
||||||
|
"n_rejected": 0,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"method": method_norm,
|
||||||
|
"note": "lista de p-valores vacia",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Posiciones validas: (indice_original, p). Las invalidas se propagan como None.
|
||||||
|
valid = [(i, float(p)) for i, p in enumerate(pvalues) if _is_valid_p(p)]
|
||||||
|
m = len(valid)
|
||||||
|
|
||||||
|
adjusted: list = [None] * n
|
||||||
|
reject: list = [False] * n
|
||||||
|
|
||||||
|
if m == 0:
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": adjusted,
|
||||||
|
"reject": reject,
|
||||||
|
"n_tests": 0,
|
||||||
|
"n_rejected": 0,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"method": method_norm,
|
||||||
|
"note": "ningun p-valor valido en la entrada",
|
||||||
|
}
|
||||||
|
|
||||||
|
a = float(alpha)
|
||||||
|
|
||||||
|
if method_norm == "bonferroni":
|
||||||
|
# p ajustado = min(1, p * m); rechaza si p_ajustado <= alpha.
|
||||||
|
for orig_idx, p in valid:
|
||||||
|
padj = min(1.0, p * m)
|
||||||
|
adjusted[orig_idx] = padj
|
||||||
|
reject[orig_idx] = padj <= a
|
||||||
|
else:
|
||||||
|
# Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores
|
||||||
|
# con la monotonicidad acumulada de derecha a izquierda.
|
||||||
|
order = sorted(valid, key=lambda t: t[1]) # [(orig_idx, p), ...] por p asc
|
||||||
|
q_sorted = [0.0] * m
|
||||||
|
prev = 1.0
|
||||||
|
for rank in range(m, 0, -1):
|
||||||
|
orig_idx, p = order[rank - 1]
|
||||||
|
val = p * m / rank
|
||||||
|
prev = min(prev, val)
|
||||||
|
q_sorted[rank - 1] = min(prev, 1.0)
|
||||||
|
for k in range(m):
|
||||||
|
orig_idx, _p = order[k]
|
||||||
|
q = q_sorted[k]
|
||||||
|
adjusted[orig_idx] = q
|
||||||
|
reject[orig_idx] = q <= a
|
||||||
|
|
||||||
|
n_rejected = sum(1 for r in reject if r)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": adjusted,
|
||||||
|
"reject": reject,
|
||||||
|
"n_tests": m,
|
||||||
|
"n_rejected": n_rejected,
|
||||||
|
"alpha": a,
|
||||||
|
"method": method_norm,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user