diff --git a/scripts/35_gate5_penpot_design.py b/scripts/35_gate5_penpot_design.py index c0478ed..553e188 100644 --- a/scripts/35_gate5_penpot_design.py +++ b/scripts/35_gate5_penpot_design.py @@ -118,6 +118,16 @@ LIMPIAR_PAGINAS = os.environ.get("GATE5_KEEP_PAGES", "").lower() not in ("1", "t # 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" @@ -144,6 +154,10 @@ 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 # ------------------------------------------------------------------------------------------ @@ -1289,8 +1303,13 @@ def parse_tool_arguments(tool_call): # ------------------------------------------------------------------------------------------ # Loop de agente sobre un prompt # ------------------------------------------------------------------------------------------ -def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir): +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}" @@ -1325,6 +1344,7 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir): 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: @@ -1730,11 +1750,18 @@ def run_full(cfg, tools, system_prompt): mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"]) mcp.handshake() - # Sonda barata: si el plugin no esta conectado, abortar ANTES de quemar los 8 prompts. - sonda, _es_error = mcp.execute_code("return { gate5Probe: true };") - if PLUGIN_NOT_CONNECTED in sonda: - raise PluginNotConnected(sonda) - print(f"[MCP] sonda de conectividad del plugin: {sonda[:120]}") + # 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" @@ -1773,62 +1800,101 @@ def run_full(cfg, tools, system_prompt): }, ensure_ascii=False, indent=2), encoding="utf-8") t0 = time.time() - resultados = [] - pendientes = [] - corte_por_degradacion = False + fin_de_pared = t0 + WALL_CLOCK_S + medidos = {} + ultimo_error = {} - # 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}") + # 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"[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 + print(f"[PASADA] el re-handshake fallo ({e}); se corta") break - print(f"\n[LOTE] {n_lote}/{total_lotes}: {[f['id'] for f in lote]}") + else: + print(f"\n[PASADA 1/{MAX_PASADAS}] {len(por_medir)} prompt(s)") - for i, fila in enumerate(lote): + 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: - 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 + 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 - volcar(resultados, time.time() - t0) - if corte_por_degradacion: - 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) + 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=True) + 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: