Phase 6: train a second LoRA for real Penpot UI design capability #5
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user