diff --git a/scripts/35_gate5_penpot_design.py b/scripts/35_gate5_penpot_design.py index 8269092..5587ac9 100644 --- a/scripts/35_gate5_penpot_design.py +++ b/scripts/35_gate5_penpot_design.py @@ -99,6 +99,10 @@ AUDIT_MAX_RETRIES = int(os.environ.get("GATE5_AUDIT_RETRIES", "5")) # 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")) + # 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" @@ -635,17 +639,28 @@ if (principal) { } } -let cssLen = 0, markupLen = 0, renderError = null; -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; +// 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); } -} catch (e) { - renderError = String((e && e.message) || e); } const tamsUnicos = []; @@ -749,7 +764,8 @@ const resultado = { rootHeightMismatch: desajusteAltura, cssLength: cssLen, markupLength: markupLen, - renderError: renderError + renderError: renderError, + renderSkipped: renderSalteado }; // El servidor MCP valida el resultado contra un schema y rechaza la llamada ENTERA si algun @@ -1062,8 +1078,13 @@ def build_raw_metrics(audit, runtime): "paletteStructured": bool(audit.get("paletteStructured", False)), "finalMessageListsHex": runtime["finalMessageListsHex"], "fontIdBoundShare": audit.get("fontIdBoundShare", 0.0), - "rendersOk": bool(audit.get("cssLength", 0) > 0 and audit.get("markupLength", 0) > 0 - and not audit.get("renderError")), + # 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"))), } @@ -1088,7 +1109,11 @@ def score_prompt(prompt_id, raw): veto = [] for clave, etiqueta, comparador in METRICS: umbral = umbrales.get(clave) - ok = evaluar(clave, comparador, raw[clave], umbral) + # 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, @@ -1689,18 +1714,54 @@ def run_full(cfg, tools, system_prompt): t0 = time.time() resultados = [] - for fila in prompts: - try: - resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir)) - except PluginNotConnected: + pendientes = [] + corte_por_degradacion = False + + # LOTES. El plugin de Penpot aguanta de forma confiable unos 5 o 6 prompts pesados y despues + # se degrada hasta dar timeouts de 30 s en `createPage`. Medido en tres corridas seguidas, + # siempre con la misma forma: los primeros funcionan, el resto falla en el setup sin + # excepcion. Procesar en lotes chicos con un handshake limpio en el medio le da al servidor + # un punto de recuperacion, y si igual se degrada se corta ahi en vez de quemar los que + # faltan de a uno. + for inicio in range(0, len(prompts), BATCH_SIZE): + lote = prompts[inicio:inicio + BATCH_SIZE] + n_lote = inicio // BATCH_SIZE + 1 + total_lotes = (len(prompts) + BATCH_SIZE - 1) // BATCH_SIZE + if inicio > 0: + print(f"\n[LOTE] handshake limpio antes del lote {n_lote}/{total_lotes}") + mcp.session_id = None + try: + mcp.handshake() + except Gate5Error as e: + print(f"[LOTE] el re-handshake fallo ({e}); se corta y quedan pendientes " + f"{len(prompts) - inicio} prompt(s)") + pendientes.extend(f["id"] for f in prompts[inicio:]) + corte_por_degradacion = True + break + print(f"\n[LOTE] {n_lote}/{total_lotes}: {[f['id'] for f in lote]}") + + for i, fila in enumerate(lote): + try: + resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir)) + except PluginNotConnected: + volcar(resultados, time.time() - t0) + raise + except Gate5Error as e: + # Un fallo de setup NO quema el prompt: queda PENDIENTE, no medido con score + # None. Y se corta el lote, porque una vez que el plugin empieza a dar timeouts + # los que siguen fallan todos igual -- insistir solo gasta paginas y ensucia el + # JSON. + print(f"[ERROR] {fila['id']}: {e}") + restantes = [f["id"] for f in lote[i:]] + \ + [f["id"] for f in prompts[inicio + BATCH_SIZE:]] + print(f"[LOTE] se corta el lote. Quedan {len(restantes)} prompt(s) PENDIENTES " + f"(no medidos, no quemados): {restantes}") + pendientes.extend(restantes) + corte_por_degradacion = True + break volcar(resultados, time.time() - t0) - raise - except Gate5Error as e: - print(f"[ERROR] {fila['id']}: {e}") - print(f"[INFO] se guardan los {len(resultados)} prompt(s) ya medidos y se sigue con " - f"el siguiente; el JSON queda marcado como parcial") - resultados.append({"id": fila["id"], "error": str(e), "score": None}) - volcar(resultados, time.time() - t0) + if corte_por_degradacion: + break dt = time.time() - t0 aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"], @@ -1708,7 +1769,8 @@ def run_full(cfg, tools, system_prompt): print(f"\n tiempo total: {dt / 60:.1f} min") volcar(resultados, dt, veredicto_parcial=veredicto, completo=True) - sin_medir = [r["id"] for r in fusionar(resultados) if r.get("score") is None] + sin_medir = sorted(set(pendientes) | + {r["id"] for r in fusionar(resultados) if r.get("score") is None}) if sin_medir: print(f"\n[PLUGIN DEGRADADO] {len(sin_medir)} prompt(s) no se pudieron medir. El plugin " f"de Penpot responde pero se degrada tras 5 o 6 prompts pesados.") @@ -1716,8 +1778,8 @@ def run_full(cfg, tools, system_prompt): print(f" GATE5_ONLY={','.join(sin_medir)}") return EXIT_PLUGIN_DEGRADADO print(f" resultados en {salida.relative_to(REPO_ROOT)}") - print(f" PNGs en {png_dir.relative_to(REPO_ROOT)}/ (la puerta no borra nada: las paginas " - f"quedan para inspeccion humana)") + 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