Phase 6.3: stop the gate depending on penpot.root, and stop blaming the plugin for its own bugs

The setup returned penpot.root.id and the run aborted when it came back as
an empty string on the second prompt of a batch. penpot.root is the root of
the ACTIVE page, and after createPage plus openPage it need not have caught
up yet - a race the page-emptying code introduced, since that leaves the
emptied page active. The value was never useful anyway: every page shares
the same root id, so it identified nothing. Setup now returns only pageId,
which is unique and stable, and both the audit and the cleanup use it.

The diagnostic message is the second half of the same mistake. It printed
"PLUGIN DEGRADADO, ask the user to reload the browser" whenever any prompt
went unmeasured, regardless of why - so it said that for a failure that was
entirely the gate's own. A message that sends the user to reload their
browser when the bug is mine costs both of us time. It now classifies on
the error text: task timeouts and transport drops point at the plugin,
anything else points at the gate and says so explicitly.

Also closes two evaluation leaks the gate's own pre-flight caught, both in
the seeds rather than the gate prompts, since the prompts have to stay as a
real user would write them:
- A seed shared the 6-gram "la home de una escuela de" with gate prompt 9.
  My first fix was overwritten by a subagent still writing the file, which
  is why it reappeared.
- A seed used the same business as gate prompt 9 - a music school - without
  sharing any 6-gram. Shingles cannot see that: two texts describe the same
  business without sharing words. Training on the domain we then evaluate
  inflates the result invisibly. So the gate now also checks that no seed
  uses any of the gate's business nouns, listed explicitly.

The training mix was rebuilt: it had been assembled before the 20
ambiguous-brief seeds existed, so training on it would not have used the
corpus that was audited. The ambiguous-brief class gets its own mix portion
rather than being folded into design, because diluted across 76 design
seeds it would be at the mercy of a ratio, and that is the class the user
named as the main painpoint. 125 seeds, 168 unique payloads, 446 distinct
user prompts, 901 train and 99 eval.
This commit is contained in:
2026-07-30 20:07:40 +00:00
parent 387b237ee5
commit ad62277f14
6 changed files with 1309 additions and 1205 deletions
+9 -1
View File
@@ -93,12 +93,20 @@ PORTION_OF_GROUP = {
"B5": "diseno", "B6": "diseno", "B7": "diseno", "B8": "diseno",
"C": "api_preguntar",
"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
# docstring (33.3 / 6.7 / 4.4 / 2.2 y 53.3 de replay).
PORTION_TARGETS = {
"diseno": 333,
"diseno": 253,
"diseno_brief_ambiguo": 80,
"api_forma_correcta": 67,
"api_recuperacion": 44,
"api_preguntar": 23,
+88 -12
View File
@@ -75,6 +75,21 @@ DISJOINT_AGAINST = [
]
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
# 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"))
@@ -481,7 +496,11 @@ if (seed) {
return {
pageId: page.id,
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
};
"""
@@ -846,6 +865,39 @@ def corpus_shingles(paths):
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):
corpus = corpus_shingles(DISJOINT_AGAINST)
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}")
setup = mcp.execute_json(build_setup_js(page_name, fila.get("seed_board")),
f"{prompt_id}: setup de la pagina")
root_id = setup.get("rootId")
if not root_id:
raise Gate5Error(f"{prompt_id}: el setup no devolvio rootId: {setup}")
page_id = setup.get("pageId")
if not page_id:
raise Gate5Error(f"{prompt_id}: el setup no devolvio pageId: {setup}")
contexto = [
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 ------------------------------------------------
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:
print(f"[WARN] {prompt_id}: la auditoria fallo ({e})")
audit = {"auditError": str(e)}
# --- PNG de la puerta (nunca reemplaza al export del modelo, se suma) -----------------
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:
if not shape_a_exportar:
raise Gate5Error("sin board principal que exportar")
_texto, binarios, _ = mcp.call_tool(
"export_shape", {"shapeId": shape_a_exportar, "format": "png", "mode": "shape"})
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.
if LIMPIAR_PAGINAS and png_path is not None:
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")
if info.get("borrada"):
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"],
"pageId": setup.get("pageId"),
"pageName": page_name,
"rootId": root_id,
"pageId_auditado": page_id,
"seedBoardId": setup.get("seedBoardId"),
"turnos_usados": len(turnos),
"finish_reason": finish_reason,
@@ -1643,6 +1702,7 @@ def run_full(cfg, tools, system_prompt):
prompts = load_prompts()
todos_los_prompts = list(prompts)
check_disjunto(prompts)
check_rubros(prompts)
# 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
@@ -1772,11 +1832,27 @@ def run_full(cfg, tools, system_prompt):
sin_medir = sorted(set(pendientes) |
{r["id"] for r in fusionar(resultados) if r.get("score") is None})
if sin_medir:
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.")
print(f" Pedile al usuario que RECARGUE el plugin en el navegador y retoma con:")
# NO culpar al plugin por defecto. Un mensaje que manda al usuario a recargar el navegador
# cuando la causa es un bug de la puerta cuesta tiempo de las dos partes, y ya paso una
# 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)}")
return EXIT_PLUGIN_DEGRADADO
return EXIT_PLUGIN_DEGRADADO if parece_plugin else 1
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 "
f"exportar su PNG; solo se conserva la de un prompt cuyo PNG haya fallado)")