feat(ml): núcleo subsistema comfyui-skill + ask_llm_vision

Grupo nuevo comfyui-skill: recetas versionadas de generación ComfyUI que
compilan a un workflow cambiando solo el subject.

- comfyui_build_skill_workflow (pura): receta -> workflow API format,
  despacha base (txt2img/flux/sdxl_refiner), sustituye {subject}+triggers,
  encadena loras e inject blocks (facedetailer, hires_fix). SkillWorkflowError tipada.
- comfyui_inject_hires_fix (pura): inyecta 2ª pasada UltimateSDUpscale sobre dict.
- comfyui_save/load/list_skill (impuras): CRUD de la librería en disco con
  versionado por snapshots, round-trip idéntico, filtro NSFW.
- ask_llm_vision (core, claude-direct): pregunta multimodal imagen+texto via
  API directa Anthropic, para puntuar generaciones.
- Página madre docs/capabilities/comfyui-skill.md con schema canónico de recipe.json.

Tests offline: 11 verdes (6 builder + 5 inject_hires_fix). Sin GPU.
This commit is contained in:
agent
2026-06-24 14:35:46 +02:00
parent e8a66f0dad
commit 70d541fca9
15 changed files with 1666 additions and 0 deletions
+92
View File
@@ -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`.
+167
View File
@@ -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:]))