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
6 changed files with 1309 additions and 1205 deletions
Showing only changes of commit ad62277f14 - Show all commits
+251 -251
View File
File diff suppressed because one or more lines are too long
+97 -97
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+843 -843
View File
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -93,12 +93,20 @@ PORTION_OF_GROUP = {
"B5": "diseno", "B6": "diseno", "B7": "diseno", "B8": "diseno", "B5": "diseno", "B6": "diseno", "B7": "diseno", "B8": "diseno",
"C": "api_preguntar", "C": "api_preguntar",
"D": "api_recuperacion", "D": "api_recuperacion",
# Brief ambiguo: el usuario da UN adjetivo y ninguna paleta, y el modelo tiene que elegirla,
# justificarla y declararla. Es el painpoint principal del usuario y la contramedida directa
# a la cuarta causa raiz (la instruccion del servidor de no elegir colores propios).
# Va como porcion PROPIA y no dentro de 'diseno' para garantizarle un piso: diluida entre los
# 76 seeds de diseno quedaria a merced de la proporcion, y esta clase es justamente la que no
# puede quedar corta.
"E": "diseno_brief_ambiguo",
} }
# Los targets son sobre la mezcla de 1000; las proporciones son las mismas que documenta el # Los targets son sobre la mezcla de 1000; las proporciones son las mismas que documenta el
# docstring (33.3 / 6.7 / 4.4 / 2.2 y 53.3 de replay). # docstring (33.3 / 6.7 / 4.4 / 2.2 y 53.3 de replay).
PORTION_TARGETS = { PORTION_TARGETS = {
"diseno": 333, "diseno": 253,
"diseno_brief_ambiguo": 80,
"api_forma_correcta": 67, "api_forma_correcta": 67,
"api_recuperacion": 44, "api_recuperacion": 44,
"api_preguntar": 23, "api_preguntar": 23,
+88 -12
View File
@@ -75,6 +75,21 @@ DISJOINT_AGAINST = [
] ]
SHINGLE_N = 6 SHINGLE_N = 6
# Rubros que usan los prompts de la puerta. Ningun seed puede compartirlos.
#
# Los shingles de 6-gramas NO atrapan esto: "necesito la home de una escuela de musica para
# chicos" (puerta) y "estoy redisenando el sitio de la escuela de musica" (seed) no comparten
# ningun 6-grama literal y sin embargo son el MISMO negocio. Entrenar sobre el dominio que
# despues se evalua infla el resultado sin que se note en ninguna metrica -- es la misma fuga
# que la de las palabras, un nivel mas arriba. Los sinonimos y las parafrasis del mismo rubro
# solo se atrapan nombrando el rubro.
RUBROS_DE_LA_PUERTA = [
"pizzeria", "pizzería",
"escuela de musica", "escuela de música",
"notaria", "notaría",
"trattoria",
]
# Reintento de transporte contra el MCP de Penpot. El servidor corta la conexion a mitad de # Reintento de transporte contra el MCP de Penpot. El servidor corta la conexion a mitad de
# llamada de forma intermitente; ya se habia visto en la puerta 4 de la Fase 4. # llamada de forma intermitente; ya se habia visto en la puerta 4 de la Fase 4.
MCP_MAX_RETRIES = int(os.environ.get("PENPOT_MCP_RETRIES", "4")) MCP_MAX_RETRIES = int(os.environ.get("PENPOT_MCP_RETRIES", "4"))
@@ -481,7 +496,11 @@ if (seed) {
return { return {
pageId: page.id, pageId: page.id,
pageName: page.name, pageName: page.name,
rootId: penpot.root.id, // NO se devuelve penpot.root.id. `penpot.root` es el root de la pagina ACTIVA, y despues de
// `createPage` + `openPage` puede no haberse actualizado todavia: en la primera corrida con
// borrado de paginas activado devolvio "" en el segundo prompt. Ademas todas las paginas
// comparten el mismo id de root, asi que ese valor nunca sirvio para identificar nada. El
// pageId es lo unico unico y estable, y es lo que usa la auditoria.
seedBoardId: seedBoardId seedBoardId: seedBoardId
}; };
""" """
@@ -846,6 +865,39 @@ def corpus_shingles(paths):
return acumulado return acumulado
def check_rubros(prompts):
"""Ningun seed puede compartir el RUBRO de un prompt de la puerta. Ver RUBROS_DE_LA_PUERTA."""
import unicodedata as _ud
def plano(t):
t = _ud.normalize("NFKD", (t or "").lower())
return "".join(c for c in t if not _ud.combining(c))
rubros = sorted({plano(r) for r in RUBROS_DE_LA_PUERTA})
problemas = []
seeds_path = REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl"
if not seeds_path.exists():
print("[WARN] no hay corpus de seeds: se saltea el chequeo de rubros")
return
with open(seeds_path, encoding="utf-8") as f:
for lineno, linea in enumerate(f, start=1):
linea = linea.strip()
if not linea:
continue
texto = plano(linea)
for rubro in rubros:
if rubro in texto:
problemas.append(f"seed {lineno}: usa el rubro {rubro!r} de la puerta 5")
break
if problemas:
raise Gate5Error(
"hay seeds que comparten RUBRO con los prompts de la puerta 5 (fuga de dominio: los "
"shingles no la ven porque no hace falta compartir palabras para compartir negocio):"
"\n - " + "\n - ".join(problemas[:20])
)
print(f"[OK] ningun seed usa los rubros de la puerta ({', '.join(rubros)})")
def check_disjunto(prompts): def check_disjunto(prompts):
corpus = corpus_shingles(DISJOINT_AGAINST) corpus = corpus_shingles(DISJOINT_AGAINST)
if not corpus: if not corpus:
@@ -1246,9 +1298,9 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
print(f"\n--- {prompt_id} ({fila.get('dificultad', '?')}) -> pagina {page_name}") print(f"\n--- {prompt_id} ({fila.get('dificultad', '?')}) -> pagina {page_name}")
setup = mcp.execute_json(build_setup_js(page_name, fila.get("seed_board")), setup = mcp.execute_json(build_setup_js(page_name, fila.get("seed_board")),
f"{prompt_id}: setup de la pagina") f"{prompt_id}: setup de la pagina")
root_id = setup.get("rootId") page_id = setup.get("pageId")
if not root_id: if not page_id:
raise Gate5Error(f"{prompt_id}: el setup no devolvio rootId: {setup}") raise Gate5Error(f"{prompt_id}: el setup no devolvio pageId: {setup}")
contexto = [ contexto = [
f"Trabajás en la página «{page_name}», que ya está creada y abierta " f"Trabajás en la página «{page_name}», que ya está creada y abierta "
@@ -1355,15 +1407,22 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
# --- auditoria inyectada por la puerta ------------------------------------------------ # --- auditoria inyectada por la puerta ------------------------------------------------
try: try:
audit = mcp.execute_json(build_audit_js(setup["pageId"]), f"{prompt_id}: auditoria") audit = mcp.execute_json(build_audit_js(page_id), f"{prompt_id}: auditoria")
except Gate5Error as e: except Gate5Error as e:
print(f"[WARN] {prompt_id}: la auditoria fallo ({e})") print(f"[WARN] {prompt_id}: la auditoria fallo ({e})")
audit = {"auditError": str(e)} audit = {"auditError": str(e)}
# --- PNG de la puerta (nunca reemplaza al export del modelo, se suma) ----------------- # --- PNG de la puerta (nunca reemplaza al export del modelo, se suma) -----------------
png_path = None png_path = None
shape_a_exportar = audit.get("mainBoardId") or root_id # Sin mainBoardId no hay nada util que exportar: el root de la pagina no se puede exportar
# por id (todas las paginas comparten el mismo), asi que se omite el PNG en vez de exportar
# el arbol equivocado.
shape_a_exportar = audit.get("mainBoardId")
if not shape_a_exportar:
print(f"[WARN] {prompt_id}: la auditoria no encontro un board principal; no hay PNG")
try: try:
if not shape_a_exportar:
raise Gate5Error("sin board principal que exportar")
_texto, binarios, _ = mcp.call_tool( _texto, binarios, _ = mcp.call_tool(
"export_shape", {"shapeId": shape_a_exportar, "format": "png", "mode": "shape"}) "export_shape", {"shapeId": shape_a_exportar, "format": "png", "mode": "shape"})
if binarios: if binarios:
@@ -1385,7 +1444,7 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
# Ademas le deja el archivo limpio al usuario. # Ademas le deja el archivo limpio al usuario.
if LIMPIAR_PAGINAS and png_path is not None: if LIMPIAR_PAGINAS and png_path is not None:
try: try:
info = mcp.execute_json(build_delete_page_js(setup["pageId"]), info = mcp.execute_json(build_delete_page_js(page_id),
f"{prompt_id}: limpieza de la pagina") f"{prompt_id}: limpieza de la pagina")
if info.get("borrada"): if info.get("borrada"):
print(f"[CLEAN] {info['nombre']}: {info['shapesBorrados']} shape(s) borrados " print(f"[CLEAN] {info['nombre']}: {info['shapesBorrados']} shape(s) borrados "
@@ -1422,7 +1481,7 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
"prompt": fila["prompt"], "prompt": fila["prompt"],
"pageId": setup.get("pageId"), "pageId": setup.get("pageId"),
"pageName": page_name, "pageName": page_name,
"rootId": root_id, "pageId_auditado": page_id,
"seedBoardId": setup.get("seedBoardId"), "seedBoardId": setup.get("seedBoardId"),
"turnos_usados": len(turnos), "turnos_usados": len(turnos),
"finish_reason": finish_reason, "finish_reason": finish_reason,
@@ -1643,6 +1702,7 @@ def run_full(cfg, tools, system_prompt):
prompts = load_prompts() prompts = load_prompts()
todos_los_prompts = list(prompts) todos_los_prompts = list(prompts)
check_disjunto(prompts) check_disjunto(prompts)
check_rubros(prompts)
# Re-medir un subconjunto sin quemar los 10. Existe porque el MCP de Penpot se cae: cuando # 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 # una corrida pierde 5 prompts por un corte de transporte, repetir los 10 desperdicia media
@@ -1772,11 +1832,27 @@ def run_full(cfg, tools, system_prompt):
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:
print(f"\n[PLUGIN DEGRADADO] {len(sin_medir)} prompt(s) no se pudieron medir. El plugin " # NO culpar al plugin por defecto. Un mensaje que manda al usuario a recargar el navegador
f"de Penpot responde pero se degrada tras 5 o 6 prompts pesados.") # cuando la causa es un bug de la puerta cuesta tiempo de las dos partes, y ya paso una
print(f" Pedile al usuario que RECARGUE el plugin en el navegador y retoma con:") # vez: el setup fallo por depender de `penpot.root` y el script igual imprimio "plugin
# degradado". Se clasifica por la evidencia del error, no por el hecho de que falte algo.
errores = " ".join(str(r.get("error", "")) for r in fusionar(resultados)
if r.get("score") is None)
parece_plugin = bool(re.search(
r"timed out after \d+ seconds|No Penpot plugin instances|ConnectionError|"
r"error de transporte", errores, re.I))
print(f"\n[INCOMPLETO] {len(sin_medir)} prompt(s) no se pudieron medir.")
if parece_plugin:
print(" Causa probable: el plugin de Penpot. Los errores son timeouts de tarea o "
"cortes de transporte.")
print(" Pedile al usuario que RECARGUE el plugin en el navegador y retoma con:")
else:
print(" Causa probable: un bug de la PUERTA, no del plugin -- los errores no son "
"timeouts ni cortes de transporte. Revisar el mensaje de error antes de "
"pedirle nada al usuario.")
print(" Cuando este arreglado, retoma con:")
print(f" GATE5_ONLY={','.join(sin_medir)}") print(f" GATE5_ONLY={','.join(sin_medir)}")
return EXIT_PLUGIN_DEGRADADO return EXIT_PLUGIN_DEGRADADO if parece_plugin else 1
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 pagina de cada prompt se vacia tras " print(f" PNGs en {png_dir.relative_to(REPO_ROOT)}/ (la pagina de cada prompt se vacia tras "
f"exportar su PNG; solo se conserva la de un prompt cuyo PNG haya fallado)") f"exportar su PNG; solo se conserva la de un prompt cuyo PNG haya fallado)")