diff --git a/scripts/35_gate5_penpot_design.py b/scripts/35_gate5_penpot_design.py index 0787b04..8269092 100644 --- a/scripts/35_gate5_penpot_design.py +++ b/scripts/35_gate5_penpot_design.py @@ -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_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", "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 # 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" 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): @@ -399,7 +416,17 @@ class PenpotMCP: `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 = 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: parsed = json.loads(texto) except json.JSONDecodeError: @@ -458,10 +485,35 @@ return { # 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 = """ -const ROOT_ID = %ROOT_ID%; -const root = penpotUtils.findShapeById(ROOT_ID); -if (!root) { return { auditError: "root no encontrado" }; } +// 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) => { @@ -618,6 +670,23 @@ function aHsl(hex) { } 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) { @@ -639,7 +708,11 @@ for (let i = 0; i < cromaticos.length; i++) { const saturacionMedia = cromaticos.length ? 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, chromaticSamples: cromaticos.slice(0, 8).map(function (c) { return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }), @@ -678,6 +751,13 @@ return { markupLength: markupLen, 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")) -def build_audit_js(root_id): - return AUDIT_JS.replace("%ROOT_ID%", js_string(root_id)) +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(): @@ -776,6 +860,8 @@ def check_disjunto(prompts): 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"), @@ -797,7 +883,7 @@ METRICS = [ ("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 # 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} +# 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, @@ -832,6 +934,7 @@ THRESHOLDS = { "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, @@ -841,6 +944,7 @@ THRESHOLDS = { "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, @@ -850,6 +954,7 @@ THRESHOLDS = { "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, @@ -859,6 +964,7 @@ THRESHOLDS = { "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, @@ -868,6 +974,7 @@ THRESHOLDS = { "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, @@ -877,6 +984,7 @@ THRESHOLDS = { "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, @@ -889,6 +997,7 @@ THRESHOLDS = { # 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, @@ -898,6 +1007,7 @@ THRESHOLDS = { "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, @@ -907,6 +1017,7 @@ THRESHOLDS = { "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, @@ -916,6 +1027,7 @@ THRESHOLDS = { "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "fontIdBoundShare": 1.0, "rendersOk": True, **UMBRALES_VIBRACION_NO_APLICA, + **UMBRALES_FILL_EXPLICITO, }, } @@ -941,6 +1053,10 @@ def build_raw_metrics(audit, runtime): "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)), @@ -1214,7 +1330,7 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir): # --- auditoria inyectada por la puerta ------------------------------------------------ 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: print(f"[WARN] {prompt_id}: la auditoria fallo ({e})") audit = {"auditError": str(e)} @@ -1235,6 +1351,28 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir): 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(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 # que la pueda ajustar. Una paleta elegida en silencio no es colaborable. hex_en_final = set(HEX_EN_TEXTO.findall(final_content or "")) @@ -1409,29 +1547,48 @@ def load_prompts(): return filas -def resumen(resultados, tag): - scores = [r["score"] for r in resultados] +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 resultados if not r["veto_violado"]) - sobre_minimo = sum(1 for r in resultados if r["score"] >= PROMPT_SCORE_MIN) - flagship = next((r for r in resultados if r["id"] == FLAGSHIP_PROMPT_ID), None) + 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 resultados: + 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}"), - ("veto limpio en 8/8", veto_limpio == len(resultados), f"{veto_limpio}/{len(resultados)}"), - (f">= {MIN_PROMPTS_OVER_MIN}/8 prompts >= {PROMPT_SCORE_MIN:.0f}", - sobre_minimo >= MIN_PROMPTS_OVER_MIN, f"{sobre_minimo}/{len(resultados)}"), + (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}"), ] @@ -1440,8 +1597,14 @@ def resumen(resultados, tag): print(f" [{'ok ' if ok else 'NO '}] {etiqueta:40s} -> {valor}") 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, { + "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, @@ -1453,7 +1616,31 @@ def resumen(resultados, tag): def run_full(cfg, tools, system_prompt): prompts = load_prompts() + todos_los_prompts = list(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() 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']}" 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. @@ -1483,14 +1677,14 @@ def run_full(cfg, tools, system_prompt): "max_tokens": MAX_TOKENS, "timestamp": datetime.now(timezone.utc).isoformat(), "duracion_s": round(dt_parcial, 1), - "prompts_completados": len(resultados_parciales), - "prompts_totales": len(prompts), + "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": resultados_parciales, + "prompts": fusionar(resultados_parciales), }, ensure_ascii=False, indent=2), encoding="utf-8") t0 = time.time() @@ -1509,10 +1703,18 @@ def run_full(cfg, tools, system_prompt): volcar(resultados, 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") 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" PNGs en {png_dir.relative_to(REPO_ROOT)}/ (la puerta no borra nada: las paginas " f"quedan para inspeccion humana)")