"""Fase 6 -- Puerta 5: calidad de diseno en Penpot, medida contra el MCP en vivo. A diferencia de las puertas 2-4, esta necesita un LOOP DE AGENTE REAL: modelo -> tool call -> MCP de Penpot en vivo -> tool result -> modelo, hasta GATE5_MAX_TURNS turnos por prompt. La calidad de diseno no existe hasta que el codigo se ejecuta: un payload de `execute_code` perfectamente plausible puede dejar ocho rectangulos grises, y eso solo se ve en el arbol. GATE5_BASE_URL=http://10.212.133.200:8004/v1 \ GATE5_MODEL=qwen3.6-35b-a3b-mcp-v2-nvfp4 \ PENPOT_MCP_URL=... PENPOT_MCP_TOKEN=... GATE5_TAG=v2-nvfp4 \ python3 scripts/35_gate5_penpot_design.py Modo barato (disparador de fallback, corre ANTES de gastar un minuto en cuantizacion): GATE5_HOLDOUT=1 GATE5_BASE_URL=... GATE5_MODEL=... GATE5_TAG=v2-bf16 \ python3 scripts/35_gate5_penpot_design.py En modo holdout NO se toca el MCP: se genera una sola respuesta por prompt de data/holdout_penpot_design.jsonl (~60) y se hace el grep estatico sobre los payloads de `code` que produjo, sin ejecutarlos. Umbral: tasa de API prohibida <= 5% (<=3/60). DOS ENDPOINTS, LOS DOS POR ENV, SIN DEFAULTS Y SIN IMPRIMIRSE NUNCA ------------------------------------------------------------------ - El modelo: API OpenAI-compatible de vLLM (GATE5_BASE_URL, GATE5_MODEL). - El MCP de Penpot: servidor HTTP MCP (streamable HTTP / JSON-RPC 2.0) en PENPOT_MCP_URL con header `Authorization: Bearer $PENPOT_MCP_TOKEN`. Ni la URL ni el token se imprimen jamas -- la URL suele llevar el token embebido en el path. PRECONDICION HUMANA: el plugin de Penpot tiene que estar abierto en el navegador y conectado al archivo. Si no lo esta, el servidor responde "No Penpot plugin instances are currently connected..." y la puerta ABORTA DE ENTRADA con exit code 2, antes de quemar los 8 prompts. LA PUERTA NUNCA BORRA NADA. Crea una pagina fresca por prompt (`gate5///`) y la deja para inspeccion humana, con su PNG exportado. LAS 17 METRICAS las computa un payload de auditoria que inyecta LA PUERTA (nunca el modelo) sobre el arbol resultante. Ese payload respeta la misma API verificada que se le exige al modelo -- se autochequea al arrancar contra los patrones prohibidos de scripts/07_lint_penpot_code.py, que es la unica fuente de verdad de esos regex. `placeholderGreys` y `forbiddenBehavior` son METRICAS DE VETO: cualquier violacion -> el prompt saca 0. score = 100 * pasadas / aplicables. Aprobacion: score medio >= 65, veto limpio en 8/8, >=6/8 prompts >= 60, y la landing de pizzeria >= 60. Exit 0 si aprueba, 1 si no, 2 si el plugin de Penpot no esta conectado. """ import base64 import importlib.util import json import os import re import sys import time import unicodedata from collections import Counter from datetime import datetime, timezone from pathlib import Path import requests REPO_ROOT = Path(__file__).resolve().parent.parent SCHEMAS_DIR = REPO_ROOT / "data" / "schemas" # GATE5_PROMPTS_PATH (Fase 6.4.25b): permite apuntar el mismo harness (17 metricas, veto, # manejo de paginas/reintentos MCP) a un archivo de prompts distinto de los 10 oficiales de # la puerta 5 -- p.ej. la sonda de generalizacion de rubros nunca vistos. La puerta 5 oficial # (data/gate5_prompts.jsonl) queda intacta: sin la env var, el comportamiento es identico. PROMPTS_PATH = Path(os.environ["GATE5_PROMPTS_PATH"]) if os.environ.get("GATE5_PROMPTS_PATH") \ else REPO_ROOT / "data" / "gate5_prompts.jsonl" HOLDOUT_PATH = REPO_ROOT / "data" / "holdout_penpot_design.jsonl" PENPOT_TOOLS_PATH = SCHEMAS_DIR / "penpot.json" SYSTEM_PROMPT_PATH = SCHEMAS_DIR / "penpot_system_prompt.md" LINT_PATH = REPO_ROOT / "scripts" / "07_lint_penpot_code.py" # Corpus contra el que los 8 prompts tienen que ser disjuntos (shingles de 6-gramas). DISJOINT_AGAINST = [ REPO_ROOT / "data" / "train.jsonl", REPO_ROOT / "data" / "eval.jsonl", REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl", ] SHINGLE_N = 6 # Rubros que usan los prompts de la puerta. Ningun seed puede compartirlos. # # Los shingles de 6-gramas NO atrapan esto: "necesito la home de una escuela de musica para # chicos" (puerta) y "estoy redisenando el sitio de la escuela de musica" (seed) no comparten # ningun 6-grama literal y sin embargo son el MISMO negocio. Entrenar sobre el dominio que # despues se evalua infla el resultado sin que se note en ninguna metrica -- es la misma fuga # que la de las palabras, un nivel mas arriba. Los sinonimos y las parafrasis del mismo rubro # solo se atrapan nombrando el rubro. RUBROS_DE_LA_PUERTA = [ "pizzeria", "pizzería", "escuela de musica", "escuela de música", "notaria", "notaría", "trattoria", ] # Reintento de transporte contra el MCP de Penpot. El servidor corta la conexion a mitad de # llamada de forma intermitente; ya se habia visto en la puerta 4 de la Fase 4. MCP_MAX_RETRIES = int(os.environ.get("PENPOT_MCP_RETRIES", "4")) MCP_RETRY_BACKOFF = float(os.environ.get("PENPOT_MCP_RETRY_BACKOFF", "3")) MAX_TURNS = int(os.environ.get("GATE5_MAX_TURNS", "14")) MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096")) HTTP_TIMEOUT = int(os.environ.get("GATE5_HTTP_TIMEOUT", "600")) MCP_TIMEOUT = int(os.environ.get("GATE5_MCP_TIMEOUT", "240")) # Fallos que vuelven como respuesta HTTP EXITOSA con texto de error adentro. El limite de 30 # segundos NO es del cliente: es el del servidor MCP para una tarea del plugin, asi que no se # puede subir desde aca -- solo reintentar. Y hay que reintentarlos explicitamente porque, al no # ser errores de transporte, el reintento de `_request` no los ve: la request fue un exito. # La auditoria es la llamada mas pesada de la puerta y es la que se cayo asi en el prompt # critico de la fase. REINTENTABLES_EN_RESULTADO = re.compile( r"timed out after \d+ seconds|Tool execution failed: Error: Task ", re.I) AUDIT_MAX_RETRIES = int(os.environ.get("GATE5_AUDIT_RETRIES", "5")) # Vaciar la pagina de la puerta despues de medirla y exportar su PNG. Se puede apagar para # inspeccion manual con GATE5_KEEP_PAGES=1. LIMPIAR_PAGINAS = os.environ.get("GATE5_KEEP_PAGES", "").lower() not in ("1", "true", "yes") # Tamano de lote. Ver el comentario del bucle en run_full(): el plugin se degrada tras 5 o 6 # prompts pesados, asi que 4 deja margen y da un punto de re-handshake antes del deterioro. BATCH_SIZE = int(os.environ.get("GATE5_BATCH_SIZE", "4")) # CORRIDA UNICA. El MCP de Penpot se cae seguido y cada ventana en que funciona es cara, asi que # la corrida tiene que medir los 10 prompts de una y no rendirse por el fallo de uno. # - Un prompt que falla NO aborta la corrida: se anota y se reintenta en una pasada posterior. # - Tope por prompt, para que un cuelgue no coma la ventana entera. # - Tope de pared, para que la corrida termine si o si y deje resultados. MAX_PASADAS = int(os.environ.get("GATE5_MAX_PASSES", "3")) PROMPT_TIMEOUT_S = int(os.environ.get("GATE5_PROMPT_TIMEOUT_S", "240")) WALL_CLOCK_S = int(os.environ.get("GATE5_WALL_CLOCK_MIN", "45")) * 60 # El prompt que reproduce el fallo exacto de produccion. Tiene su propia condicion de # aprobacion: si este no llega a 60, la puerta no aprueba aunque el promedio alcance. FLAGSHIP_PROMPT_ID = "g5-06-landing-pizzeria" MEAN_SCORE_MIN = 65.0 PROMPT_SCORE_MIN = 60.0 MIN_PROMPTS_OVER_MIN = 6 HOLDOUT_FORBIDDEN_RATE_MAX = 0.05 HEX_EN_TEXTO = re.compile(r"#[0-9a-fA-F]{6}\b") PLUGIN_NOT_CONNECTED = "No Penpot plugin instances are currently connected" EXIT_PLUGIN_NOT_CONNECTED = 2 # El plugin respondio pero se degrado a mitad de corrida (timeouts en createPage). No es lo mismo # que "no esta conectado": hay que pedirle al usuario que lo RECARGUE y retomar los que faltan. EXIT_PLUGIN_DEGRADADO = 3 class Gate5Error(RuntimeError): """Fallo de la puerta que no es un fallo del modelo (env, transporte, MCP).""" class PluginNotConnected(Gate5Error): """El plugin de Penpot no esta abierto/conectado: no tiene sentido seguir.""" class PromptTimeout(Gate5Error): """Se agoto el tope de tiempo de ESTE prompt. No dice nada del resto de la corrida.""" # ------------------------------------------------------------------------------------------ # Patrones prohibidos: se IMPORTAN del lint, nunca se duplican # ------------------------------------------------------------------------------------------ def load_lint_module(): """Importa scripts/07_lint_penpot_code.py (el nombre empieza con digito, no es importable con `import`). La fuente de verdad de los regex prohibidos tiene que ser una sola.""" spec = importlib.util.spec_from_file_location("lint_penpot_code", LINT_PATH) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module LINT = load_lint_module() # El subconjunto de FORBIDDEN que constituye "comportamiento prohibido" a efectos de VETO, # identificado por el nombre exacto con el que el lint lo declara. El resto de los patrones del # lint se sigue registrando en el JSON de resultados (columna `otros_patrones`) porque es # informacion util, pero no veta: la lista de veto es la del PLAN, ni mas ni menos. VETO_PATTERN_NAMES = { "findShapeById con 2 argumentos", "propiedad .layout inexistente", "flex.appendChild", "fontSize/fontWeight/lineHeight/letterSpacing numerico", "import_image / importImage / createImage / filePath", } # Argumentos que el schema de export_shape NO declara. Inventarlos es el mismo comportamiento # prohibido, solo que del lado de los argumentos de la tool y no del texto del `code`. EXPORT_SHAPE_ALLOWED_ARGS = {"shapeId", "format", "mode"} def scan_forbidden(code): """Devuelve (veto_hits, otros_hits) sobre un payload de `code`.""" veto, otros = [], [] for pname, rx, _why in LINT.FORBIDDEN: match = rx.search(code) if not match: continue frag = code[max(0, match.start() - 40):match.end() + 40].replace("\n", " ") hit = {"patron": pname, "fragmento": frag} (veto if pname in VETO_PATTERN_NAMES else otros).append(hit) return veto, otros # ------------------------------------------------------------------------------------------ # Entorno # ------------------------------------------------------------------------------------------ def require_env(name, why): value = os.environ.get(name, "").strip() if not value: raise Gate5Error(f"falta la variable de entorno {name} ({why})") return value def load_env(need_mcp): """Lee la config. Nunca devuelve nada que se pueda imprimir sin pensar: la URL del MCP lleva el token embebido en varios deployments.""" faltantes = [] cfg = {} for name, why in (("GATE5_BASE_URL", "endpoint OpenAI-compatible de vLLM, ej. http://HOST:PUERTO/v1"), ("GATE5_MODEL", "nombre del modelo servido por vLLM")): try: cfg[name] = require_env(name, why) except Gate5Error as e: faltantes.append(str(e)) if need_mcp: for name, why in (("PENPOT_MCP_URL", "endpoint HTTP MCP del servidor de Penpot"), ("PENPOT_MCP_TOKEN", "token bearer del servidor MCP de Penpot")): try: cfg[name] = require_env(name, why) except Gate5Error as e: faltantes.append(str(e)) if faltantes: raise Gate5Error("configuracion incompleta:\n - " + "\n - ".join(faltantes)) cfg["GATE5_TAG"] = os.environ.get("GATE5_TAG", "").strip() or "sin-tag" return cfg # ------------------------------------------------------------------------------------------ # Cliente MCP sobre HTTP (streamable HTTP / JSON-RPC 2.0) # ------------------------------------------------------------------------------------------ class PenpotMCP: """Cliente minimo de MCP sobre HTTP. Handshake: initialize -> notifications/initialized -> tools/list -> tools/call. La respuesta puede llegar como `application/json` o como `text/event-stream` (lineas `data: {...}`); las dos se manejan. El `Mcp-Session-Id` que devuelve el initialize se guarda y se reusa en todas las llamadas siguientes. """ PROTOCOL_VERSION = "2025-06-18" def __init__(self, url, token, timeout=MCP_TIMEOUT): self._url = url self._token = token self.timeout = timeout self.session_id = None self.tool_names = [] self._next_id = 0 self._in_handshake = False self._http = requests.Session() # -- transporte ------------------------------------------------------------------- def _headers(self): headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": f"Bearer {self._token}", "MCP-Protocol-Version": self.PROTOCOL_VERSION, } if self.session_id: headers["Mcp-Session-Id"] = self.session_id return headers @staticmethod def _parse_body(response): """Devuelve la lista de mensajes JSON-RPC del cuerpo, venga como SSE o como JSON.""" ctype = (response.headers.get("Content-Type") or "").lower() text = response.text or "" if "text/event-stream" in ctype: mensajes = [] for line in text.splitlines(): line = line.strip() if not line.startswith("data:"): continue payload = line[len("data:"):].strip() if not payload or payload == "[DONE]": continue try: mensajes.append(json.loads(payload)) except json.JSONDecodeError: continue return mensajes if not text.strip(): return [] try: parsed = json.loads(text) except json.JSONDecodeError as e: raise Gate5Error(f"el servidor MCP devolvio un cuerpo no-JSON ({e})") return parsed if isinstance(parsed, list) else [parsed] def _post(self, payload, expect_response=True): try: response = self._http.post( self._url, headers=self._headers(), json=payload, timeout=self.timeout ) except requests.RequestException as e: # El mensaje de requests incluye la URL -> se descarta a proposito. raise Gate5Error(f"error de transporte contra el MCP de Penpot: {type(e).__name__}") nueva_sesion = response.headers.get("Mcp-Session-Id") or response.headers.get("mcp-session-id") if nueva_sesion: self.session_id = nueva_sesion if response.status_code >= 400: cuerpo = (response.text or "")[:300] if PLUGIN_NOT_CONNECTED in cuerpo: raise PluginNotConnected(cuerpo.strip()) raise Gate5Error(f"el MCP de Penpot respondio HTTP {response.status_code}: {cuerpo}") if not expect_response: return None mensajes = self._parse_body(response) for msg in mensajes: if msg.get("id") != payload.get("id"): continue if "error" in msg: detalle = json.dumps(msg["error"], ensure_ascii=False)[:400] if PLUGIN_NOT_CONNECTED in detalle: raise PluginNotConnected(detalle) raise Gate5Error(f"el MCP de Penpot devolvio un error JSON-RPC: {detalle}") return msg.get("result", {}) raise Gate5Error(f"el MCP de Penpot no devolvio respuesta para el metodo {payload.get('method')}") def _request(self, method, params=None): """Envia un request JSON-RPC, reintentando los fallos de TRANSPORTE. El MCP de Penpot corta la conexion a mitad de llamada de forma intermitente ("transport dropped mid-call"); ya se habia visto en la puerta 4 de la Fase 4. Sin reintento, un corte transitorio en el prompt 6 de 8 tira abajo una corrida entera de la puerta, y peor: se diagnostica como un fallo del modelo cuando es de la red. Se reintenta SOLO `Gate5Error` de transporte/protocolo. `PluginNotConnected` no se reintenta -- ese es un estado real del mundo (el usuario no tiene el plugin abierto) y reintentarlo solo demora el mensaje que hay que dar. """ ultimo = None for intento in range(1, MCP_MAX_RETRIES + 1): self._next_id += 1 payload = {"jsonrpc": "2.0", "id": self._next_id, "method": method} if params is not None: payload["params"] = params try: return self._post(payload) except PluginNotConnected: raise except Gate5Error as e: ultimo = e if intento == MCP_MAX_RETRIES: break espera = MCP_RETRY_BACKOFF * (2 ** (intento - 1)) print(f"[MCP] fallo transitorio en '{method}' (intento {intento}/" f"{MCP_MAX_RETRIES}): {e}. Reintento en {espera:.0f}s") time.sleep(espera) # Un ConnectionError no solo tira la request: puede tirar la SESION. Reintentar # el mismo tools/call contra una sesion muerta falla siempre igual, que es lo # que se vio en la primera corrida (4 intentos identicos, 4 ConnectionError). # Hay que rehacer el handshake antes de reintentar. if not self._in_handshake and method != "initialize": try: print("[MCP] rehaciendo el handshake (la sesion pudo haber muerto)") self.session_id = None self.handshake() except Gate5Error as e2: print(f"[MCP] el re-handshake tambien fallo: {e2}") raise Gate5Error(f"el MCP de Penpot fallo {MCP_MAX_RETRIES} veces en '{method}': {ultimo}") def _notify(self, method, params=None): payload = {"jsonrpc": "2.0", "method": method} if params is not None: payload["params"] = params self._post(payload, expect_response=False) # -- protocolo -------------------------------------------------------------------- def handshake(self): self._in_handshake = True try: return self._handshake_inner() finally: self._in_handshake = False def _handshake_inner(self): result = self._request("initialize", { "protocolVersion": self.PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "gate5-penpot-design", "version": "1.0"}, }) self._notify("notifications/initialized") listado = self._request("tools/list") or {} self.tool_names = [t.get("name") for t in listado.get("tools", [])] server = (result.get("serverInfo") or {}).get("name", "?") print(f"[MCP] handshake ok con '{server}'; tools: {', '.join(self.tool_names)}") return result @staticmethod def _flatten_content(result): """Aplana `content` de un tool result MCP a (texto, lista_de_binarios_base64).""" partes, binarios = [], [] for item in (result or {}).get("content", []) or []: tipo = item.get("type") if tipo == "text": partes.append(item.get("text") or "") elif tipo in ("image", "audio") and item.get("data"): binarios.append(item["data"]) partes.append(f"[{tipo}/{item.get('mimeType', '?')}, {len(item['data'])} bytes base64]") elif tipo == "resource": recurso = item.get("resource") or {} if recurso.get("text"): partes.append(recurso["text"]) elif recurso.get("blob"): binarios.append(recurso["blob"]) return "\n".join(partes).strip(), binarios def call_tool(self, name, arguments): """Devuelve (texto, binarios, es_error). Aborta si el plugin no esta conectado.""" result = self._request("tools/call", {"name": name, "arguments": arguments}) or {} texto, binarios = self._flatten_content(result) if PLUGIN_NOT_CONNECTED in texto: raise PluginNotConnected(texto) return texto, binarios, bool(result.get("isError")) def execute_code(self, code): texto, _binarios, es_error = self.call_tool("execute_code", {"code": code}) return texto, es_error def execute_json(self, code, que): """execute_code cuyo resultado la puerta necesita parsear como JSON. El servidor MCP no devuelve el `return` del payload pelado: lo envuelve en `{"result": , "log": ""}`. La puerta necesita el interior, asi que se desenvuelve ACA, en un solo lugar. Ojo con no desenvolver de mas: si un payload de la puerta retornara a proposito un objeto con una clave `result` propia, desenvolver a ciegas lo destruiria. Por eso se exige la forma exacta del envoltorio (`result` presente y ninguna clave fuera de {result, log}). `execute_code` (el metodo de abajo) NO desenvuelve: lo que ve el modelo como tool result tiene que ser byte a byte lo que le llegaria en produccion, envoltorio incluido. """ texto = es_error = None for intento in range(1, AUDIT_MAX_RETRIES + 1): texto, es_error = self.execute_code(code) if not REINTENTABLES_EN_RESULTADO.search(texto or ""): break if intento == AUDIT_MAX_RETRIES: break espera = MCP_RETRY_BACKOFF * intento print(f"[MCP] '{que}' fallo con timeout de tarea del servidor " f"(intento {intento}/{AUDIT_MAX_RETRIES}). Reintento en {espera:.0f}s") time.sleep(espera) try: parsed = json.loads(texto) except json.JSONDecodeError: raise Gate5Error( f"{que}: el MCP no devolvio JSON ({'error' if es_error else 'texto'}): {texto[:300]}" ) if (isinstance(parsed, dict) and "result" in parsed and set(parsed).issubset({"result", "log"})): log = (parsed.get("log") or "").strip() if log: print(f"[MCP] console del payload de la puerta ({que}): {log[:300]}") return parsed["result"] return parsed # ------------------------------------------------------------------------------------------ # Payloads de la puerta (nunca los escribe el modelo) # ------------------------------------------------------------------------------------------ def js_string(value): """Literal JS seguro a partir de un valor Python (via JSON, que es un subconjunto).""" return json.dumps(value, ensure_ascii=False) SETUP_JS = """ const page = penpot.createPage(); page.name = %PAGE_NAME%; penpot.openPage(page); const seed = %SEED%; let seedBoardId = null; if (seed) { const board = penpot.createBoard(); board.name = seed.nombre; board.x = seed.x; board.y = seed.y; board.resize(seed.ancho, seed.alto); board.fills = [{ fillColor: "#FFFFFF", fillOpacity: 1 }]; for (const spec of seed.rectangulos) { const rect = penpot.createRectangle(); rect.name = spec.nombre; rect.x = seed.x + spec.x; rect.y = seed.y + spec.y; rect.resize(spec.ancho, spec.alto); rect.fills = [{ fillColor: seed.gris, fillOpacity: 1 }]; board.insertChild(board.children.length, rect); } seedBoardId = board.id; } return { pageId: page.id, pageName: page.name, // NO se devuelve penpot.root.id. `penpot.root` es el root de la pagina ACTIVA, y despues de // `createPage` + `openPage` puede no haberse actualizado todavia: en la primera corrida con // borrado de paginas activado devolvio "" en el segundo prompt. Ademas todas las paginas // comparten el mismo id de root, asi que ese valor nunca sirvio para identificar nada. El // pageId es lo unico unico y estable, y es lo que usa la auditoria. seedBoardId: seedBoardId }; """ # Payload de auditoria. Lo inyecta la puerta, asi que tiene que respetar la misma API verificada # que se le exige al modelo: findShapeById de 1 argumento, board.flex/board.grid (nunca .layout), # includeChildren (nunca withChildren), sin asignaciones a propiedades read-only. DELETE_PAGE_JS = """ // Borra la pagina que creo LA PUERTA, nunca una del usuario: se exige que el nombre empiece con // el prefijo gate5/ y se compara el id, asi que no hay forma de que apunte a otra cosa. const PAGE_ID = %PAGE_ID%; const page = penpotUtils.getPageById(PAGE_ID); if (!page) { return { borrada: false, motivo: "no existe" }; } if (String(page.name).indexOf("gate5/") !== 0) { return { borrada: false, motivo: "no es una pagina de la puerta: " + page.name }; } const nombre = page.name; const shapes = penpotUtils.findShapes(function () { return true; }, page.root); for (let i = shapes.length - 1; i >= 0; i--) { shapes[i].remove(); } return { borrada: true, nombre: nombre, shapesBorrados: shapes.length }; """ AUDIT_JS = """ // El root se resuelve por PAGE_ID, nunca por rootId. // Por que: en Penpot TODAS las paginas nuevas comparten el mismo id de root frame, // "00000000-0000-0000-0000-000000000000". Y `penpotUtils.findShapeById` busca GLOBALMENTE, asi // que buscar por ese id devuelve el root de la PRIMERA pagina del archivo -- la del usuario, // vacia -- y no la que la puerta acaba de crear. Esa fue la causa de que el primer baseline // midiera shapeCount=0 en los 10 prompts mientras el archivo real tenia 28 shapes y 20 textos. // El pageId si es unico. const PAGE_ID = %PAGE_ID%; const page = penpotUtils.getPageById(PAGE_ID); if (!page) { return { auditError: "pagina no encontrada: " + PAGE_ID }; } const root = page.root; if (!root) { return { auditError: "la pagina no tiene root" }; } const nodes = []; const collect = (shape, depth) => { nodes.push({ shape: shape, depth: depth }); const kids = shape.children || []; for (let i = 0; i < kids.length; i++) { collect(kids[i], depth + 1); } }; const top = root.children || []; for (let i = 0; i < top.length; i++) { collect(top[i], 1); } const PUROS = ["#FFFFFF", "#000000"]; const esGrisPlaceholder = (hex) => { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); const mx = Math.max(r, g, b); const mn = Math.min(r, g, b); const sat = mx === 0 ? 0 : (100 * (mx - mn)) / mx; return sat <= 10 && mx >= 100 && mx <= 220; }; const hexes = []; const grises = []; let gradientes = 0, sombras = 0, radios = 0; let textos = 0, textosConChars = 0, textosConFill = 0, textosConFont = 0; const fontSizes = []; let boards = 0, boardsConLayout = 0, hijosDeLayout = 0, hijosDimensionados = 0; let profundidad = 0, secciones = 0; for (const nodo of nodes) { const s = nodo.shape; if (nodo.depth > profundidad) { profundidad = nodo.depth; } let fills = null; try { fills = s.fills; } catch (e) { fills = null; } const locales = []; if (Array.isArray(fills)) { for (const f of fills) { if (f && typeof f.fillColor === "string") { locales.push(f.fillColor.toUpperCase()); } const grad = f && f.fillColorGradient; if (grad) { gradientes++; for (const stop of (grad.stops || [])) { if (stop && typeof stop.color === "string") { locales.push(stop.color.toUpperCase()); } } } } } for (const hex of locales) { hexes.push(hex); if (esGrisPlaceholder(hex)) { grises.push({ id: s.id, name: s.name, hex: hex }); } } try { if (Array.isArray(s.shadows) && s.shadows.length > 0) { sombras++; } } catch (e) {} try { if (typeof s.borderRadius === "number" && s.borderRadius > 0) { radios++; } } catch (e) {} if (s.type === "text") { textos++; const chars = (s.characters || "").trim(); if (chars.length > 0) { textosConChars++; } if (Array.isArray(fills) && fills.length > 0) { textosConFill++; } if (s.fontId && String(s.fontId).length > 0) { textosConFont++; } const tam = parseFloat(s.fontSize); if (!isNaN(tam)) { fontSizes.push(tam); } } if (s.type === "board") { boards++; const conLayout = !!(s.flex || s.grid); if (conLayout) { boardsConLayout++; for (const kid of (s.children || [])) { hijosDeLayout++; let lc = null; try { lc = kid.layoutChild; } catch (e) { lc = null; } if (lc && lc.horizontalSizing) { hijosDimensionados++; } } } if (nodo.depth <= 2 && (s.children || []).length >= 2) { secciones++; } } } const distintos = []; for (const hex of hexes) { if (PUROS.indexOf(hex) === -1 && distintos.indexOf(hex) === -1) { distintos.push(hex); } } let contencion = []; let contencionError = null; try { const analisis = penpotUtils.analyzeDescendants(root, (raiz, shape) => { const padre = shape.parent; if (!padre || padre.id === raiz.id) { return null; } return penpotUtils.isContainedIn(shape, padre) ? null : { id: shape.id, name: shape.name, parent: padre.name }; }, 12) || []; for (const entrada of analisis) { if (entrada && entrada.result) { contencion.push(entrada.result); } } } catch (e) { contencionError = String((e && e.message) || e); } let principal = null; for (const s of top) { if (s.type !== "board") { continue; } if (!principal || s.width * s.height > principal.width * principal.height) { principal = s; } } if (!principal && top.length > 0) { principal = top[0]; } let desajusteAltura = null; if (principal) { let fondo = null; for (const kid of (principal.children || [])) { const limite = kid.y + kid.height; if (fondo === null || limite > fondo) { fondo = limite; } } if (fondo !== null) { desajusteAltura = Math.round(Math.abs(principal.y + principal.height - fondo)); } } // generateStyle con includeChildren y generateMarkup son, de lejos, lo mas caro de la auditoria: // serializan el subarbol entero. El servidor MCP corta cualquier tarea del plugin a los 30 // segundos -- ese limite es SUYO, no del cliente, asi que no se puede subir desde aca; lo unico // que se puede hacer es no acercarse. Por encima de MAX_NODOS_RENDER se saltea y se deja // constancia, en vez de arriesgar que se caiga la auditoria ENTERA y con ella la medicion del // prompt. `rendersOk` queda en null (no aplica) y no entra al denominador del score. const MAX_NODOS_RENDER = 400; let cssLen = 0, markupLen = 0, renderError = null, renderSalteado = false; if (nodes.length > MAX_NODOS_RENDER) { renderSalteado = true; } else { try { const objetivo = principal ? [principal] : top; if (objetivo.length > 0) { const css = penpot.generateStyle(objetivo, { type: "css", includeChildren: true }) || ""; const markup = penpot.generateMarkup(objetivo, { type: "html" }) || ""; cssLen = String(css).length; markupLen = String(markup).length; } } catch (e) { renderError = String((e && e.message) || e); } } const tamsUnicos = []; for (const tam of fontSizes) { if (tamsUnicos.indexOf(tam) === -1) { tamsUnicos.push(tam); } } // VIBRACION. Ni `distinctFillColors` ni `placeholderGreys` distinguen una paleta vibrante de // una apagada pero tecnicamente no gris: seis tonos polvorientos pasan las dos. Esto mide // saturacion de verdad, en HSL. function aHsl(hex) { const r = parseInt(hex.slice(1, 3), 16) / 255; const g = parseInt(hex.slice(3, 5), 16) / 255; const b = parseInt(hex.slice(5, 7), 16) / 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; const l = (mx + mn) / 2; let h = 0, sat = 0; if (d !== 0) { sat = d / (1 - Math.abs(2 * l - 1)); if (mx === r) { h = ((g - b) / d) % 6; } else if (mx === g) { h = (b - r) / d + 2; } else { h = (r - g) / d + 4; } h = h * 60; if (h < 0) { h += 360; } } return { h: h, s: sat * 100, l: l * 100 }; } // FILL EXPLICITO. Los tres defaults de Penpot: board blanco, rectangulo #B1B2B5, texto negro. // Un diseno cuyos UNICOS colores son esos tres no es un diseno: son shapes que nunca recibieron // fill. Esta metrica sola habria diagnosticado el bug de produccion, que las de "colores // distintos" y "cero grises" no ven -- #FFFFFF y #000000 no son gris medio, asi que pasan. const DEFAULTS = ["#FFFFFF", "#B1B2B5", "#000000"]; let conFill = 0, conFillExplicito = 0; for (const n of nodes) { const fs = Array.isArray(n.shape.fills) ? n.shape.fills : []; const hexes = fs.filter(function (f) { return f && typeof f.fillColor === "string"; }) .map(function (f) { return f.fillColor.toUpperCase(); }); if (!hexes.length) { continue; } conFill++; if (hexes.some(function (h) { return DEFAULTS.indexOf(h) === -1; })) { conFillExplicito++; } } const soloDefaults = distintos.length > 0 && distintos.every(function (h) { return DEFAULTS.indexOf(h) !== -1; }); const cromaticos = []; const neutrales = []; for (const hex of distintos) { const c = aHsl(hex); // Cromatico: saturacion >= 45 y luminosidad en 15..85. La banda de luminosidad excluye los // casi-negros y casi-blancos, que pueden dar saturacion alta y sin embargo leerse neutros. if (c.s >= 45 && c.l >= 15 && c.l <= 85) { cromaticos.push({ hex: hex, h: c.h, s: c.s, l: c.l }); } else if (c.s <= 15) { neutrales.push(hex); } } // Estructura de paleta: una marca dominante, un acento con tono claramente distinto, y neutrales. let separacionDeTono = 0; for (let i = 0; i < cromaticos.length; i++) { for (let j = i + 1; j < cromaticos.length; j++) { let d = Math.abs(cromaticos[i].h - cromaticos[j].h); if (d > 180) { d = 360 - d; } if (d > separacionDeTono) { separacionDeTono = d; } } } const saturacionMedia = cromaticos.length ? cromaticos.reduce(function (a, c) { return a + c.s; }, 0) / cromaticos.length : 0; const resultado = { explicitFillShare: conFill > 0 ? conFillExplicito / conFill : 0, shapesWithFill: conFill, shapesWithExplicitFill: conFillExplicito, onlyDefaultColors: soloDefaults, chromaticFills: cromaticos.length, chromaticSamples: cromaticos.slice(0, 8).map(function (c) { return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }), meanChromaticSaturation: Math.round(saturacionMedia), neutralFills: neutrales.length, hueSeparation: Math.round(separacionDeTono), paletteStructured: cromaticos.length >= 2 && neutrales.length >= 1 && separacionDeTono >= 30, shapeCount: nodes.length, distinctFillColors: distintos.length, fillColors: distintos.slice(0, 24), placeholderGreys: grises.length, greySamples: grises.slice(0, 8), textsTotal: textos, textsWithCharacters: textosConChars, distinctFontSizes: tamsUnicos.length, maxFontSize: tamsUnicos.length > 0 ? Math.max.apply(null, tamsUnicos) : 0, textFillShare: textos > 0 ? textosConFill / textos : 0, fontIdBoundShare: textos > 0 ? textosConFont / textos : 0, boards: boards, boardsWithLayout: boardsConLayout, layoutChildren: hijosDeLayout, layoutChildrenSized: hijosDimensionados, layoutChildSizingShare: hijosDeLayout > 0 ? hijosDimensionados / hijosDeLayout : 0, containmentViolations: contencion.length, containmentSamples: contencion.slice(0, 5), containmentError: contencionError, nestingDepth: profundidad, sectionCount: secciones, radiusCount: radios, shadowCount: sombras, gradientCount: gradientes, styleRichness: radios + sombras + gradientes, mainBoardId: principal ? principal.id : null, rootHeightMismatch: desajusteAltura, cssLength: cssLen, markupLength: markupLen, renderError: renderError, renderSkipped: renderSalteado }; // El servidor MCP valida el resultado contra un schema y rechaza la llamada ENTERA si algun // campo no es serializable ("Invalid input: expected string, received function"). Un solo objeto // de Penpot que se cuele en el return -- todos traen metodos -- tira abajo la auditoria completa // y con ella la medicion de ese prompt. Forzar datos planos es mas barato y mas robusto que // cazar el campo culpable cada vez que se agrega una metrica. return JSON.parse(JSON.stringify(resultado)); """ def build_setup_js(page_name, seed_board): return (SETUP_JS .replace("%PAGE_NAME%", js_string(page_name)) .replace("%SEED%", js_string(seed_board) if seed_board else "null")) def build_audit_js(page_id): return AUDIT_JS.replace("%PAGE_ID%", js_string(page_id)) def build_delete_page_js(page_id): return DELETE_PAGE_JS.replace("%PAGE_ID%", js_string(page_id)) def self_check_gate_payloads(): """El payload de auditoria de la puerta tiene que respetar la misma API verificada que se le exige al modelo. Si no, la puerta medirira sus propias excepciones.""" problemas = [] for nombre, code in (("SETUP_JS", SETUP_JS), ("AUDIT_JS", AUDIT_JS)): veto, otros = scan_forbidden(code) for hit in veto + otros: problemas.append(f"{nombre}: [{hit['patron']}] ...{hit['fragmento']}...") if problemas: raise Gate5Error( "los payloads de la propia puerta violan la API verificada:\n - " + "\n - ".join(problemas) ) # ------------------------------------------------------------------------------------------ # Disjuncion contra train/eval/seeds via shingles de 6-gramas # ------------------------------------------------------------------------------------------ def normalizar(texto): sin_tildes = "".join( c for c in unicodedata.normalize("NFD", texto.lower()) if unicodedata.category(c) != "Mn" ) return re.findall(r"[a-z0-9]+", sin_tildes) def shingles(texto, n=SHINGLE_N): tokens = normalizar(texto) if len(tokens) < n: return {" ".join(tokens)} if tokens else set() return {" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1)} def corpus_shingles(paths): """Shingles de todo el texto de usuario de los corpus de entrenamiento/eval/seeds.""" acumulado = set() for path in paths: if not path.exists(): print(f"[WARN] {path.name} no existe: no se puede verificar disjuncion contra el") continue with open(path, encoding="utf-8") as f: for linea in f: linea = linea.strip() if not linea: continue try: fila = json.loads(linea) except json.JSONDecodeError: continue for msg in fila.get("messages", []): if msg.get("role") != "user": continue acumulado |= shingles(msg.get("content") or "") return acumulado def check_rubros(prompts): """Ningun seed puede compartir el RUBRO de un prompt de la puerta. Ver RUBROS_DE_LA_PUERTA.""" import unicodedata as _ud def plano(t): t = _ud.normalize("NFKD", (t or "").lower()) return "".join(c for c in t if not _ud.combining(c)) rubros = sorted({plano(r) for r in RUBROS_DE_LA_PUERTA}) problemas = [] seeds_path = REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl" if not seeds_path.exists(): print("[WARN] no hay corpus de seeds: se saltea el chequeo de rubros") return with open(seeds_path, encoding="utf-8") as f: for lineno, linea in enumerate(f, start=1): linea = linea.strip() if not linea: continue texto = plano(linea) for rubro in rubros: if rubro in texto: problemas.append(f"seed {lineno}: usa el rubro {rubro!r} de la puerta 5") break if problemas: raise Gate5Error( "hay seeds que comparten RUBRO con los prompts de la puerta 5 (fuga de dominio: los " "shingles no la ven porque no hace falta compartir palabras para compartir negocio):" "\n - " + "\n - ".join(problemas[:20]) ) print(f"[OK] ningun seed usa los rubros de la puerta ({', '.join(rubros)})") def check_disjunto(prompts): corpus = corpus_shingles(DISJOINT_AGAINST) if not corpus: print("[WARN] corpus vacio: se saltea el chequeo de disjuncion") return problemas = [] for fila in prompts: solapados = sorted(shingles(fila["prompt"]) & corpus) if solapados: problemas.append(f"{fila['id']}: {len(solapados)} shingle(s), p.ej. {solapados[0]!r}") if problemas: raise Gate5Error( "los prompts de la puerta 5 NO son disjuntos de train/eval/seeds " f"(shingles de {SHINGLE_N}-gramas):\n - " + "\n - ".join(problemas) ) print(f"[OK] los {len(prompts)} prompts son disjuntos de train/eval/seeds " f"({len(corpus)} shingles de referencia)") # ------------------------------------------------------------------------------------------ # Las 17 metricas # ------------------------------------------------------------------------------------------ # (clave, etiqueta, comparador). Comparadores: # min -> pasa si valor >= umbral # max -> pasa si valor <= umbral # pair -> valor y umbral son tuplas; pasa si cada componente >= la suya # bool -> pasa si valor es True (el umbral es True o None para "no aplica") METRICS = [ ("shapeCount", "conteo de shapes", "min"), ("distinctFillColors", "fillColor distintos (sin blanco/negro puros)", "min"), ("explicitFillShare", "share de shapes con fill NO-default", "min"), ("onlyDefaultColors", "la paleta son SOLO los 3 defaults de Penpot [VETO]", "max"), ("chromaticFills", "fills cromaticos (sat HSL >= 45, L 15-85)", "min"), ("meanChromaticSaturation", "saturacion media de los fills cromaticos", "min"), ("paletteStructured", "paleta estructurada (marca + acento + neutrales)", "bool"), ("finalMessageListsHex", "el mensaje final enumera los hex elegidos", "bool"), ("placeholderGreys", "grises de placeholder [VETO]", "max"), ("textsWithCharacters", "textos con characters no vacio", "min"), ("typeScale", "fontSize distintos y maximo", "pair"), ("textFillShare", "share de textos con fill", "min"), ("layoutBoards", "boards con flex/grid", "min"), ("layoutChildSizingShare", "share de hijos con layoutChild.horizontalSizing", "min"), ("containmentViolations", "violaciones de contencion", "max"), ("structure", "profundidad de anidamiento y secciones", "pair"), ("styleRichness", "riqueza de estilo (radios/sombras/gradientes)", "min"), ("rootHeightMismatch", "desajuste de altura root vs contenido", "max"), ("exportBeforeFinal", "export_shape antes del mensaje final", "bool"), ("forbiddenBehavior", "comportamiento prohibido [VETO]", "max"), ("exceptionRate", "tasa de excepciones de execute_code", "max"), ("fontIdBoundShare", "binding tipografico real (fontId)", "min"), ("rendersOk", "el arbol renderiza (generateStyle/generateMarkup)", "bool"), ] VETO_METRICS = ("placeholderGreys", "forbiddenBehavior", "onlyDefaultColors") # Umbrales por prompt. `None` = la metrica NO aplica a ese prompt y no entra en el denominador # del score. Estan aca y no en el .jsonl a proposito: el .jsonl describe la TAREA, el umbral es # criterio de la puerta y se versiona junto al codigo que lo aplica. # ------------------------------------------------------------------------------------------ # Umbrales de VIBRACION, solo para los prompts de brief ambiguo # ------------------------------------------------------------------------------------------ # Requisito explicito del usuario: ante un brief ambiguo el modelo tiene que ELEGIR una paleta y # justificarla, no preguntar ni caer en defaults. Ni `distinctFillColors` ni `placeholderGreys` # distinguen una paleta vibrante de una apagada pero tecnicamente no gris. # # El umbral de saturacion 45 NO es una opinion: sale de medir las 325 paletas no neutrales del # corpus escrito a mano de esta fase. Su saturacion HSL mediana es 75, con p25 en 48 y p10 en 35. # Un piso de 45 queda apenas por debajo del primer cuartil -- lo pasa el 79% de esos fills -- asi # que es un suelo que el comportamiento objetivo despeja comodo, no una aspiracion. La banda de # luminosidad 15..85 excluye casi-negros y casi-blancos, que pueden dar saturacion alta y aun asi # leerse neutros. UMBRALES_VIBRACION = { "chromaticFills": 2, "meanChromaticSaturation": 45, "paletteStructured": True, "finalMessageListsHex": True, } UMBRALES_VIBRACION_NO_APLICA = {k: None for k in UMBRALES_VIBRACION} # Fill explicito. `onlyDefaultColors` es VETO y aplica a TODOS los prompts: un diseno cuyos unicos # colores son los tres defaults de Penpot es el bug de produccion, no un diseno sobrio. # # La distincion que pedia el usuario -- blanco y negro son legitimos cuando el modelo los ELIGE -- # se resuelve por CO-PRESENCIA, no penalizando el hex: #FFFFFF y #000000 solo son sospechosos si # son lo UNICO que hay. Si conviven con colores de marca elegidos, `onlyDefaultColors` es false y # ademas cuentan como neutrales legitimos en `paletteStructured`. Por eso son dos metricas y no # una: `explicitFillShare` mide cuanto del diseno recibio color deliberado, y `onlyDefaultColors` # atrapa el caso degenerado donde no recibio ninguno. # # El umbral de share se grada por dificultad: un boton primario (baja) tiene pocos shapes y algun # neutral estructural pesa mucho en la proporcion; una landing (alta) no tiene excusa. UMBRALES_FILL_EXPLICITO_BAJA = {"explicitFillShare": 0.50, "onlyDefaultColors": False} UMBRALES_FILL_EXPLICITO_MEDIA = {"explicitFillShare": 0.65, "onlyDefaultColors": False} UMBRALES_FILL_EXPLICITO = {"explicitFillShare": 0.80, "onlyDefaultColors": False} THRESHOLDS = { "g5-01-boton-primario": { "shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0, "textsWithCharacters": 1, "typeScale": (1, 14), "textFillShare": 1.0, "layoutBoards": None, "layoutChildSizingShare": None, "containmentViolations": 0, "structure": None, "styleRichness": 2, "rootHeightMismatch": 24, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_FILL_EXPLICITO_BAJA, }, "g5-02-navbar-flex": { "shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0, "textsWithCharacters": 5, "typeScale": (1, 14), "textFillShare": 1.0, "layoutBoards": 1, "layoutChildSizingShare": 0.5, "containmentViolations": 0, "structure": (2, 1), "styleRichness": 1, "rootHeightMismatch": 24, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_FILL_EXPLICITO_BAJA, }, "g5-03-card-producto": { "shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0, "textsWithCharacters": 4, "typeScale": (3, 20), "textFillShare": 1.0, "layoutBoards": None, "layoutChildSizingShare": None, "containmentViolations": 0, "structure": (2, 1), "styleRichness": 3, "rootHeightMismatch": 32, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_FILL_EXPLICITO_MEDIA, }, "g5-04-tokens-biblioteca": { "shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0, "textsWithCharacters": 4, "typeScale": (3, 24), "textFillShare": 1.0, "layoutBoards": None, "layoutChildSizingShare": None, "containmentViolations": 0, "structure": (2, 1), "styleRichness": 2, "rootHeightMismatch": 32, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_FILL_EXPLICITO_MEDIA, }, "g5-05-grid-tres-diferenciales": { "shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0, "textsWithCharacters": 9, "typeScale": (2, 20), "textFillShare": 1.0, "layoutBoards": 1, "layoutChildSizingShare": 0.5, "containmentViolations": 0, "structure": (3, 3), "styleRichness": 3, "rootHeightMismatch": 32, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_FILL_EXPLICITO_MEDIA, }, "g5-09-brief-ambiguo-escuela": { "shapeCount": 30, "distinctFillColors": 6, "placeholderGreys": 0, "textsWithCharacters": 12, "typeScale": (3, 32), "textFillShare": 1.0, "layoutBoards": 3, "layoutChildSizingShare": 0.5, "containmentViolations": 0, "structure": (3, 4), "styleRichness": 3, "rootHeightMismatch": 48, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION, **UMBRALES_FILL_EXPLICITO, }, "g5-10-brief-ambiguo-notaria": { "shapeCount": 30, "distinctFillColors": 5, "placeholderGreys": 0, "textsWithCharacters": 12, "typeScale": (3, 32), "textFillShare": 1.0, "layoutBoards": 3, "layoutChildSizingShare": 0.5, "containmentViolations": 0, "structure": (3, 4), "styleRichness": 3, "rootHeightMismatch": 48, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, # "elegante" no pide estridencia: alcanza con UN cromatico bien elegido sobre neutrales, # pero la paleta tiene que estar estructurada y declarada igual. "chromaticFills": 1, "meanChromaticSaturation": 30, "paletteStructured": True, "finalMessageListsHex": True, **UMBRALES_FILL_EXPLICITO, }, "g5-06-landing-pizzeria": { "shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0, "textsWithCharacters": 12, "typeScale": (4, 32), "textFillShare": 1.0, "layoutBoards": 2, "layoutChildSizingShare": 0.5, "containmentViolations": 0, "structure": (3, 5), "styleRichness": 5, "rootHeightMismatch": 48, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION, **UMBRALES_FILL_EXPLICITO, }, "g5-07-onboarding-mobile": { "shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0, "textsWithCharacters": 5, "typeScale": (3, 24), "textFillShare": 1.0, "layoutBoards": 1, "layoutChildSizingShare": 0.5, "containmentViolations": 0, "structure": (3, 2), "styleRichness": 3, "rootHeightMismatch": 32, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_FILL_EXPLICITO, }, "g5-08-reparacion-grises": { "shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0, "textsWithCharacters": 5, "typeScale": (3, 24), "textFillShare": 1.0, "layoutBoards": None, "layoutChildSizingShare": None, "containmentViolations": 0, "structure": (2, 2), "styleRichness": 3, "rootHeightMismatch": 32, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_FILL_EXPLICITO, }, } def build_raw_metrics(audit, runtime): """Aplana la auditoria del arbol + las señales del loop en las 17 claves de METRICS.""" audit = audit or {} return { "shapeCount": audit.get("shapeCount", 0), "distinctFillColors": audit.get("distinctFillColors", 0), "placeholderGreys": audit.get("placeholderGreys", 0), "textsWithCharacters": audit.get("textsWithCharacters", 0), "typeScale": (audit.get("distinctFontSizes", 0), audit.get("maxFontSize", 0)), "textFillShare": audit.get("textFillShare", 0.0), "layoutBoards": audit.get("boardsWithLayout", 0), "layoutChildSizingShare": audit.get("layoutChildSizingShare", 0.0), "containmentViolations": audit.get("containmentViolations", 0), "structure": (audit.get("nestingDepth", 0), audit.get("sectionCount", 0)), "styleRichness": audit.get("styleRichness", 0), # Sin contenido no hay desajuste que medir: se penaliza como el peor caso posible. "rootHeightMismatch": (audit.get("rootHeightMismatch") if audit.get("rootHeightMismatch") is not None else 10 ** 6), "exportBeforeFinal": runtime["exportBeforeFinal"], "forbiddenBehavior": runtime["forbiddenBehavior"], "exceptionRate": runtime["exceptionRate"], "explicitFillShare": audit.get("explicitFillShare", 0.0), # Sin shapes no hay paleta que juzgar, pero "no hice nada" no puede pasar el veto: se # trata como el caso malo, igual que rootHeightMismatch. "onlyDefaultColors": bool(audit.get("onlyDefaultColors", True)), "chromaticFills": audit.get("chromaticFills", 0), "meanChromaticSaturation": audit.get("meanChromaticSaturation", 0), "paletteStructured": bool(audit.get("paletteStructured", False)), "finalMessageListsHex": runtime["finalMessageListsHex"], "fontIdBoundShare": audit.get("fontIdBoundShare", 0.0), # Si la auditoria salteo el render por tamano, la metrica NO APLICA (None): no se puede # afirmar que el arbol renderiza ni que no renderiza, y contarla como fallo castigaria # justo a los disenos grandes, que es lo contrario de lo que la puerta quiere premiar. "rendersOk": (None if audit.get("renderSkipped") else bool(audit.get("cssLength", 0) > 0 and audit.get("markupLength", 0) > 0 and not audit.get("renderError"))), } def evaluar(clave, comparador, valor, umbral): if umbral is None: return None if comparador == "min": return valor >= umbral if comparador == "max": return valor <= umbral if comparador == "bool": return bool(valor) is bool(umbral) if comparador == "pair": return all(v >= u for v, u in zip(valor, umbral)) raise Gate5Error(f"comparador desconocido para {clave}: {comparador}") def score_prompt(prompt_id, raw): umbrales = THRESHOLDS[prompt_id] detalle = [] pasadas = aplicables = 0 veto = [] for clave, etiqueta, comparador in METRICS: umbral = umbrales.get(clave) # Una metrica NO APLICA si su umbral es None (el prompt no la exige) o si su VALOR es # None (la auditoria no la pudo computar, p.ej. el render salteado por tamano). Contar # "no se pudo medir" como fallo castigaria al diseno grande, que es lo contrario de lo # que la puerta premia. ok = None if raw[clave] is None else evaluar(clave, comparador, raw[clave], umbral) detalle.append({ "metrica": clave, "etiqueta": etiqueta, "valor": raw[clave], "umbral": umbral, "aplica": ok is not None, "pasa": ok, }) if ok is None: continue aplicables += 1 if ok: pasadas += 1 elif clave in VETO_METRICS: veto.append(clave) score = 0.0 if veto else (100.0 * pasadas / aplicables if aplicables else 0.0) return { "score": round(score, 1), "pasadas": pasadas, "aplicables": aplicables, "veto_violado": veto, "metricas": detalle, } # ------------------------------------------------------------------------------------------ # Cliente del modelo (vLLM, OpenAI-compatible) # ------------------------------------------------------------------------------------------ def load_penpot_tools(): """Las 4 tools de data/schemas/penpot.json, byte a byte lo que el modelo ve en produccion.""" tools = json.loads(PENPOT_TOOLS_PATH.read_text(encoding="utf-8")) return [{ "type": "function", "function": { "name": t["name"], "description": t.get("description", ""), "parameters": t.get("parameters") or {"type": "object", "properties": {}}, }, } for t in tools] def load_system_prompt(): """El bloque `instructions` COMPLETO del servidor MCP, verbatim. Medir con otro system prompt mide otra cosa. Y esto se equivoco una vez, caro: La primera corrida del baseline inyectaba solo la seccion inicial del documento (la que `penpot_system_prompt.md` guarda como "Bloque completo, verbatim"), que son 2290 de los 16396 caracteres reales -- el 14%. El 86% que faltaba es justo el grounding de API: "Core Shape Properties and Methods", "Layout Systems", "Text Elements" y "The `penpot` and `penpotUtils` Objects", o sea insertChild, resize(), los layouts y penpotUtils. Con ese recorte, produccion media shapes=0 en los 8 prompts: sin ninguna documentacion de API, el modelo se inventaba una con forma de Figma y todas las llamadas lanzaban. Pero el sintoma que el usuario reporto en su sesion real de Claude Code era otro y menos severo ("se conecta y crea cajas grises"), o sea shapes>0. Esa discrepancia era la firma de que el harness, y no el modelo, estaba produciendo el resultado. En una sesion real el servidor MCP entrega el documento entero como `instructions`, que es tambien lo que devuelve `high_level_overview`. Asi que la fuente correcta es la Captura 1 de penpot_api_docs.md, completa. Nota deliberada sobre los seeds: los 34 seeds que llevan mensaje `system` cargan solo la seccion inicial, no las 4554 tokens del documento entero -- no entrarian en el presupuesto de 3300 tokens por ejemplo. Eso es aceptable porque la contramedida que los seeds tienen que ensenar (la regla de "don't pick your own colours" y el razonamiento que la desambigua) vive entera en esa seccion inicial. """ if os.environ.get("GATE5_SYSTEM_PROMPT_SECTION_ONLY", "").lower() in ("1", "true", "yes"): print("[SYSTEM] usando SOLO la seccion inicial del documento (modo de diagnostico)") return LINT.extract_system_block() lineas = (SCHEMAS_DIR / "penpot_api_docs.md").read_text(encoding="utf-8").splitlines() try: inicio = next(n for n, l in enumerate(lineas) if l.strip() == "Salida verbatim:") + 3 fin = next(n for n in range(inicio + 5, len(lineas)) if lineas[n].rstrip() == "```") except StopIteration: raise Gate5Error( "no se pudo extraer la Captura 1 (high_level_overview) de penpot_api_docs.md; " "ese bloque es el system prompt real del servidor y sin el la medicion no es fiel" ) completo = "\n".join(lineas[inicio:fin]) # La captura viene de `high_level_overview`, que cierra con un separador y la frase # "You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it # again." Eso es framing de la RESPUESTA DE LA TOOL, no parte del bloque `instructions` que # el servidor inyecta. Dejarlo adentro le estaria diciendo al modelo que ya leyo un documento # por haberlo recibido, lo que altera su decision de llamar o no a la tool. corte = completo.rfind("\n--\nYou have hereby read") if corte != -1: completo = completo[:corte] seccion = LINT.extract_system_block() if not completo.startswith(seccion[:200]): raise Gate5Error( "la Captura 1 de penpot_api_docs.md no arranca como el bloque de " "penpot_system_prompt.md: uno de los dos archivos se desincronizo" ) print(f"[SYSTEM] documento completo del servidor: {len(completo)} chars " f"({len(completo.splitlines())} lineas)") return completo def chat_completion(cfg, messages, tools): payload = { "model": cfg["GATE5_MODEL"], "messages": messages, "tools": tools, "tool_choice": "auto", "max_tokens": MAX_TOKENS, "temperature": 0.0, } url = cfg["GATE5_BASE_URL"].rstrip("/") + "/chat/completions" response = requests.post(url, json=payload, timeout=HTTP_TIMEOUT) response.raise_for_status() return response.json()["choices"][0] def parse_tool_arguments(tool_call): raw = tool_call.get("function", {}).get("arguments") if isinstance(raw, dict): return raw, None try: return json.loads(raw or "{}"), None except json.JSONDecodeError as e: return {}, f"arguments no es JSON valido: {e}" # ------------------------------------------------------------------------------------------ # Loop de agente sobre un prompt # ------------------------------------------------------------------------------------------ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir, deadline=None): prompt_id = fila["id"] def queda_tiempo(que): if deadline is not None and time.time() > deadline: raise PromptTimeout( f"{prompt_id}: se agoto el tope de {PROMPT_TIMEOUT_S}s en '{que}'") tag = cfg["GATE5_TAG"] ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") page_name = f"gate5/{tag}/{prompt_id}/{ts}" print(f"\n--- {prompt_id} ({fila.get('dificultad', '?')}) -> pagina {page_name}") setup = mcp.execute_json(build_setup_js(page_name, fila.get("seed_board")), f"{prompt_id}: setup de la pagina") page_id = setup.get("pageId") if not page_id: raise Gate5Error(f"{prompt_id}: el setup no devolvio pageId: {setup}") contexto = [ f"Trabajás en la página «{page_name}», que ya está creada y abierta " f"(id {setup.get('pageId')}). Todo lo que hagas va en esa página." ] if setup.get("seedBoardId"): contexto.append( f"El board a intervenir es «{fila['seed_board']['nombre']}», " f"id {setup['seedBoardId']}." ) user_content = fila["prompt"] + "\n\n" + " ".join(contexto) messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}] code_payloads = [] export_calls = [] turnos = [] exec_calls = exec_exceptions = 0 veto_hits, otros_hits = [], [] final_content = None finish_reason = None for turno in range(1, MAX_TURNS + 1): queda_tiempo(f"turno {turno}") try: choice = chat_completion(cfg, messages, tools) except requests.RequestException as e: turnos.append({"turno": turno, "error_modelo": f"{type(e).__name__}: {e}"}) break message = choice.get("message", {}) finish_reason = choice.get("finish_reason") tool_calls = message.get("tool_calls") or [] registro = { "turno": turno, "finish_reason": finish_reason, "content": message.get("content") or "", "reasoning": message.get("reasoning") or "", "tool_calls": [], } if not tool_calls: final_content = message.get("content") or "" turnos.append(registro) break messages.append({ "role": "assistant", "content": message.get("content") or "", "tool_calls": tool_calls, }) for tool_call in tool_calls: nombre = tool_call.get("function", {}).get("name") args, error_args = parse_tool_arguments(tool_call) resultado = "" if error_args: resultado = error_args elif nombre == "execute_code": code = args.get("code", "") code_payloads.append({"turno": turno, "code": code}) veto, otros = scan_forbidden(code) for hit in veto: hit["turno"] = turno for hit in otros: hit["turno"] = turno veto_hits.extend(veto) otros_hits.extend(otros) exec_calls += 1 texto, es_error = mcp.execute_code(code) if es_error or not texto.strip().startswith(("{", "[")): exec_exceptions += 1 resultado = texto elif nombre == "export_shape": inventados = sorted(k for k in args if k not in EXPORT_SHAPE_ALLOWED_ARGS) if inventados: veto_hits.append({ "patron": "argumentos inventados en export_shape", "fragmento": ", ".join(inventados), "turno": turno, }) export_calls.append({"turno": turno, "args": args}) limpios = {k: v for k, v in args.items() if k in EXPORT_SHAPE_ALLOWED_ARGS} texto, binarios, _ = mcp.call_tool("export_shape", limpios) resultado = texto or f"[{len(binarios)} binario(s) devueltos]" elif nombre in ("high_level_overview", "penpot_api_info"): texto, _binarios, _ = mcp.call_tool(nombre, args) resultado = texto else: resultado = f"Unknown tool: {nombre}" registro["tool_calls"].append({ "name": nombre, "arguments": args, "result_preview": resultado[:400], }) messages.append({ "role": "tool", "tool_call_id": tool_call.get("id"), "name": nombre, "content": resultado, }) turnos.append(registro) else: print(f"[WARN] {prompt_id}: se agotaron los {MAX_TURNS} turnos sin mensaje final") # --- auditoria inyectada por la puerta ------------------------------------------------ try: audit = mcp.execute_json(build_audit_js(page_id), f"{prompt_id}: auditoria") except Gate5Error as e: print(f"[WARN] {prompt_id}: la auditoria fallo ({e})") audit = {"auditError": str(e)} # --- PNG de la puerta (nunca reemplaza al export del modelo, se suma) ----------------- png_path = None # Sin mainBoardId no hay nada util que exportar: el root de la pagina no se puede exportar # por id (todas las paginas comparten el mismo), asi que se omite el PNG en vez de exportar # el arbol equivocado. shape_a_exportar = audit.get("mainBoardId") if not shape_a_exportar: print(f"[WARN] {prompt_id}: la auditoria no encontro un board principal; no hay PNG") try: if not shape_a_exportar: raise Gate5Error("sin board principal que exportar") _texto, binarios, _ = mcp.call_tool( "export_shape", {"shapeId": shape_a_exportar, "format": "png", "mode": "shape"}) if binarios: png_dir.mkdir(parents=True, exist_ok=True) png_path = png_dir / f"{prompt_id}.png" png_path.write_bytes(base64.b64decode(binarios[0])) print(f"[PNG] {png_path.relative_to(REPO_ROOT)}") else: print(f"[WARN] {prompt_id}: export_shape no devolvio binario") except Gate5Error as e: print(f"[WARN] {prompt_id}: no se pudo exportar el PNG ({e})") # --- vaciar la pagina de la puerta --------------------------------------------------------- # La evidencia que queda es el PNG + las metricas del JSON, no la pagina viva. Dejarlas # acumular tiene un costo medible: el plugin de Penpot se degrada de forma progresiva a # medida que crece el archivo -- en tres corridas seguidas aguanto 5 o 6 prompts pesados y # despues empezo a dar timeouts de 30 s en `createPage`. Con 26 paginas acumuladas eso deja # de ser flakiness del MCP y pasa a ser un problema que la propia puerta se fabrica. # Ademas le deja el archivo limpio al usuario. if LIMPIAR_PAGINAS and png_path is not None: try: info = mcp.execute_json(build_delete_page_js(page_id), f"{prompt_id}: limpieza de la pagina") if info.get("borrada"): print(f"[CLEAN] {info['nombre']}: {info['shapesBorrados']} shape(s) borrados " f"(la evidencia queda en el PNG y en el JSON)") else: print(f"[WARN] {prompt_id}: no se limpio la pagina ({info.get('motivo')})") except Gate5Error as e: print(f"[WARN] {prompt_id}: no se pudo limpiar la pagina ({e})") elif png_path is None: print(f"[CLEAN] {prompt_id}: la pagina se CONSERVA porque no hubo PNG, para poder " f"inspeccionar que paso") # Ante un brief ambiguo el modelo tiene que ELEGIR una paleta y DECIRSELA al usuario, para # que la pueda ajustar. Una paleta elegida en silencio no es colaborable. hex_en_final = set(HEX_EN_TEXTO.findall(final_content or "")) runtime = { "exportBeforeFinal": bool(export_calls), "forbiddenBehavior": len(veto_hits), "exceptionRate": (exec_exceptions / exec_calls) if exec_calls else 0.0, "finalMessageListsHex": len(hex_en_final) >= 3, } raw = build_raw_metrics(audit, runtime) resultado = score_prompt(prompt_id, raw) print(f" score={resultado['score']:.1f} " f"({resultado['pasadas']}/{resultado['aplicables']}) " f"grises={raw['placeholderGreys']} prohibido={raw['forbiddenBehavior']} " f"shapes={raw['shapeCount']} colores={raw['distinctFillColors']} " f"textos={raw['textsWithCharacters']}") return { "id": prompt_id, "dificultad": fila.get("dificultad"), "prompt": fila["prompt"], "pageId": setup.get("pageId"), "pageName": page_name, "pageId_auditado": page_id, "seedBoardId": setup.get("seedBoardId"), "turnos_usados": len(turnos), "finish_reason": finish_reason, "mensaje_final": final_content, "auditoria": audit, "metricas_crudas": {k: (list(v) if isinstance(v, tuple) else v) for k, v in raw.items()}, "execute_code_calls": exec_calls, "execute_code_excepciones": exec_exceptions, "code_payloads": code_payloads, "export_shape_calls": export_calls, "veto_hits": veto_hits, "otros_patrones": otros_hits, "png": str(png_path.relative_to(REPO_ROOT)) if png_path else None, "turnos": turnos, **resultado, } # ------------------------------------------------------------------------------------------ # Modo holdout: grep estatico barato, sin MCP y sin loop # ------------------------------------------------------------------------------------------ def run_holdout(cfg, tools, system_prompt): if not HOLDOUT_PATH.exists(): raise Gate5Error( f"no existe {HOLDOUT_PATH.relative_to(REPO_ROOT)}; generalo con " f"scripts/35_build_penpot_design_holdout.py antes de correr el modo holdout" ) filas = [] with open(HOLDOUT_PATH, encoding="utf-8") as f: for linea in f: linea = linea.strip() if linea: filas.append(json.loads(linea)) print(f"[INFO] modo holdout: {len(filas)} prompts de diseno, sin tocar el MCP\n") resultados = [] con_prohibido = 0 errores = 0 patrones = Counter() for i, fila in enumerate(filas, start=1): prompt = fila.get("prompt") or fila.get("user") or "" messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}] try: choice = chat_completion(cfg, messages, tools) except requests.RequestException as e: # Una generacion que nunca ocurrio NO es una generacion limpia: se cuenta aparte y # nunca entra en el denominador de la tasa (si no, un endpoint caido "aprueba"). errores += 1 resultados.append({"prompt": prompt, "error": f"{type(e).__name__}: {e}"}) continue message = choice.get("message", {}) veto_total, otros_total, codes = [], [], [] for tool_call in message.get("tool_calls") or []: nombre = tool_call.get("function", {}).get("name") args, error_args = parse_tool_arguments(tool_call) if error_args: continue if nombre == "execute_code": code = args.get("code", "") codes.append(code) veto, otros = scan_forbidden(code) veto_total.extend(veto) otros_total.extend(otros) elif nombre == "export_shape": inventados = sorted(k for k in args if k not in EXPORT_SHAPE_ALLOWED_ARGS) if inventados: veto_total.append({"patron": "argumentos inventados en export_shape", "fragmento": ", ".join(inventados)}) elif nombre not in ("high_level_overview", "penpot_api_info"): veto_total.append({"patron": "tool inexistente", "fragmento": str(nombre)}) if veto_total: con_prohibido += 1 for hit in veto_total: patrones[hit["patron"]] += 1 resultados.append({ "prompt": prompt, "id": fila.get("id"), "tool_calls": [tc.get("function", {}).get("name") for tc in (message.get("tool_calls") or [])], "code_payloads": codes, "veto_hits": veto_total, "otros_patrones": otros_total, "content": message.get("content") or "", }) if i % 10 == 0: print(f"[INFO] {i}/{len(filas)} prompts procesados") total = len(resultados) - errores if total <= 0: raise Gate5Error( f"ninguna de las {len(filas)} generaciones del holdout llego a completarse " f"({errores} error(es) de request contra vLLM): no hay nada que medir" ) tasa = con_prohibido / total # Los errores de request bloquean la aprobacion a proposito: la puerta es el disparador de # fallback antes de cuantizar, y un "aprueba" sobre generaciones faltantes no vale nada. aprueba = tasa <= HOLDOUT_FORBIDDEN_RATE_MAX and errores == 0 print("\n=== Puerta 5 (modo holdout) -- tasa de API prohibida ===") print(f" prompts medidos: {total} de {len(filas)}") print(f" errores de request: {errores}") print(f" con API prohibida: {con_prohibido}") print(f" tasa: {100 * tasa:.1f}% (umbral <= {100 * HOLDOUT_FORBIDDEN_RATE_MAX:.0f}%)") if patrones: print(" desglose por patron:") for patron, n in patrones.most_common(): print(f" - {patron}: {n}") print(f"\n VEREDICTO: {'APRUEBA' if aprueba else 'NO APRUEBA -- disparador de fallback'}") salida = REPO_ROOT / "data" / f"gate5_holdout_{cfg['GATE5_TAG']}.json" salida.write_text(json.dumps({ "modo": "holdout", "tag": cfg["GATE5_TAG"], "modelo": cfg["GATE5_MODEL"], "total": total, "prompts_en_archivo": len(filas), "errores_de_request": errores, "con_api_prohibida": con_prohibido, "tasa": tasa, "umbral": HOLDOUT_FORBIDDEN_RATE_MAX, "aprueba": aprueba, "patrones": dict(patrones), "resultados": resultados, }, ensure_ascii=False, indent=2), encoding="utf-8") print(f" resultados en {salida.relative_to(REPO_ROOT)}") return 0 if aprueba else 1 # ------------------------------------------------------------------------------------------ # Modo completo # ------------------------------------------------------------------------------------------ def load_prompts(): filas = [] with open(PROMPTS_PATH, encoding="utf-8") as f: for lineno, linea in enumerate(f, start=1): linea = linea.strip() if not linea: continue try: filas.append(json.loads(linea)) except json.JSONDecodeError as e: raise Gate5Error(f"{PROMPTS_PATH.name}:{lineno}: JSON invalido: {e}") faltantes = [f["id"] for f in filas if f["id"] not in THRESHOLDS] if faltantes: raise Gate5Error(f"prompts sin umbrales declarados en THRESHOLDS: {faltantes}") return filas def resumen(resultados, tag, esperados=None): """Resume la corrida. Un prompt SIN medicion no cuenta como 0 en la media. "No pude medir" y "el modelo lo hizo mal" son dos cosas distintas y promediarlas juntas produce un numero que no significa nada: un corte del plugin en el prompt 6 abarataria artificialmente el promedio y despues se compararia contra el modelo nuevo como si fuera calidad. Los no medidos se reportan aparte y **invalidan el veredicto**: un baseline con 5 de 10 medidos no es un baseline. """ medidos = [r for r in resultados if r.get("score") is not None] no_medidos = [r for r in resultados if r.get("score") is None] scores = [r["score"] for r in medidos] media = sum(scores) / len(scores) if scores else 0.0 veto_limpio = sum(1 for r in medidos if not r["veto_violado"]) sobre_minimo = sum(1 for r in medidos if r["score"] >= PROMPT_SCORE_MIN) flagship = next((r for r in medidos if r["id"] == FLAGSHIP_PROMPT_ID), None) flagship_score = flagship["score"] if flagship else 0.0 total_esperado = esperados if esperados is not None else len(resultados) print(f"\n=== Puerta 5 -- calidad de diseno en Penpot (tag={tag}) ===\n") print(f" {'prompt':32s} {'dif':6s} {'score':>6s} {'pasa':>7s} {'grises':>7s} " f"{'prohib':>7s} {'shapes':>7s} {'colores':>8s} {'textos':>7s}") for r in medidos: crudas = r["metricas_crudas"] print(f" {r['id']:32s} {str(r['dificultad'])[:6]:6s} {r['score']:6.1f} " f"{r['pasadas']:3d}/{r['aplicables']:<3d} {crudas['placeholderGreys']:7d} " f"{crudas['forbiddenBehavior']:7d} {crudas['shapeCount']:7d} " f"{crudas['distinctFillColors']:8d} {crudas['textsWithCharacters']:7d}") if no_medidos: print(f"\n NO MEDIDOS ({len(no_medidos)}), no cuentan como 0 en la media:") for r in no_medidos: print(f" {r['id']:32s} {str(r.get('error'))[:110]}") condiciones = [ ("todos los prompts medidos", len(medidos) == total_esperado, f"{len(medidos)}/{total_esperado}"), (f"score medio >= {MEAN_SCORE_MIN:.0f}", media >= MEAN_SCORE_MIN, f"{media:.1f}"), (f"veto limpio en {total_esperado}/{total_esperado}", veto_limpio == total_esperado, f"{veto_limpio}/{total_esperado}"), (f">= {MIN_PROMPTS_OVER_MIN}/{total_esperado} prompts >= {PROMPT_SCORE_MIN:.0f}", sobre_minimo >= MIN_PROMPTS_OVER_MIN, f"{sobre_minimo}/{total_esperado}"), (f"landing de pizzeria >= {PROMPT_SCORE_MIN:.0f}", flagship_score >= PROMPT_SCORE_MIN, f"{flagship_score:.1f}"), ] print("\n condiciones de aprobacion:") for etiqueta, ok, valor in condiciones: print(f" [{'ok ' if ok else 'NO '}] {etiqueta:40s} -> {valor}") aprueba = all(ok for _e, ok, _v in condiciones) if no_medidos: print(f"\n VEREDICTO GLOBAL: INVALIDO -- faltan {len(no_medidos)} prompt(s) por medir. " f"Re-medilos con GATE5_ONLY={','.join(r['id'] for r in no_medidos)}") else: print(f"\n VEREDICTO GLOBAL: {'APRUEBA' if aprueba else 'NO APRUEBA'}") return aprueba, { "valido": not no_medidos, "no_medidos": [r["id"] for r in no_medidos], "score_medio": round(media, 2), "veto_limpio": veto_limpio, "prompts_sobre_minimo": sobre_minimo, "score_landing_pizzeria": flagship_score, "aprueba": aprueba, "condiciones": [{"condicion": e, "ok": ok, "valor": v} for e, ok, v in condiciones], } def run_full(cfg, tools, system_prompt): prompts = load_prompts() todos_los_prompts = list(prompts) check_disjunto(prompts) check_rubros(prompts) # Re-medir un subconjunto sin quemar los 10. Existe porque el MCP de Penpot se cae: cuando # una corrida pierde 5 prompts por un corte de transporte, repetir los 10 desperdicia media # hora de produccion arriba Y descarta mediciones que eran validas. solo = [x.strip() for x in os.environ.get("GATE5_ONLY", "").split(",") if x.strip()] previos = [] if solo: conocidos = {f["id"] for f in prompts} desconocidos = [x for x in solo if x not in conocidos] if desconocidos: raise Gate5Error(f"GATE5_ONLY nombra prompts que no existen: {desconocidos}") # Los resultados ya medidos que NO se vuelven a correr se conservan y se fusionan. anterior = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json" if anterior.exists(): try: previos = [r for r in json.loads(anterior.read_text(encoding="utf-8")).get("prompts", []) if r.get("id") not in solo and r.get("score") is not None] print(f"[INFO] se conservan {len(previos)} prompt(s) ya medidos de " f"{anterior.name}: {[r['id'] for r in previos]}") except (json.JSONDecodeError, OSError) as e: print(f"[WARN] no se pudo leer el resultado anterior ({e}); se re-mide todo lo pedido") prompts = [f for f in prompts if f["id"] in solo] print(f"[INFO] GATE5_ONLY: se miden {len(prompts)} prompt(s): {[f['id'] for f in prompts]}") self_check_gate_payloads() mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"]) mcp.handshake() # Sonda barata. Ya NO aborta: la corrida hace hasta MAX_PASADAS pasadas con handshake limpio, # asi que un plugin que no responde ahora puede responder en la pasada 2. Abortar aca # desperdiciaria la ventana entera por un estado transitorio. try: sonda, _es_error = mcp.execute_code("return { gate5Probe: true };") if PLUGIN_NOT_CONNECTED in sonda: print("[MCP] AVISO: el plugin no figura conectado. Se intenta igual: las pasadas " "posteriores rehacen el handshake.") else: print(f"[MCP] sonda de conectividad del plugin: {sonda[:120]}") except Gate5Error as e: print(f"[MCP] AVISO: la sonda fallo ({str(e)[:140]}). Se intenta igual.") png_dir = REPO_ROOT / "data" / f"gate5_png_{cfg['GATE5_TAG']}" salida = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json" def fusionar(resultados_parciales): """Los recien medidos + los conservados, en el orden canonico del .jsonl.""" por_id = {r["id"]: r for r in previos} por_id.update({r["id"]: r for r in resultados_parciales}) orden = [f["id"] for f in todos_los_prompts] return [por_id[i] for i in orden if i in por_id] def volcar(resultados_parciales, dt_parcial, veredicto_parcial=None, completo=False): """Escribe el JSON de resultados. Se llama DESPUES DE CADA PROMPT. La primera corrida del baseline murio en el prompt 4 por una caida de conexion del MCP y se perdio la medicion de los tres que ya habian terminado -- que era justamente el dato caro, porque exige tener produccion arriba. Un fallo de transporte no puede costar el trabajo ya hecho. """ salida.write_text(json.dumps({ "modo": "completo" if completo else "parcial", "tag": cfg["GATE5_TAG"], "modelo": cfg["GATE5_MODEL"], "max_turnos": MAX_TURNS, "max_tokens": MAX_TOKENS, "timestamp": datetime.now(timezone.utc).isoformat(), "duracion_s": round(dt_parcial, 1), "prompts_completados": len(fusionar(resultados_parciales)), "prompts_totales": len(todos_los_prompts), "metricas": [{"clave": k, "etiqueta": e, "comparador": c} for k, e, c in METRICS], "veto": list(VETO_METRICS), "umbrales": {pid: {k: (list(v) if isinstance(v, tuple) else v) for k, v in u.items()} for pid, u in THRESHOLDS.items()}, "veredicto": veredicto_parcial, "prompts": fusionar(resultados_parciales), }, ensure_ascii=False, indent=2), encoding="utf-8") t0 = time.time() fin_de_pared = t0 + WALL_CLOCK_S medidos = {} ultimo_error = {} # CORRIDA UNICA, EN PASADAS. El MCP de Penpot se cae seguido y cada ventana en que funciona # es cara, asi que la corrida no puede rendirse por el fallo de uno: recorre todos los # prompts, anota los que fallaron, y los reintenta en la pasada siguiente con un handshake # limpio en el medio. Los cortes anteriores nos costaron tres ventanas del usuario. por_medir = list(prompts) for pasada in range(1, MAX_PASADAS + 1): if not por_medir: break if time.time() > fin_de_pared: print(f"\n[PARED] se agoto el tope de {WALL_CLOCK_S // 60} min; quedan " f"{len(por_medir)} sin medir") break if pasada > 1: print(f"\n[PASADA {pasada}/{MAX_PASADAS}] handshake limpio y reintento de " f"{len(por_medir)}: {[f['id'] for f in por_medir]}") mcp.session_id = None try: mcp.handshake() sonda, _ = mcp.execute_code("return 1;") if PLUGIN_NOT_CONNECTED in sonda: print("[PASADA] el plugin no esta conectado; no tiene sentido reintentar") break except Gate5Error as e: print(f"[PASADA] el re-handshake fallo ({e}); se corta") break else: print(f"\n[PASADA 1/{MAX_PASADAS}] {len(por_medir)} prompt(s)") fallaron = [] for fila in por_medir: if time.time() > fin_de_pared: print(f"[PARED] tope alcanzado; {fila['id']} y los siguientes quedan sin medir") fallaron.append(fila) continue deadline = min(time.time() + PROMPT_TIMEOUT_S, fin_de_pared) try: medidos[fila["id"]] = run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir, deadline=deadline) except PluginNotConnected as e: # Sin plugin no hay nada que medir en NINGUN prompt: se corta la pasada, pero la # corrida NO aborta -- lo ya medido queda en disco y la pasada siguiente # reintenta con handshake limpio. print(f"[ERROR] {fila['id']}: plugin no conectado ({str(e)[:90]})") ultimo_error[fila["id"]] = f"PluginNotConnected: {str(e)[:200]}" fallaron.append(fila) fallaron.extend(f for f in por_medir[por_medir.index(fila) + 1:]) break except Gate5Error as e: # Un fallo de un prompt NO aborta la corrida ni quema el prompt: se reintenta. clase = "timeout del prompt" if isinstance(e, PromptTimeout) else "fallo" print(f"[ERROR] {fila['id']}: {clase} -> {str(e)[:220]}") ultimo_error[fila["id"]] = str(e)[:400] fallaron.append(fila) except Exception as e: # noqa: BLE001 - una excepcion inesperada tampoco puede print(f"[ERROR] {fila['id']}: excepcion inesperada {type(e).__name__}: " f"{str(e)[:200]}") # tirar abajo los 9 prompts restantes ultimo_error[fila["id"]] = f"{type(e).__name__}: {str(e)[:300]}" fallaron.append(fila) # Linea de progreso legible desde afuera, sin parsear el JSON. r = medidos.get(fila["id"]) if r: c = r.get("metricas_crudas") or {} print(f"PROGRESO {len(medidos)} de {len(todos_los_prompts)}, {fila['id']}, " f"score {r.get('score')}, shapes {c.get('shapeCount')}, " f"colores {c.get('distinctFillColors')}", flush=True) volcar(list(medidos.values()), time.time() - t0) por_medir = fallaron resultados = list(medidos.values()) for fila in por_medir: resultados.append({"id": fila["id"], "score": None, "error": ultimo_error.get(fila["id"], "no medido")}) pendientes = [f["id"] for f in por_medir] dt = time.time() - t0 aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"], esperados=len(todos_los_prompts)) print(f"\n tiempo total: {dt / 60:.1f} min") volcar(resultados, dt, veredicto_parcial=veredicto, completo=not pendientes) # Inventario de las paginas gate5 que quedaron de corridas ANTERIORES. No se borran solas: # es el archivo del usuario y la decision es suya. Se listan para poder ofrecerselo. try: inv = mcp.execute_json( "const ps = penpotUtils.getPages().filter(function(p){" " return p.name && p.name.indexOf('gate5/') === 0; });" "return { total: ps.length, nombres: ps.slice(0, 60).map(function(p){ return p.name; }) };", "inventario de paginas gate5") print(f"\n[PAGINAS] el archivo tiene {inv.get('total')} pagina(s) gate5/ acumuladas de " f"esta corrida y de anteriores.") print(" Las de ESTA corrida ya quedaron vaciadas tras exportar su PNG. Las viejas se " "dejan como estan: es el archivo del usuario y se le ofrece borrarlas en el C1.") (REPO_ROOT / "data" / f"gate5_paginas_{cfg['GATE5_TAG']}.json").write_text( json.dumps(inv, ensure_ascii=False, indent=2), encoding="utf-8") except Gate5Error as e: print(f"[WARN] no se pudo inventariar las paginas gate5 ({e})") sin_medir = sorted(set(pendientes) | {r["id"] for r in fusionar(resultados) if r.get("score") is None}) if sin_medir: # NO culpar al plugin por defecto. Un mensaje que manda al usuario a recargar el navegador # cuando la causa es un bug de la puerta cuesta tiempo de las dos partes, y ya paso una # vez: el setup fallo por depender de `penpot.root` y el script igual imprimio "plugin # degradado". Se clasifica por la evidencia del error, no por el hecho de que falte algo. errores = " ".join(str(r.get("error", "")) for r in fusionar(resultados) if r.get("score") is None) parece_plugin = bool(re.search( r"timed out after \d+ seconds|No Penpot plugin instances|ConnectionError|" r"error de transporte", errores, re.I)) print(f"\n[INCOMPLETO] {len(sin_medir)} prompt(s) no se pudieron medir.") if parece_plugin: print(" Causa probable: el plugin de Penpot. Los errores son timeouts de tarea o " "cortes de transporte.") print(" Pedile al usuario que RECARGUE el plugin en el navegador y retoma con:") else: print(" Causa probable: un bug de la PUERTA, no del plugin -- los errores no son " "timeouts ni cortes de transporte. Revisar el mensaje de error antes de " "pedirle nada al usuario.") print(" Cuando este arreglado, retoma con:") print(f" GATE5_ONLY={','.join(sin_medir)}") return EXIT_PLUGIN_DEGRADADO if parece_plugin else 1 print(f" resultados en {salida.relative_to(REPO_ROOT)}") print(f" PNGs en {png_dir.relative_to(REPO_ROOT)}/ (la pagina de cada prompt se vacia tras " f"exportar su PNG; solo se conserva la de un prompt cuyo PNG haya fallado)") return 0 if aprueba else 1 def main(): modo_holdout = os.environ.get("GATE5_HOLDOUT", "").strip() in ("1", "true", "yes") try: cfg = load_env(need_mcp=not modo_holdout) tools = load_penpot_tools() system_prompt = load_system_prompt() if modo_holdout: return run_holdout(cfg, tools, system_prompt) return run_full(cfg, tools, system_prompt) except PluginNotConnected as e: print( "\n[ABORTA] el servidor MCP de Penpot no tiene ningun plugin conectado.\n" " Abri el archivo de Penpot en el navegador, arranca el plugin del MCP y espera a\n" " que quede conectado; recien ahi volve a correr la puerta. Sin plugin conectado no\n" " tiene sentido gastar los 8 prompts.\n" f" Mensaje del servidor: {str(e)[:200]}", file=sys.stderr, ) return EXIT_PLUGIN_NOT_CONNECTED except Gate5Error as e: print(f"\n[ERROR] {e}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())