Phase 6: train a second LoRA for real Penpot UI design capability #5

Merged
aleleba merged 36 commits from agente-fase6-lora2-penpot into master 2026-08-04 21:36:07 -06:00
Showing only changes of commit f63ff6830d - Show all commits
+226 -24
View File
@@ -83,7 +83,21 @@ MCP_RETRY_BACKOFF = float(os.environ.get("PENPOT_MCP_RETRY_BACKOFF", "3"))
MAX_TURNS = int(os.environ.get("GATE5_MAX_TURNS", "14")) MAX_TURNS = int(os.environ.get("GATE5_MAX_TURNS", "14"))
MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096")) MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096"))
HTTP_TIMEOUT = int(os.environ.get("GATE5_HTTP_TIMEOUT", "600")) HTTP_TIMEOUT = int(os.environ.get("GATE5_HTTP_TIMEOUT", "600"))
MCP_TIMEOUT = int(os.environ.get("GATE5_MCP_TIMEOUT", "180")) 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")
# El prompt que reproduce el fallo exacto de produccion. Tiene su propia condicion de # 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. # aprobacion: si este no llega a 60, la puerta no aprueba aunque el promedio alcance.
@@ -98,6 +112,9 @@ HEX_EN_TEXTO = re.compile(r"#[0-9a-fA-F]{6}\b")
PLUGIN_NOT_CONNECTED = "No Penpot plugin instances are currently connected" PLUGIN_NOT_CONNECTED = "No Penpot plugin instances are currently connected"
EXIT_PLUGIN_NOT_CONNECTED = 2 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): class Gate5Error(RuntimeError):
@@ -399,7 +416,17 @@ class PenpotMCP:
`execute_code` (el metodo de abajo) NO desenvuelve: lo que ve el modelo como tool result `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. tiene que ser byte a byte lo que le llegaria en produccion, envoltorio incluido.
""" """
texto, es_error = self.execute_code(code) 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: try:
parsed = json.loads(texto) parsed = json.loads(texto)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -458,10 +485,35 @@ return {
# Payload de auditoria. Lo inyecta la puerta, asi que tiene que respetar la misma API verificada # 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), # 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. # 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 = """ AUDIT_JS = """
const ROOT_ID = %ROOT_ID%; // El root se resuelve por PAGE_ID, nunca por rootId.
const root = penpotUtils.findShapeById(ROOT_ID); // Por que: en Penpot TODAS las paginas nuevas comparten el mismo id de root frame,
if (!root) { return { auditError: "root no encontrado" }; } // "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 nodes = [];
const collect = (shape, depth) => { const collect = (shape, depth) => {
@@ -618,6 +670,23 @@ function aHsl(hex) {
} }
return { h: h, s: sat * 100, l: l * 100 }; 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 cromaticos = [];
const neutrales = []; const neutrales = [];
for (const hex of distintos) { for (const hex of distintos) {
@@ -639,7 +708,11 @@ for (let i = 0; i < cromaticos.length; i++) {
const saturacionMedia = cromaticos.length const saturacionMedia = cromaticos.length
? cromaticos.reduce(function (a, c) { return a + c.s; }, 0) / cromaticos.length : 0; ? cromaticos.reduce(function (a, c) { return a + c.s; }, 0) / cromaticos.length : 0;
return { const resultado = {
explicitFillShare: conFill > 0 ? conFillExplicito / conFill : 0,
shapesWithFill: conFill,
shapesWithExplicitFill: conFillExplicito,
onlyDefaultColors: soloDefaults,
chromaticFills: cromaticos.length, chromaticFills: cromaticos.length,
chromaticSamples: cromaticos.slice(0, 8).map(function (c) { chromaticSamples: cromaticos.slice(0, 8).map(function (c) {
return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }), return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }),
@@ -678,6 +751,13 @@ return {
markupLength: markupLen, markupLength: markupLen,
renderError: renderError renderError: renderError
}; };
// 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));
""" """
@@ -687,8 +767,12 @@ def build_setup_js(page_name, seed_board):
.replace("%SEED%", js_string(seed_board) if seed_board else "null")) .replace("%SEED%", js_string(seed_board) if seed_board else "null"))
def build_audit_js(root_id): def build_audit_js(page_id):
return AUDIT_JS.replace("%ROOT_ID%", js_string(root_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(): def self_check_gate_payloads():
@@ -776,6 +860,8 @@ def check_disjunto(prompts):
METRICS = [ METRICS = [
("shapeCount", "conteo de shapes", "min"), ("shapeCount", "conteo de shapes", "min"),
("distinctFillColors", "fillColor distintos (sin blanco/negro puros)", "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"), ("chromaticFills", "fills cromaticos (sat HSL >= 45, L 15-85)", "min"),
("meanChromaticSaturation", "saturacion media de los fills cromaticos", "min"), ("meanChromaticSaturation", "saturacion media de los fills cromaticos", "min"),
("paletteStructured", "paleta estructurada (marca + acento + neutrales)", "bool"), ("paletteStructured", "paleta estructurada (marca + acento + neutrales)", "bool"),
@@ -797,7 +883,7 @@ METRICS = [
("rendersOk", "el arbol renderiza (generateStyle/generateMarkup)", "bool"), ("rendersOk", "el arbol renderiza (generateStyle/generateMarkup)", "bool"),
] ]
VETO_METRICS = ("placeholderGreys", "forbiddenBehavior") VETO_METRICS = ("placeholderGreys", "forbiddenBehavior", "onlyDefaultColors")
# Umbrales por prompt. `None` = la metrica NO aplica a ese prompt y no entra en el denominador # 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 # del score. Estan aca y no en el .jsonl a proposito: el .jsonl describe la TAREA, el umbral es
@@ -823,6 +909,22 @@ UMBRALES_VIBRACION = {
} }
UMBRALES_VIBRACION_NO_APLICA = {k: None for k in UMBRALES_VIBRACION} 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 = { THRESHOLDS = {
"g5-01-boton-primario": { "g5-01-boton-primario": {
"shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0, "shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0,
@@ -832,6 +934,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_VIBRACION_NO_APLICA,
**UMBRALES_FILL_EXPLICITO_BAJA,
}, },
"g5-02-navbar-flex": { "g5-02-navbar-flex": {
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0, "shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -841,6 +944,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_VIBRACION_NO_APLICA,
**UMBRALES_FILL_EXPLICITO_BAJA,
}, },
"g5-03-card-producto": { "g5-03-card-producto": {
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0, "shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -850,6 +954,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_VIBRACION_NO_APLICA,
**UMBRALES_FILL_EXPLICITO_MEDIA,
}, },
"g5-04-tokens-biblioteca": { "g5-04-tokens-biblioteca": {
"shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0, "shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0,
@@ -859,6 +964,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_VIBRACION_NO_APLICA,
**UMBRALES_FILL_EXPLICITO_MEDIA,
}, },
"g5-05-grid-tres-diferenciales": { "g5-05-grid-tres-diferenciales": {
"shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0, "shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -868,6 +974,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_VIBRACION_NO_APLICA,
**UMBRALES_FILL_EXPLICITO_MEDIA,
}, },
"g5-09-brief-ambiguo-escuela": { "g5-09-brief-ambiguo-escuela": {
"shapeCount": 30, "distinctFillColors": 6, "placeholderGreys": 0, "shapeCount": 30, "distinctFillColors": 6, "placeholderGreys": 0,
@@ -877,6 +984,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION, **UMBRALES_VIBRACION,
**UMBRALES_FILL_EXPLICITO,
}, },
"g5-10-brief-ambiguo-notaria": { "g5-10-brief-ambiguo-notaria": {
"shapeCount": 30, "distinctFillColors": 5, "placeholderGreys": 0, "shapeCount": 30, "distinctFillColors": 5, "placeholderGreys": 0,
@@ -889,6 +997,7 @@ THRESHOLDS = {
# pero la paleta tiene que estar estructurada y declarada igual. # pero la paleta tiene que estar estructurada y declarada igual.
"chromaticFills": 1, "meanChromaticSaturation": 30, "chromaticFills": 1, "meanChromaticSaturation": 30,
"paletteStructured": True, "finalMessageListsHex": True, "paletteStructured": True, "finalMessageListsHex": True,
**UMBRALES_FILL_EXPLICITO,
}, },
"g5-06-landing-pizzeria": { "g5-06-landing-pizzeria": {
"shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0, "shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0,
@@ -898,6 +1007,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION, **UMBRALES_VIBRACION,
**UMBRALES_FILL_EXPLICITO,
}, },
"g5-07-onboarding-mobile": { "g5-07-onboarding-mobile": {
"shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0, "shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0,
@@ -907,6 +1017,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_VIBRACION_NO_APLICA,
**UMBRALES_FILL_EXPLICITO,
}, },
"g5-08-reparacion-grises": { "g5-08-reparacion-grises": {
"shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0, "shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0,
@@ -916,6 +1027,7 @@ THRESHOLDS = {
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA, **UMBRALES_VIBRACION_NO_APLICA,
**UMBRALES_FILL_EXPLICITO,
}, },
} }
@@ -941,6 +1053,10 @@ def build_raw_metrics(audit, runtime):
"exportBeforeFinal": runtime["exportBeforeFinal"], "exportBeforeFinal": runtime["exportBeforeFinal"],
"forbiddenBehavior": runtime["forbiddenBehavior"], "forbiddenBehavior": runtime["forbiddenBehavior"],
"exceptionRate": runtime["exceptionRate"], "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), "chromaticFills": audit.get("chromaticFills", 0),
"meanChromaticSaturation": audit.get("meanChromaticSaturation", 0), "meanChromaticSaturation": audit.get("meanChromaticSaturation", 0),
"paletteStructured": bool(audit.get("paletteStructured", False)), "paletteStructured": bool(audit.get("paletteStructured", False)),
@@ -1214,7 +1330,7 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
# --- auditoria inyectada por la puerta ------------------------------------------------ # --- auditoria inyectada por la puerta ------------------------------------------------
try: try:
audit = mcp.execute_json(build_audit_js(root_id), f"{prompt_id}: auditoria") audit = mcp.execute_json(build_audit_js(setup["pageId"]), f"{prompt_id}: auditoria")
except Gate5Error as e: except Gate5Error as e:
print(f"[WARN] {prompt_id}: la auditoria fallo ({e})") print(f"[WARN] {prompt_id}: la auditoria fallo ({e})")
audit = {"auditError": str(e)} audit = {"auditError": str(e)}
@@ -1235,6 +1351,28 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
except Gate5Error as e: except Gate5Error as e:
print(f"[WARN] {prompt_id}: no se pudo exportar el PNG ({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(setup["pageId"]),
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 # 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. # que la pueda ajustar. Una paleta elegida en silencio no es colaborable.
hex_en_final = set(HEX_EN_TEXTO.findall(final_content or "")) hex_en_final = set(HEX_EN_TEXTO.findall(final_content or ""))
@@ -1409,29 +1547,48 @@ def load_prompts():
return filas return filas
def resumen(resultados, tag): def resumen(resultados, tag, esperados=None):
scores = [r["score"] for r in resultados] """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 media = sum(scores) / len(scores) if scores else 0.0
veto_limpio = sum(1 for r in resultados if not r["veto_violado"]) veto_limpio = sum(1 for r in medidos if not r["veto_violado"])
sobre_minimo = sum(1 for r in resultados if r["score"] >= PROMPT_SCORE_MIN) sobre_minimo = sum(1 for r in medidos if r["score"] >= PROMPT_SCORE_MIN)
flagship = next((r for r in resultados if r["id"] == FLAGSHIP_PROMPT_ID), None) flagship = next((r for r in medidos if r["id"] == FLAGSHIP_PROMPT_ID), None)
flagship_score = flagship["score"] if flagship else 0.0 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"\n=== Puerta 5 -- calidad de diseno en Penpot (tag={tag}) ===\n")
print(f" {'prompt':32s} {'dif':6s} {'score':>6s} {'pasa':>7s} {'grises':>7s} " print(f" {'prompt':32s} {'dif':6s} {'score':>6s} {'pasa':>7s} {'grises':>7s} "
f"{'prohib':>7s} {'shapes':>7s} {'colores':>8s} {'textos':>7s}") f"{'prohib':>7s} {'shapes':>7s} {'colores':>8s} {'textos':>7s}")
for r in resultados: for r in medidos:
crudas = r["metricas_crudas"] crudas = r["metricas_crudas"]
print(f" {r['id']:32s} {str(r['dificultad'])[:6]:6s} {r['score']:6.1f} " 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"{r['pasadas']:3d}/{r['aplicables']:<3d} {crudas['placeholderGreys']:7d} "
f"{crudas['forbiddenBehavior']:7d} {crudas['shapeCount']:7d} " f"{crudas['forbiddenBehavior']:7d} {crudas['shapeCount']:7d} "
f"{crudas['distinctFillColors']:8d} {crudas['textsWithCharacters']: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 = [ 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"score medio >= {MEAN_SCORE_MIN:.0f}", media >= MEAN_SCORE_MIN, f"{media:.1f}"),
("veto limpio en 8/8", veto_limpio == len(resultados), f"{veto_limpio}/{len(resultados)}"), (f"veto limpio en {total_esperado}/{total_esperado}", veto_limpio == total_esperado,
(f">= {MIN_PROMPTS_OVER_MIN}/8 prompts >= {PROMPT_SCORE_MIN:.0f}", f"{veto_limpio}/{total_esperado}"),
sobre_minimo >= MIN_PROMPTS_OVER_MIN, f"{sobre_minimo}/{len(resultados)}"), (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}", (f"landing de pizzeria >= {PROMPT_SCORE_MIN:.0f}",
flagship_score >= PROMPT_SCORE_MIN, f"{flagship_score:.1f}"), flagship_score >= PROMPT_SCORE_MIN, f"{flagship_score:.1f}"),
] ]
@@ -1440,8 +1597,14 @@ def resumen(resultados, tag):
print(f" [{'ok ' if ok else 'NO '}] {etiqueta:40s} -> {valor}") print(f" [{'ok ' if ok else 'NO '}] {etiqueta:40s} -> {valor}")
aprueba = all(ok for _e, ok, _v in condiciones) aprueba = all(ok for _e, ok, _v in condiciones)
print(f"\n VEREDICTO GLOBAL: {'APRUEBA' if aprueba else 'NO APRUEBA'}") 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, { return aprueba, {
"valido": not no_medidos,
"no_medidos": [r["id"] for r in no_medidos],
"score_medio": round(media, 2), "score_medio": round(media, 2),
"veto_limpio": veto_limpio, "veto_limpio": veto_limpio,
"prompts_sobre_minimo": sobre_minimo, "prompts_sobre_minimo": sobre_minimo,
@@ -1453,7 +1616,31 @@ def resumen(resultados, tag):
def run_full(cfg, tools, system_prompt): def run_full(cfg, tools, system_prompt):
prompts = load_prompts() prompts = load_prompts()
todos_los_prompts = list(prompts)
check_disjunto(prompts) check_disjunto(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() self_check_gate_payloads()
mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"]) mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"])
@@ -1467,6 +1654,13 @@ def run_full(cfg, tools, system_prompt):
png_dir = REPO_ROOT / "data" / f"gate5_png_{cfg['GATE5_TAG']}" png_dir = REPO_ROOT / "data" / f"gate5_png_{cfg['GATE5_TAG']}"
salida = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json" 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): def volcar(resultados_parciales, dt_parcial, veredicto_parcial=None, completo=False):
"""Escribe el JSON de resultados. Se llama DESPUES DE CADA PROMPT. """Escribe el JSON de resultados. Se llama DESPUES DE CADA PROMPT.
@@ -1483,14 +1677,14 @@ def run_full(cfg, tools, system_prompt):
"max_tokens": MAX_TOKENS, "max_tokens": MAX_TOKENS,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"duracion_s": round(dt_parcial, 1), "duracion_s": round(dt_parcial, 1),
"prompts_completados": len(resultados_parciales), "prompts_completados": len(fusionar(resultados_parciales)),
"prompts_totales": len(prompts), "prompts_totales": len(todos_los_prompts),
"metricas": [{"clave": k, "etiqueta": e, "comparador": c} for k, e, c in METRICS], "metricas": [{"clave": k, "etiqueta": e, "comparador": c} for k, e, c in METRICS],
"veto": list(VETO_METRICS), "veto": list(VETO_METRICS),
"umbrales": {pid: {k: (list(v) if isinstance(v, tuple) else v) for k, v in u.items()} "umbrales": {pid: {k: (list(v) if isinstance(v, tuple) else v) for k, v in u.items()}
for pid, u in THRESHOLDS.items()}, for pid, u in THRESHOLDS.items()},
"veredicto": veredicto_parcial, "veredicto": veredicto_parcial,
"prompts": resultados_parciales, "prompts": fusionar(resultados_parciales),
}, ensure_ascii=False, indent=2), encoding="utf-8") }, ensure_ascii=False, indent=2), encoding="utf-8")
t0 = time.time() t0 = time.time()
@@ -1509,10 +1703,18 @@ def run_full(cfg, tools, system_prompt):
volcar(resultados, time.time() - t0) volcar(resultados, time.time() - t0)
dt = time.time() - t0 dt = time.time() - t0
aprueba, veredicto = resumen(resultados, cfg["GATE5_TAG"]) aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"],
esperados=len(todos_los_prompts))
print(f"\n tiempo total: {dt / 60:.1f} min") print(f"\n tiempo total: {dt / 60:.1f} min")
volcar(resultados, dt, veredicto_parcial=veredicto, completo=True) volcar(resultados, dt, veredicto_parcial=veredicto, completo=True)
sin_medir = [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.")
print(f" Pedile al usuario que RECARGUE el plugin en el navegador y retoma con:")
print(f" GATE5_ONLY={','.join(sin_medir)}")
return EXIT_PLUGIN_DEGRADADO
print(f" resultados en {salida.relative_to(REPO_ROOT)}") 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 " print(f" PNGs en {png_dir.relative_to(REPO_ROOT)}/ (la puerta no borra nada: las paginas "
f"quedan para inspeccion humana)") f"quedan para inspeccion humana)")