"""comfyui_generate_styles_llm — genera estilos WAS para ComfyUI con el LLM (grupo claude-direct). Pide al modelo (vía `ask_llm`, API directa de Anthropic, arranque 0) que produzca N estilos de una CATEGORÍA temática en el formato exacto del selector WAS: { "NombreEstilo": {"prompt": "modificadores de estilo", "negative_prompt": "..."}, ... } Cada `prompt` son MODIFICADORES de estilo (cámara, lente, iluminación, render, medio artístico, paleta, mood) — NO una descripción de sujeto y SIN el placeholder `{prompt}` (el selector múltiple concatena los prompts elegidos). Robusta por diseño: extrae el primer bloque JSON de la respuesta, lo valida entrada por entrada (descarta las que no tengan `prompt`, rellena `negative_prompt` por defecto si falta) y, ante CUALQUIER fallo (rate-limit 429, JSON corrupto, respuesta vacía), devuelve `{}` en vez de lanzar. Así el caller puede iterar varias categorías y seguir aunque una falle (error-path del DoD). Impura: llama a la API del LLM (red). No escribe disco (eso es trabajo de `comfyui_append_styles`). Pensada para componer: generar -> validar -> append. """ from __future__ import annotations import json import os import re import sys # Importa ask_llm del registry (grupo claude-direct). Path al paquete de funciones Python. sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) try: from core.ask_llm import ask_llm # type: ignore except Exception: # pragma: no cover - sólo si el registry no está en el path ask_llm = None # type: ignore DEFAULT_NEGATIVE = ( "ugly, deformed, noisy, blurry, low quality, distorted, disfigured, " "bad anatomy, watermark, signature, text, NSFW" ) _SYSTEM = ( "You are an expert prompt engineer for Stable Diffusion. You output ONLY valid JSON, " "no prose, no markdown fences. Styles must be SFW." ) _PROMPT_TMPL = """Generate exactly {n} distinct image STYLE presets for the category: "{category}". Output a single JSON object mapping a short unique style name to an object with keys "prompt" and "negative_prompt". Rules: - "prompt" = a comma-separated list of powerful STYLE modifiers ONLY (camera, lens, lighting, render engine, art medium, palette, texture, mood). NO subject description. Do NOT include the literal token {{prompt}}. - "negative_prompt" = comma-separated terms to avoid for this style. Keep it SFW. - Style names must be short, kebab-case, prefixed with "{prefix}", and must NOT repeat these existing names: {avoid}. - Output ONLY the JSON object, nothing else. Example of ONE entry: "{prefix}example": {{"prompt": "cinematic film still, anamorphic lens, teal and orange grade, dramatic key light", "negative_prompt": "lowres, blurry, deformed, watermark, text"}} """ def _extract_json_object(text: str) -> dict | None: """Extrae el primer objeto JSON {...} de un texto, tolerando fences de markdown.""" if not text: return None # Quitar fences ```json ... ``` si los hay. fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL) candidate = fenced.group(1) if fenced else None if candidate is None: # Buscar desde la primera { hasta la última } (greedy) — robusto a prosa alrededor. start = text.find("{") end = text.rfind("}") if start == -1 or end == -1 or end <= start: return None candidate = text[start : end + 1] try: obj = json.loads(candidate) except (json.JSONDecodeError, ValueError): return None return obj if isinstance(obj, dict) else None def _validate(obj: dict, avoid: set[str]) -> dict: """Filtra el dict del LLM a entradas válidas WAS, descartando duplicados y vacíos.""" clean: dict[str, dict[str, str]] = {} for name, v in obj.items(): if not isinstance(name, str) or not name.strip(): continue if name in avoid: continue if not isinstance(v, dict): continue prompt = v.get("prompt") if not isinstance(prompt, str) or not prompt.strip(): continue if "{prompt}" in prompt: prompt = prompt.replace("{prompt}", "").strip().strip(",").strip() if not prompt: continue neg = v.get("negative_prompt") if not isinstance(neg, str) or not neg.strip(): neg = DEFAULT_NEGATIVE clean[name.strip()] = {"prompt": prompt.strip(), "negative_prompt": neg.strip()} return clean def comfyui_generate_styles_llm( category: str, n: int = 8, prefix: str = "", avoid: list[str] | None = None, model: str = "claude-haiku-4-5-20251001", ) -> dict: """Genera estilos WAS de una categoría con el LLM. Devuelve {} ante cualquier fallo. Args: category: tema de los estilos (ej. "vaporwave aesthetics", "baroque painting", "macro insect photography"). Texto libre, dirige el contenido. n: número de estilos a pedir (el modelo puede devolver menos; se validan todos). prefix: prefijo para los nombres generados (ej. "vapor-"), para agrupar y reducir colisiones con estilos existentes. Si vacío, los nombres van sin prefijo. avoid: lista de nombres ya existentes que el modelo NO debe repetir (dedup previo). model: id del modelo Anthropic. Default haiku (más cuota, rápido). Otros: claude-opus-4-8, claude-sonnet-4-6. Returns: dict {nombre: {"prompt", "negative_prompt"}} ya validado (prompt no vacío, sin `{prompt}`, negative rellenado). Vacío `{}` si el LLM falla, la respuesta no trae JSON válido, o no hay entradas utilizables. NUNCA lanza: el error-path es devolver {}. """ if ask_llm is None: return {} avoid_set = set(avoid or []) avoid_str = ", ".join(sorted(avoid_set)[:40]) if avoid_set else "(none)" prompt = _PROMPT_TMPL.format( n=int(n), category=category, prefix=prefix, avoid=avoid_str ) try: raw = ask_llm(prompt, model=model, system=_SYSTEM, max_tokens=4096, echo=False) except Exception: return {} obj = _extract_json_object(raw or "") if obj is None: return {} return _validate(obj, avoid_set) if __name__ == "__main__": import json as _json cat = sys.argv[1] if len(sys.argv) > 1 else "synthwave retro aesthetics" n = int(sys.argv[2]) if len(sys.argv) > 2 else 6 pref = sys.argv[3] if len(sys.argv) > 3 else "synth-" out = comfyui_generate_styles_llm(cat, n=n, prefix=pref) print(_json.dumps(out, ensure_ascii=False, indent=2)) print("GENERADOS:", len(out), file=sys.stderr)