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 387b237ee5 - Show all commits
+74 -12
View File
@@ -99,6 +99,10 @@ AUDIT_MAX_RETRIES = int(os.environ.get("GATE5_AUDIT_RETRIES", "5"))
# inspeccion manual con GATE5_KEEP_PAGES=1. # inspeccion manual con GATE5_KEEP_PAGES=1.
LIMPIAR_PAGINAS = os.environ.get("GATE5_KEEP_PAGES", "").lower() not in ("1", "true", "yes") LIMPIAR_PAGINAS = os.environ.get("GATE5_KEEP_PAGES", "").lower() not in ("1", "true", "yes")
# Tamano de lote. Ver el comentario del bucle en run_full(): el plugin se degrada tras 5 o 6
# 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"))
# 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"
@@ -635,7 +639,17 @@ if (principal) {
} }
} }
let cssLen = 0, markupLen = 0, renderError = null; // generateStyle con includeChildren y generateMarkup son, de lejos, lo mas caro de la auditoria:
// serializan el subarbol entero. El servidor MCP corta cualquier tarea del plugin a los 30
// segundos -- ese limite es SUYO, no del cliente, asi que no se puede subir desde aca; lo unico
// que se puede hacer es no acercarse. Por encima de MAX_NODOS_RENDER se saltea y se deja
// constancia, en vez de arriesgar que se caiga la auditoria ENTERA y con ella la medicion del
// prompt. `rendersOk` queda en null (no aplica) y no entra al denominador del score.
const MAX_NODOS_RENDER = 400;
let cssLen = 0, markupLen = 0, renderError = null, renderSalteado = false;
if (nodes.length > MAX_NODOS_RENDER) {
renderSalteado = true;
} else {
try { try {
const objetivo = principal ? [principal] : top; const objetivo = principal ? [principal] : top;
if (objetivo.length > 0) { if (objetivo.length > 0) {
@@ -647,6 +661,7 @@ try {
} catch (e) { } catch (e) {
renderError = String((e && e.message) || e); renderError = String((e && e.message) || e);
} }
}
const tamsUnicos = []; const tamsUnicos = [];
for (const tam of fontSizes) { if (tamsUnicos.indexOf(tam) === -1) { tamsUnicos.push(tam); } } for (const tam of fontSizes) { if (tamsUnicos.indexOf(tam) === -1) { tamsUnicos.push(tam); } }
@@ -749,7 +764,8 @@ const resultado = {
rootHeightMismatch: desajusteAltura, rootHeightMismatch: desajusteAltura,
cssLength: cssLen, cssLength: cssLen,
markupLength: markupLen, markupLength: markupLen,
renderError: renderError renderError: renderError,
renderSkipped: renderSalteado
}; };
// El servidor MCP valida el resultado contra un schema y rechaza la llamada ENTERA si algun // El servidor MCP valida el resultado contra un schema y rechaza la llamada ENTERA si algun
@@ -1062,8 +1078,13 @@ def build_raw_metrics(audit, runtime):
"paletteStructured": bool(audit.get("paletteStructured", False)), "paletteStructured": bool(audit.get("paletteStructured", False)),
"finalMessageListsHex": runtime["finalMessageListsHex"], "finalMessageListsHex": runtime["finalMessageListsHex"],
"fontIdBoundShare": audit.get("fontIdBoundShare", 0.0), "fontIdBoundShare": audit.get("fontIdBoundShare", 0.0),
"rendersOk": bool(audit.get("cssLength", 0) > 0 and audit.get("markupLength", 0) > 0 # Si la auditoria salteo el render por tamano, la metrica NO APLICA (None): no se puede
and not audit.get("renderError")), # afirmar que el arbol renderiza ni que no renderiza, y contarla como fallo castigaria
# justo a los disenos grandes, que es lo contrario de lo que la puerta quiere premiar.
"rendersOk": (None if audit.get("renderSkipped")
else bool(audit.get("cssLength", 0) > 0
and audit.get("markupLength", 0) > 0
and not audit.get("renderError"))),
} }
@@ -1088,7 +1109,11 @@ def score_prompt(prompt_id, raw):
veto = [] veto = []
for clave, etiqueta, comparador in METRICS: for clave, etiqueta, comparador in METRICS:
umbral = umbrales.get(clave) umbral = umbrales.get(clave)
ok = evaluar(clave, comparador, raw[clave], umbral) # Una metrica NO APLICA si su umbral es None (el prompt no la exige) o si su VALOR es
# None (la auditoria no la pudo computar, p.ej. el render salteado por tamano). Contar
# "no se pudo medir" como fallo castigaria al diseno grande, que es lo contrario de lo
# que la puerta premia.
ok = None if raw[clave] is None else evaluar(clave, comparador, raw[clave], umbral)
detalle.append({ detalle.append({
"metrica": clave, "etiqueta": etiqueta, "valor": raw[clave], "metrica": clave, "etiqueta": etiqueta, "valor": raw[clave],
"umbral": umbral, "aplica": ok is not None, "pasa": ok, "umbral": umbral, "aplica": ok is not None, "pasa": ok,
@@ -1689,18 +1714,54 @@ def run_full(cfg, tools, system_prompt):
t0 = time.time() t0 = time.time()
resultados = [] resultados = []
for fila in prompts: pendientes = []
corte_por_degradacion = False
# 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}")
mcp.session_id = None
try:
mcp.handshake()
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
break
print(f"\n[LOTE] {n_lote}/{total_lotes}: {[f['id'] for f in lote]}")
for i, fila in enumerate(lote):
try: try:
resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir)) resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir))
except PluginNotConnected: except PluginNotConnected:
volcar(resultados, time.time() - t0) volcar(resultados, time.time() - t0)
raise raise
except Gate5Error as e: 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}") print(f"[ERROR] {fila['id']}: {e}")
print(f"[INFO] se guardan los {len(resultados)} prompt(s) ya medidos y se sigue con " restantes = [f["id"] for f in lote[i:]] + \
f"el siguiente; el JSON queda marcado como parcial") [f["id"] for f in prompts[inicio + BATCH_SIZE:]]
resultados.append({"id": fila["id"], "error": str(e), "score": None}) 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
volcar(resultados, time.time() - t0) volcar(resultados, time.time() - t0)
if corte_por_degradacion:
break
dt = time.time() - t0 dt = time.time() - t0
aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"], aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"],
@@ -1708,7 +1769,8 @@ def run_full(cfg, tools, system_prompt):
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] sin_medir = sorted(set(pendientes) |
{r["id"] for r in fusionar(resultados) if r.get("score") is None})
if sin_medir: if sin_medir:
print(f"\n[PLUGIN DEGRADADO] {len(sin_medir)} prompt(s) no se pudieron medir. El plugin " 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.") f"de Penpot responde pero se degrada tras 5 o 6 prompts pesados.")
@@ -1716,8 +1778,8 @@ def run_full(cfg, tools, system_prompt):
print(f" GATE5_ONLY={','.join(sin_medir)}") print(f" GATE5_ONLY={','.join(sin_medir)}")
return EXIT_PLUGIN_DEGRADADO 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 pagina de cada prompt se vacia tras "
f"quedan para inspeccion humana)") f"exportar su PNG; solo se conserva la de un prompt cuyo PNG haya fallado)")
return 0 if aprueba else 1 return 0 if aprueba else 1