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:
@@ -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:]))
|
||||
Reference in New Issue
Block a user