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 869b00c924 - Show all commits
+113 -47
View File
@@ -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. # 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")) 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 # 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.
FLAGSHIP_PROMPT_ID = "g5-06-landing-pizzeria" 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.""" """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 # 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 # 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"] 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"] tag = cfg["GATE5_TAG"]
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
page_name = f"gate5/{tag}/{prompt_id}/{ts}" 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 finish_reason = None
for turno in range(1, MAX_TURNS + 1): for turno in range(1, MAX_TURNS + 1):
queda_tiempo(f"turno {turno}")
try: try:
choice = chat_completion(cfg, messages, tools) choice = chat_completion(cfg, messages, tools)
except requests.RequestException as e: 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 = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"])
mcp.handshake() mcp.handshake()
# Sonda barata: si el plugin no esta conectado, abortar ANTES de quemar los 8 prompts. # Sonda barata. Ya NO aborta: la corrida hace hasta MAX_PASADAS pasadas con handshake limpio,
sonda, _es_error = mcp.execute_code("return { gate5Probe: true };") # asi que un plugin que no responde ahora puede responder en la pasada 2. Abortar aca
if PLUGIN_NOT_CONNECTED in sonda: # desperdiciaria la ventana entera por un estado transitorio.
raise PluginNotConnected(sonda) try:
print(f"[MCP] sonda de conectividad del plugin: {sonda[:120]}") 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']}" 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"
@@ -1773,62 +1800,101 @@ def run_full(cfg, tools, system_prompt):
}, ensure_ascii=False, indent=2), encoding="utf-8") }, ensure_ascii=False, indent=2), encoding="utf-8")
t0 = time.time() t0 = time.time()
resultados = [] fin_de_pared = t0 + WALL_CLOCK_S
pendientes = [] medidos = {}
corte_por_degradacion = False ultimo_error = {}
# LOTES. El plugin de Penpot aguanta de forma confiable unos 5 o 6 prompts pesados y despues # CORRIDA UNICA, EN PASADAS. El MCP de Penpot se cae seguido y cada ventana en que funciona
# se degrada hasta dar timeouts de 30 s en `createPage`. Medido en tres corridas seguidas, # es cara, asi que la corrida no puede rendirse por el fallo de uno: recorre todos los
# siempre con la misma forma: los primeros funcionan, el resto falla en el setup sin # prompts, anota los que fallaron, y los reintenta en la pasada siguiente con un handshake
# excepcion. Procesar en lotes chicos con un handshake limpio en el medio le da al servidor # limpio en el medio. Los cortes anteriores nos costaron tres ventanas del usuario.
# un punto de recuperacion, y si igual se degrada se corta ahi en vez de quemar los que por_medir = list(prompts)
# faltan de a uno. for pasada in range(1, MAX_PASADAS + 1):
for inicio in range(0, len(prompts), BATCH_SIZE): if not por_medir:
lote = prompts[inicio:inicio + BATCH_SIZE] break
n_lote = inicio // BATCH_SIZE + 1 if time.time() > fin_de_pared:
total_lotes = (len(prompts) + BATCH_SIZE - 1) // BATCH_SIZE print(f"\n[PARED] se agoto el tope de {WALL_CLOCK_S // 60} min; quedan "
if inicio > 0: f"{len(por_medir)} sin medir")
print(f"\n[LOTE] handshake limpio antes del lote {n_lote}/{total_lotes}") 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 mcp.session_id = None
try: try:
mcp.handshake() 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: except Gate5Error as e:
print(f"[LOTE] el re-handshake fallo ({e}); se corta y quedan pendientes " print(f"[PASADA] el re-handshake fallo ({e}); se corta")
f"{len(prompts) - inicio} prompt(s)")
pendientes.extend(f["id"] for f in prompts[inicio:])
corte_por_degradacion = True
break 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: try:
resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir)) medidos[fila["id"]] = run_prompt(cfg, mcp, tools, system_prompt, fila,
except PluginNotConnected: png_dir, deadline=deadline)
volcar(resultados, time.time() - t0) except PluginNotConnected as e:
raise # Sin plugin no hay nada que medir en NINGUN prompt: se corta la pasada, pero la
except Gate5Error as e: # corrida NO aborta -- lo ya medido queda en disco y la pasada siguiente
# Un fallo de setup NO quema el prompt: queda PENDIENTE, no medido con score # reintenta con handshake limpio.
# None. Y se corta el lote, porque una vez que el plugin empieza a dar timeouts print(f"[ERROR] {fila['id']}: plugin no conectado ({str(e)[:90]})")
# los que siguen fallan todos igual -- insistir solo gasta paginas y ensucia el ultimo_error[fila["id"]] = f"PluginNotConnected: {str(e)[:200]}"
# JSON. fallaron.append(fila)
print(f"[ERROR] {fila['id']}: {e}") fallaron.extend(f for f in por_medir[por_medir.index(fila) + 1:])
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 break
volcar(resultados, time.time() - t0) except Gate5Error as e:
if corte_por_degradacion: # Un fallo de un prompt NO aborta la corrida ni quema el prompt: se reintenta.
break 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 dt = time.time() - t0
aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"], aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"],
esperados=len(todos_los_prompts)) 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=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) | sin_medir = sorted(set(pendientes) |
{r["id"] for r in fusionar(resultados) if r.get("score") is None}) {r["id"] for r in fusionar(resultados) if r.get("score") is None})
if sin_medir: if sin_medir: