"""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))