feat(ml): cierre del bucle de mejora comfyui-skill (genera→juzga→bump)

Tres funciones nuevas que cierran el lazo skill→generación→juicio→promoción
del grupo comfyui-skill (issue 0087):

- comfyui_bump_skill_version (impura): promueve una versión nueva SOLO si el
  score del panel-juez sube (gate objetivo). Snapshot versions/vN.json
  pre-mutación, deep-merge de recipe_patch, semver↑, línea en growth_log.jsonl.
  force=True salta el gate. No usa datetime.now().
- comfyui_update_skill_score (impura): media incremental de score_mean/score_n
  reescribiendo recipe.json in-place (sin snapshot ni growth_log).
- comfyui_generate_with_skill_oneshot (pipeline): one-shot load→build→submit→
  wait→fetch→judge→score_mean. recipe_patch prueba variantes sin guardar score.
  Compone 7 funciones del registry.

Tests offline: 11 passed (gate, semver, deep-merge, media incremental, errores).
Página madre docs/capabilities/comfyui-skill.md: +3 funciones, sección "Bucle de
mejora" con diagrama, fronteras de scoring actualizadas.

Demo real verificada: skill seed portrait_cinematic_sd15 (SD1.5) generó imagen
SFW real, el panel la juzgó, una variante puntuó más alto (4.787 > 4.7276) y el
gate promovió v1.0.0→v1.1.0 con el judge_run_id como evidencia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 15:09:33 +02:00
parent 974cc06bc7
commit bcf731275e
9 changed files with 1046 additions and 3 deletions
@@ -0,0 +1,224 @@
"""comfyui_bump_skill_version — promueve una nueva versión de una *skill* ComfyUI.
Cierra el bucle de mejora del grupo `comfyui-skill`: una skill genera, el panel
`comfyui-judge` la puntúa y, **solo si el score sube**, esta función promociona una
versión nueva de la receta. Es la pieza de "crecimiento por composición" del issue 0087
aplicada a la generación de imágenes — el catálogo de recetas crece registrando mejoras
medibles, no inflando recetas a ciegas.
Qué hace, en orden:
1. **Gate objetivo**: si ``score_after <= score_before`` y no se pasa ``force=True``,
rechaza con ``{ok: False}`` sin tocar nada. El juez (no el humano) decide.
2. **Snapshot pre-mutación**: escribe ``versions/vN.json`` con la receta ACTUAL antes de
cambiarla (backup recuperable con ``comfyui_load_skill(slug, version=N)``).
3. **Aplica ``recipe_patch``** (deep-merge) sobre ``recipe.json``.
4. **Sube el semver** de la receta (``minor`` por defecto: 1.0.0 → 1.1.0).
5. **Append a ``growth_log.jsonl``**: una línea
``{version, date, change, score_before, score_after, judge_run_id, diff}``.
`library_dir` por defecto ``~/ComfyUI/skills_library``. Slug inexistente → ``{ok: False}``.
Impura: lee y escribe archivos en disco. Sin red. No usa ``datetime.now()`` (prohibido en
algunos entornos) — la fecha se deriva del ``timestamp`` recibido o de ``time.time()``.
"""
import json
import os
import time
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
def _lib_dir(library_dir):
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
def _bump_semver(version: str, part: str) -> str:
"""Sube un semver ``X.Y.Z`` por ``major``/``minor``/``patch``.
Tolerante a versiones mal formadas: si ``version`` no parsea como tres enteros,
parte de ``0.0.0`` antes de aplicar el incremento.
"""
try:
nums = (list(map(int, str(version).split("."))) + [0, 0, 0])[:3]
major, minor, patch = nums
except (ValueError, TypeError):
major, minor, patch = 0, 0, 0
if part == "major":
return f"{major + 1}.0.0"
if part == "patch":
return f"{major}.{minor}.{patch + 1}"
# minor (default)
return f"{major}.{minor + 1}.0"
def _deep_merge(base: dict, patch: dict) -> dict:
"""Merge recursivo de ``patch`` sobre ``base`` sin mutar los originales.
Las claves cuyo valor es dict en ambos se fusionan en profundidad; cualquier otro
tipo (incluida una lista, p.ej. ``loras``/``blocks``) se reemplaza entero.
"""
out = dict(base)
for key, val in (patch or {}).items():
if isinstance(val, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], val)
else:
out[key] = val
return out
def _next_version_index(versions_dir: str) -> int:
"""Siguiente N para ``versions/vN.json`` (1 + cuántos snapshots ya hay)."""
try:
existing = [f for f in os.listdir(versions_dir)
if f.startswith("v") and f.endswith(".json")]
except OSError:
existing = []
return len(existing) + 1
def comfyui_bump_skill_version(
slug: str,
change: str,
*,
score_before: float,
score_after: float,
judge_run_id: str = None,
recipe_patch: dict = None,
force: bool = False,
bump: str = "minor",
library_dir: str = None,
timestamp: float = None,
) -> dict:
"""Promueve una versión nueva de una skill si el score mejora (gate objetivo).
Args:
slug: slug de la skill (su carpeta en la librería).
change: descripción de una línea del cambio que motiva el bump (va al growth_log).
score_before: score de la versión actual (del panel-juez). keyword-only.
score_after: score de la variante candidata. Debe ser ``> score_before`` salvo
``force=True``. keyword-only.
judge_run_id: identificador de la corrida del juez que justifica el bump
(evidencia trazable). keyword-only.
recipe_patch: dict con los cambios a aplicar sobre la receta (deep-merge). Por
ejemplo ``{"params": {"steps": 32}}``. keyword-only.
force: si True, salta el gate y promueve aunque el score no mejore. keyword-only.
bump: parte del semver a subir: ``minor`` (default), ``major`` o ``patch``.
keyword-only.
library_dir: raíz de la librería. Default ``~/ComfyUI/skills_library``. keyword-only.
timestamp: epoch en segundos para la fecha del growth_log; None = ``time.time()``.
keyword-only.
Returns:
dict ``{ok, slug, old_version, new_version, snapshot_file, growth_entry,
recipe_path, error}``. ``ok=False`` con ``error`` si el gate rechaza el bump, si
la skill no existe, o si falla la escritura; nunca lanza.
"""
if not slug or not isinstance(slug, str):
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
"error": "slug requerido (string no vacío)"}
# 1. Gate objetivo: el juez decide, no el humano.
try:
sb = float(score_before)
sa = float(score_after)
except (TypeError, ValueError):
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
"error": f"score_before/score_after deben ser numéricos "
f"(recibido {score_before!r}, {score_after!r})"}
if sa <= sb and not force:
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
"error": f"gate: score_after ({sa}) no supera score_before ({sb}); "
f"no se promueve (usa force=True para forzar)"}
lib = _lib_dir(library_dir)
skill_dir = os.path.join(lib, slug)
recipe_path = os.path.join(skill_dir, "recipe.json")
versions_dir = os.path.join(skill_dir, "versions")
if not os.path.isfile(recipe_path):
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
"error": f"skill no encontrada: {slug!r} (sin recipe.json en {skill_dir})"}
try:
with open(recipe_path, encoding="utf-8") as fh:
recipe = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
"error": f"no se pudo leer la receta: {exc}"}
old_version = recipe.get("version", "0.0.0")
new_version = _bump_semver(old_version, bump)
ts = float(timestamp) if timestamp is not None else time.time()
date = time.strftime("%Y-%m-%d", time.gmtime(ts))
try:
os.makedirs(versions_dir, exist_ok=True)
# 2. Snapshot pre-mutación: preserva la receta actual antes de cambiarla.
n = _next_version_index(versions_dir)
snapshot_file = os.path.join(versions_dir, f"v{n}.json")
with open(snapshot_file, "w", encoding="utf-8") as fh:
json.dump(recipe, fh, indent=2, ensure_ascii=False)
# 3. Aplicar el patch (deep-merge) + 4. subir el semver.
new_recipe = _deep_merge(recipe, recipe_patch or {})
new_recipe["version"] = new_version
with open(recipe_path, "w", encoding="utf-8") as fh:
json.dump(new_recipe, fh, indent=2, ensure_ascii=False)
# 5. Bitácora append-only del crecimiento.
growth_entry = {
"version": new_version,
"date": date,
"ts": int(ts),
"change": change,
"score_before": sb,
"score_after": sa,
"judge_run_id": judge_run_id or "",
"diff": recipe_patch or {},
"forced": bool(force and sa <= sb),
}
with open(os.path.join(skill_dir, "growth_log.jsonl"), "a", encoding="utf-8") as fh:
fh.write(json.dumps(growth_entry, ensure_ascii=False) + "\n")
except OSError as exc:
return {"ok": False, "slug": slug, "old_version": old_version, "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
"error": f"fallo de escritura: {exc}"}
return {"ok": True, "slug": slug, "old_version": old_version,
"new_version": new_version, "snapshot_file": snapshot_file,
"growth_entry": growth_entry, "recipe_path": recipe_path, "error": ""}
# Alias con el nombre completo del ID para descubrimiento por convención.
bump_skill_version = comfyui_bump_skill_version
if __name__ == "__main__":
import sys
# Demo offline contra una librería temporal.
demo_lib = "/tmp/skills_bump_demo"
sdir = os.path.join(demo_lib, "demo_skill")
os.makedirs(os.path.join(sdir, "versions"), exist_ok=True)
with open(os.path.join(sdir, "recipe.json"), "w", encoding="utf-8") as f:
json.dump({"schema_version": 1, "slug": "demo_skill", "version": "1.0.0",
"base_workflow": "txt2img", "params": {"steps": 28}}, f, indent=2)
# Gate bloquea cuando no mejora.
blocked = comfyui_bump_skill_version("demo_skill", "subir steps", score_before=7.0,
score_after=6.5, library_dir=demo_lib)
print("gate bloquea:", blocked["ok"], "->", blocked["error"], file=sys.stderr)
# Promueve cuando mejora.
ok = comfyui_bump_skill_version("demo_skill", "subir steps a 32", score_before=6.5,
score_after=7.4, judge_run_id="judge_abc",
recipe_patch={"params": {"steps": 32}},
library_dir=demo_lib)
print(json.dumps(ok, indent=2, ensure_ascii=False))