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 7903 additions and 52 deletions
Showing only changes of commit 8094183940 - Show all commits
@@ -0,0 +1,77 @@
[OK] los 8 prompts son disjuntos de train/eval/seeds (7472 shingles de referencia)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[MCP] sonda de conectividad del plugin: {
"result": {
"gate5Probe": true
},
"log": ""
}
--- g5-01-boton-primario (baja) -> pagina gate5/prod-baseline/g5-01-boton-primario/20260730T175220Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-01-boton-primario: se agotaron los 14 turnos sin mensaje final
[WARN] g5-01-boton-primario: export_shape no devolvio binario
score=0.0 (2/14) grises=0 prohibido=7 shapes=0 colores=0 textos=0
--- g5-02-navbar-flex (baja) -> pagina gate5/prod-baseline/g5-02-navbar-flex/20260730T175644Z
[WARN] g5-02-navbar-flex: se agotaron los 14 turnos sin mensaje final
[WARN] g5-02-navbar-flex: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-03-card-producto (media) -> pagina gate5/prod-baseline/g5-03-card-producto/20260730T175753Z
[WARN] g5-03-card-producto: se agotaron los 14 turnos sin mensaje final
[WARN] g5-03-card-producto: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-04-tokens-biblioteca (media) -> pagina gate5/prod-baseline/g5-04-tokens-biblioteca/20260730T175855Z
[WARN] g5-04-tokens-biblioteca: se agotaron los 14 turnos sin mensaje final
[WARN] g5-04-tokens-biblioteca: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-05-grid-tres-diferenciales (media) -> pagina gate5/prod-baseline/g5-05-grid-tres-diferenciales/20260730T175957Z
[WARN] g5-05-grid-tres-diferenciales: se agotaron los 14 turnos sin mensaje final
[WARN] g5-05-grid-tres-diferenciales: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-06-landing-pizzeria (alta) -> pagina gate5/prod-baseline/g5-06-landing-pizzeria/20260730T180102Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-06-landing-pizzeria: export_shape no devolvio binario
score=0.0 (3/17) grises=0 prohibido=5 shapes=0 colores=0 textos=0
--- g5-07-onboarding-mobile (alta) -> pagina gate5/prod-baseline/g5-07-onboarding-mobile/20260730T180525Z
[WARN] g5-07-onboarding-mobile: se agotaron los 14 turnos sin mensaje final
[WARN] g5-07-onboarding-mobile: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-08-reparacion-grises (alta) -> pagina gate5/prod-baseline/g5-08-reparacion-grises/20260730T180627Z
[WARN] g5-08-reparacion-grises: se agotaron los 14 turnos sin mensaje final
[WARN] g5-08-reparacion-grises: export_shape no devolvio binario
score=26.7 (4/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
=== Puerta 5 -- calidad de diseno en Penpot (tag=prod-baseline) ===
prompt dif score pasa grises prohib shapes colores textos
g5-01-boton-primario baja 0.0 2/14 0 7 0 0 0
g5-02-navbar-flex baja 17.6 3/17 0 0 0 0 0
g5-03-card-producto media 20.0 3/15 0 0 0 0 0
g5-04-tokens-biblioteca media 20.0 3/15 0 0 0 0 0
g5-05-grid-tres-diferenciales media 17.6 3/17 0 0 0 0 0
g5-06-landing-pizzeria alta 0.0 3/17 0 5 0 0 0
g5-07-onboarding-mobile alta 17.6 3/17 0 0 0 0 0
g5-08-reparacion-grises alta 26.7 4/15 0 0 0 0 0
condiciones de aprobacion:
[NO ] score medio >= 65 -> 14.9
[NO ] veto limpio en 8/8 -> 6/8
[NO ] >= 6/8 prompts >= 60 -> 0/8
[NO ] landing de pizzeria >= 60 -> 0.0
VEREDICTO GLOBAL: NO APRUEBA
tiempo total: 15.1 min
resultados en data/gate5_results_prod-baseline.json
PNGs en data/gate5_png_prod-baseline/ (la puerta no borra nada: las paginas quedan para inspeccion humana)
+26 -48
View File
@@ -1,4 +1,5 @@
[OK] los 8 prompts son disjuntos de train/eval/seeds (7472 shingles de referencia) [SYSTEM] documento completo del servidor: 16293 chars (247 lineas)
[OK] los 10 prompts son disjuntos de train/eval/seeds (7472 shingles de referencia)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape [MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[MCP] sonda de conectividad del plugin: { [MCP] sonda de conectividad del plugin: {
"result": { "result": {
@@ -7,71 +8,48 @@
"log": "" "log": ""
} }
--- g5-01-boton-primario (baja) -> pagina gate5/prod-baseline/g5-01-boton-primario/20260730T175220Z --- g5-01-boton-primario (baja) -> pagina gate5/prod-baseline/g5-01-boton-primario/20260730T182503Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-01-boton-primario: se agotaron los 14 turnos sin mensaje final
[WARN] g5-01-boton-primario: export_shape no devolvio binario [WARN] g5-01-boton-primario: export_shape no devolvio binario
score=0.0 (2/14) grises=0 prohibido=7 shapes=0 colores=0 textos=0 score=0.0 (2/14) grises=0 prohibido=5 shapes=0 colores=0 textos=0
--- g5-02-navbar-flex (baja) -> pagina gate5/prod-baseline/g5-02-navbar-flex/20260730T175644Z --- g5-02-navbar-flex (baja) -> pagina gate5/prod-baseline/g5-02-navbar-flex/20260730T182608Z
[WARN] g5-02-navbar-flex: se agotaron los 14 turnos sin mensaje final [WARN] g5-02-navbar-flex: se agotaron los 14 turnos sin mensaje final
[WARN] g5-02-navbar-flex: export_shape no devolvio binario [WARN] g5-02-navbar-flex: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0 score=0.0 (3/17) grises=0 prohibido=1 shapes=0 colores=0 textos=0
--- g5-03-card-producto (media) -> pagina gate5/prod-baseline/g5-03-card-producto/20260730T175753Z --- g5-03-card-producto (media) -> pagina gate5/prod-baseline/g5-03-card-producto/20260730T182706Z
[WARN] g5-03-card-producto: se agotaron los 14 turnos sin mensaje final [WARN] g5-03-card-producto: se agotaron los 14 turnos sin mensaje final
[WARN] g5-03-card-producto: export_shape no devolvio binario [WARN] g5-03-card-producto: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0 score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-04-tokens-biblioteca (media) -> pagina gate5/prod-baseline/g5-04-tokens-biblioteca/20260730T175855Z --- g5-04-tokens-biblioteca (media) -> pagina gate5/prod-baseline/g5-04-tokens-biblioteca/20260730T182804Z
[WARN] g5-04-tokens-biblioteca: se agotaron los 14 turnos sin mensaje final
[WARN] g5-04-tokens-biblioteca: export_shape no devolvio binario [WARN] g5-04-tokens-biblioteca: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0 score=0.0 (3/15) grises=0 prohibido=1 shapes=0 colores=0 textos=0
--- g5-05-grid-tres-diferenciales (media) -> pagina gate5/prod-baseline/g5-05-grid-tres-diferenciales/20260730T175957Z --- g5-05-grid-tres-diferenciales (media) -> pagina gate5/prod-baseline/g5-05-grid-tres-diferenciales/20260730T182903Z
[WARN] g5-05-grid-tres-diferenciales: se agotaron los 14 turnos sin mensaje final
[WARN] g5-05-grid-tres-diferenciales: export_shape no devolvio binario [WARN] g5-05-grid-tres-diferenciales: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0 score=23.5 (4/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-06-landing-pizzeria (alta) -> pagina gate5/prod-baseline/g5-06-landing-pizzeria/20260730T180102Z --- g5-06-landing-pizzeria (alta) -> pagina gate5/prod-baseline/g5-06-landing-pizzeria/20260730T183005Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s [MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto) [MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape [MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-06-landing-pizzeria: la auditoria fallo (g5-06-landing-pizzeria: auditoria: el MCP no devolvio JSON (texto): Tool execution failed: Error: Task 374cec0b-2e20-40bc-b58e-44e7ae20b6b0 timed out after 30 seconds)
[WARN] g5-06-landing-pizzeria: export_shape no devolvio binario [WARN] g5-06-landing-pizzeria: export_shape no devolvio binario
score=0.0 (3/17) grises=0 prohibido=5 shapes=0 colores=0 textos=0 score=0.0 (2/21) grises=0 prohibido=1 shapes=0 colores=0 textos=0
--- g5-07-onboarding-mobile (alta) -> pagina gate5/prod-baseline/g5-07-onboarding-mobile/20260730T180525Z --- g5-07-onboarding-mobile (alta) -> pagina gate5/prod-baseline/g5-07-onboarding-mobile/20260730T183529Z
[WARN] g5-07-onboarding-mobile: se agotaron los 14 turnos sin mensaje final [ERROR] g5-07-onboarding-mobile: g5-07-onboarding-mobile: setup de la pagina: el MCP no devolvio JSON (texto): Tool execution failed: Error: Task 98dda7fa-baed-4e2e-99f4-fa8bf33c1326 timed out after 30 seconds
[WARN] g5-07-onboarding-mobile: export_shape no devolvio binario [INFO] se guardan los 6 prompt(s) ya medidos y se sigue con el siguiente; el JSON queda marcado como parcial
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-08-reparacion-grises (alta) -> pagina gate5/prod-baseline/g5-08-reparacion-grises/20260730T180627Z --- g5-08-reparacion-grises (alta) -> pagina gate5/prod-baseline/g5-08-reparacion-grises/20260730T183559Z
[WARN] g5-08-reparacion-grises: se agotaron los 14 turnos sin mensaje final [ERROR] g5-08-reparacion-grises: g5-08-reparacion-grises: setup de la pagina: el MCP no devolvio JSON (texto): Tool execution failed: Error: Task 725a0fd0-3c6e-4b4b-bf1c-bb65776c4fb0 timed out after 30 seconds
[WARN] g5-08-reparacion-grises: export_shape no devolvio binario [INFO] se guardan los 7 prompt(s) ya medidos y se sigue con el siguiente; el JSON queda marcado como parcial
score=26.7 (4/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
=== Puerta 5 -- calidad de diseno en Penpot (tag=prod-baseline) === --- g5-09-brief-ambiguo-escuela (alta) -> pagina gate5/prod-baseline/g5-09-brief-ambiguo-escuela/20260730T183629Z
prompt dif score pasa grises prohib shapes colores textos [ABORTA] el servidor MCP de Penpot no tiene ningun plugin conectado.
g5-01-boton-primario baja 0.0 2/14 0 7 0 0 0 Abri el archivo de Penpot en el navegador, arranca el plugin del MCP y espera a
g5-02-navbar-flex baja 17.6 3/17 0 0 0 0 0 que quede conectado; recien ahi volve a correr la puerta. Sin plugin conectado no
g5-03-card-producto media 20.0 3/15 0 0 0 0 0 tiene sentido gastar los 8 prompts.
g5-04-tokens-biblioteca media 20.0 3/15 0 0 0 0 0 Mensaje del servidor: Tool execution failed: Error: No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.
g5-05-grid-tres-diferenciales media 17.6 3/17 0 0 0 0 0
g5-06-landing-pizzeria alta 0.0 3/17 0 5 0 0 0
g5-07-onboarding-mobile alta 17.6 3/17 0 0 0 0 0
g5-08-reparacion-grises alta 26.7 4/15 0 0 0 0 0
condiciones de aprobacion:
[NO ] score medio >= 65 -> 14.9
[NO ] veto limpio en 8/8 -> 6/8
[NO ] >= 6/8 prompts >= 60 -> 0/8
[NO ] landing de pizzeria >= 60 -> 0.0
VEREDICTO GLOBAL: NO APRUEBA
tiempo total: 15.1 min
resultados en data/gate5_results_prod-baseline.json
PNGs en data/gate5_png_prod-baseline/ (la puerta no borra nada: las paginas quedan para inspeccion humana)
+3 -1
View File
@@ -3,6 +3,8 @@
{"id": "g5-03-card-producto", "dificultad": "media", "prompt": "Hacé una card de producto de 320 de ancho con una foto real traída de https://picsum.photos/seed/espresso/640/480, un título, una bajada de dos líneas, el precio destacado y un botón de compra. La foto tiene que ser la imagen de verdad: nada de rectángulos grises simulando la imagen."} {"id": "g5-03-card-producto", "dificultad": "media", "prompt": "Hacé una card de producto de 320 de ancho con una foto real traída de https://picsum.photos/seed/espresso/640/480, un título, una bajada de dos líneas, el precio destacado y un botón de compra. La foto tiene que ser la imagen de verdad: nada de rectángulos grises simulando la imagen."}
{"id": "g5-04-tokens-biblioteca", "dificultad": "media", "prompt": "Creá en la biblioteca local del archivo una paleta de 6 colores nombrados por rol y una escala tipográfica de 5 pasos, y después aplicá esos tokens a una muestra visible: un encabezado, un párrafo de apoyo y tres chips que muestren los colores aplicados desde la biblioteca."} {"id": "g5-04-tokens-biblioteca", "dificultad": "media", "prompt": "Creá en la biblioteca local del archivo una paleta de 6 colores nombrados por rol y una escala tipográfica de 5 pasos, y después aplicá esos tokens a una muestra visible: un encabezado, un párrafo de apoyo y tres chips que muestren los colores aplicados desde la biblioteca."}
{"id": "g5-05-grid-tres-diferenciales", "dificultad": "media", "prompt": "Componé una sección de 3 diferenciales de 1140 de ancho resuelta con un grid de 3 columnas: cada columna con una figura geométrica propia a modo de ícono, un titular corto y dos líneas de texto de apoyo, todo con la misma alineación y respiración entre columnas."} {"id": "g5-05-grid-tres-diferenciales", "dificultad": "media", "prompt": "Componé una sección de 3 diferenciales de 1140 de ancho resuelta con un grid de 3 columnas: cada columna con una figura geométrica propia a modo de ícono, un titular corto y dos líneas de texto de apoyo, todo con la misma alineación y respiración entre columnas."}
{"id": "g5-06-landing-pizzeria", "dificultad": "alta", "prompt": "hazme una landing page de una pizzería napolitana con colores vibrantes, de 1200 de ancho"} {"id": "g5-06-landing-pizzeria", "dificultad": "alta", "prompt": "hazme una landing page de una pizzería con colores vibrantes", "brief": "ambiguo"}
{"id": "g5-07-onboarding-mobile", "dificultad": "alta", "prompt": "Diseñá una pantalla mobile de onboarding de 375x812 para una app de reservas: ilustración o figura de bienvenida arriba, titular, párrafo explicativo, indicador de tres pasos y dos acciones al pie (una primaria y una secundaria)."} {"id": "g5-07-onboarding-mobile", "dificultad": "alta", "prompt": "Diseñá una pantalla mobile de onboarding de 375x812 para una app de reservas: ilustración o figura de bienvenida arriba, titular, párrafo explicativo, indicador de tres pasos y dos acciones al pie (una primaria y una secundaria)."}
{"id": "g5-08-reparacion-grises", "dificultad": "alta", "prompt": "Este board está hecho enteramente de rectángulos grises y no tiene una sola línea de texto. Auditalo, definí una paleta y una escala tipográfica, y rehacelo hasta que sea una pantalla de verdad, con contenido legible y jerarquía visual.", "seed_board": {"nombre": "Wireframe sin disenar", "x": 0, "y": 0, "ancho": 960, "alto": 640, "gris": "#B1B2B5", "rectangulos": [{"nombre": "header", "x": 32, "y": 32, "ancho": 896, "alto": 88}, {"nombre": "hero", "x": 32, "y": 144, "ancho": 896, "alto": 208}, {"nombre": "card-1", "x": 32, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "card-2", "x": 336, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "card-3", "x": 640, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "footer", "x": 32, "y": 560, "ancho": 896, "alto": 48}]}} {"id": "g5-08-reparacion-grises", "dificultad": "alta", "prompt": "Este board está hecho enteramente de rectángulos grises y no tiene una sola línea de texto. Auditalo, definí una paleta y una escala tipográfica, y rehacelo hasta que sea una pantalla de verdad, con contenido legible y jerarquía visual.", "seed_board": {"nombre": "Wireframe sin disenar", "x": 0, "y": 0, "ancho": 960, "alto": 640, "gris": "#B1B2B5", "rectangulos": [{"nombre": "header", "x": 32, "y": 32, "ancho": 896, "alto": 88}, {"nombre": "hero", "x": 32, "y": 144, "ancho": 896, "alto": 208}, {"nombre": "card-1", "x": 32, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "card-2", "x": 336, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "card-3", "x": 640, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "footer", "x": 32, "y": 560, "ancho": 896, "alto": 48}]}}
{"id": "g5-09-brief-ambiguo-escuela", "dificultad": "alta", "brief": "ambiguo", "prompt": "necesito la home de una escuela de música para chicos, que sea alegre; no tengo paleta ni logo, elegí vos"}
{"id": "g5-10-brief-ambiguo-notaria", "dificultad": "alta", "brief": "ambiguo", "prompt": "quiero el sitio de una notaría, que transmita seriedad; no te paso colores"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+168 -2
View File
@@ -94,6 +94,8 @@ PROMPT_SCORE_MIN = 60.0
MIN_PROMPTS_OVER_MIN = 6 MIN_PROMPTS_OVER_MIN = 6
HOLDOUT_FORBIDDEN_RATE_MAX = 0.05 HOLDOUT_FORBIDDEN_RATE_MAX = 0.05
HEX_EN_TEXTO = re.compile(r"#[0-9a-fA-F]{6}\b")
PLUGIN_NOT_CONNECTED = "No Penpot plugin instances are currently connected" PLUGIN_NOT_CONNECTED = "No Penpot plugin instances are currently connected"
EXIT_PLUGIN_NOT_CONNECTED = 2 EXIT_PLUGIN_NOT_CONNECTED = 2
@@ -597,7 +599,54 @@ try {
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); } }
// VIBRACION. Ni `distinctFillColors` ni `placeholderGreys` distinguen una paleta vibrante de
// una apagada pero tecnicamente no gris: seis tonos polvorientos pasan las dos. Esto mide
// saturacion de verdad, en HSL.
function aHsl(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
const l = (mx + mn) / 2;
let h = 0, sat = 0;
if (d !== 0) {
sat = d / (1 - Math.abs(2 * l - 1));
if (mx === r) { h = ((g - b) / d) % 6; }
else if (mx === g) { h = (b - r) / d + 2; }
else { h = (r - g) / d + 4; }
h = h * 60; if (h < 0) { h += 360; }
}
return { h: h, s: sat * 100, l: l * 100 };
}
const cromaticos = [];
const neutrales = [];
for (const hex of distintos) {
const c = aHsl(hex);
// Cromatico: saturacion >= 45 y luminosidad en 15..85. La banda de luminosidad excluye los
// casi-negros y casi-blancos, que pueden dar saturacion alta y sin embargo leerse neutros.
if (c.s >= 45 && c.l >= 15 && c.l <= 85) { cromaticos.push({ hex: hex, h: c.h, s: c.s, l: c.l }); }
else if (c.s <= 15) { neutrales.push(hex); }
}
// Estructura de paleta: una marca dominante, un acento con tono claramente distinto, y neutrales.
let separacionDeTono = 0;
for (let i = 0; i < cromaticos.length; i++) {
for (let j = i + 1; j < cromaticos.length; j++) {
let d = Math.abs(cromaticos[i].h - cromaticos[j].h);
if (d > 180) { d = 360 - d; }
if (d > separacionDeTono) { separacionDeTono = d; }
}
}
const saturacionMedia = cromaticos.length
? cromaticos.reduce(function (a, c) { return a + c.s; }, 0) / cromaticos.length : 0;
return { return {
chromaticFills: cromaticos.length,
chromaticSamples: cromaticos.slice(0, 8).map(function (c) {
return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }),
meanChromaticSaturation: Math.round(saturacionMedia),
neutralFills: neutrales.length,
hueSeparation: Math.round(separacionDeTono),
paletteStructured: cromaticos.length >= 2 && neutrales.length >= 1 && separacionDeTono >= 30,
shapeCount: nodes.length, shapeCount: nodes.length,
distinctFillColors: distintos.length, distinctFillColors: distintos.length,
fillColors: distintos.slice(0, 24), fillColors: distintos.slice(0, 24),
@@ -727,6 +776,10 @@ def check_disjunto(prompts):
METRICS = [ METRICS = [
("shapeCount", "conteo de shapes", "min"), ("shapeCount", "conteo de shapes", "min"),
("distinctFillColors", "fillColor distintos (sin blanco/negro puros)", "min"), ("distinctFillColors", "fillColor distintos (sin blanco/negro puros)", "min"),
("chromaticFills", "fills cromaticos (sat HSL >= 45, L 15-85)", "min"),
("meanChromaticSaturation", "saturacion media de los fills cromaticos", "min"),
("paletteStructured", "paleta estructurada (marca + acento + neutrales)", "bool"),
("finalMessageListsHex", "el mensaje final enumera los hex elegidos", "bool"),
("placeholderGreys", "grises de placeholder [VETO]", "max"), ("placeholderGreys", "grises de placeholder [VETO]", "max"),
("textsWithCharacters", "textos con characters no vacio", "min"), ("textsWithCharacters", "textos con characters no vacio", "min"),
("typeScale", "fontSize distintos y maximo", "pair"), ("typeScale", "fontSize distintos y maximo", "pair"),
@@ -749,6 +802,27 @@ VETO_METRICS = ("placeholderGreys", "forbiddenBehavior")
# Umbrales por prompt. `None` = la metrica NO aplica a ese prompt y no entra en el denominador # Umbrales por prompt. `None` = la metrica NO aplica a ese prompt y no entra en el denominador
# del score. Estan aca y no en el .jsonl a proposito: el .jsonl describe la TAREA, el umbral es # del score. Estan aca y no en el .jsonl a proposito: el .jsonl describe la TAREA, el umbral es
# criterio de la puerta y se versiona junto al codigo que lo aplica. # criterio de la puerta y se versiona junto al codigo que lo aplica.
# ------------------------------------------------------------------------------------------
# Umbrales de VIBRACION, solo para los prompts de brief ambiguo
# ------------------------------------------------------------------------------------------
# Requisito explicito del usuario: ante un brief ambiguo el modelo tiene que ELEGIR una paleta y
# justificarla, no preguntar ni caer en defaults. Ni `distinctFillColors` ni `placeholderGreys`
# distinguen una paleta vibrante de una apagada pero tecnicamente no gris.
#
# El umbral de saturacion 45 NO es una opinion: sale de medir las 325 paletas no neutrales del
# corpus escrito a mano de esta fase. Su saturacion HSL mediana es 75, con p25 en 48 y p10 en 35.
# Un piso de 45 queda apenas por debajo del primer cuartil -- lo pasa el 79% de esos fills -- asi
# que es un suelo que el comportamiento objetivo despeja comodo, no una aspiracion. La banda de
# luminosidad 15..85 excluye casi-negros y casi-blancos, que pueden dar saturacion alta y aun asi
# leerse neutros.
UMBRALES_VIBRACION = {
"chromaticFills": 2,
"meanChromaticSaturation": 45,
"paletteStructured": True,
"finalMessageListsHex": True,
}
UMBRALES_VIBRACION_NO_APLICA = {k: None for k in UMBRALES_VIBRACION}
THRESHOLDS = { THRESHOLDS = {
"g5-01-boton-primario": { "g5-01-boton-primario": {
"shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0, "shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0,
@@ -757,6 +831,7 @@ THRESHOLDS = {
"structure": None, "styleRichness": 2, "rootHeightMismatch": 24, "structure": None, "styleRichness": 2, "rootHeightMismatch": 24,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
}, },
"g5-02-navbar-flex": { "g5-02-navbar-flex": {
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0, "shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -765,6 +840,7 @@ THRESHOLDS = {
"structure": (2, 1), "styleRichness": 1, "rootHeightMismatch": 24, "structure": (2, 1), "styleRichness": 1, "rootHeightMismatch": 24,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
}, },
"g5-03-card-producto": { "g5-03-card-producto": {
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0, "shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -773,6 +849,7 @@ THRESHOLDS = {
"structure": (2, 1), "styleRichness": 3, "rootHeightMismatch": 32, "structure": (2, 1), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
}, },
"g5-04-tokens-biblioteca": { "g5-04-tokens-biblioteca": {
"shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0, "shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0,
@@ -781,6 +858,7 @@ THRESHOLDS = {
"structure": (2, 1), "styleRichness": 2, "rootHeightMismatch": 32, "structure": (2, 1), "styleRichness": 2, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
}, },
"g5-05-grid-tres-diferenciales": { "g5-05-grid-tres-diferenciales": {
"shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0, "shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -789,6 +867,28 @@ THRESHOLDS = {
"structure": (3, 3), "styleRichness": 3, "rootHeightMismatch": 32, "structure": (3, 3), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
"g5-09-brief-ambiguo-escuela": {
"shapeCount": 30, "distinctFillColors": 6, "placeholderGreys": 0,
"textsWithCharacters": 12, "typeScale": (3, 32), "textFillShare": 1.0,
"layoutBoards": 3, "layoutChildSizingShare": 0.5, "containmentViolations": 0,
"structure": (3, 4), "styleRichness": 3, "rootHeightMismatch": 48,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION,
},
"g5-10-brief-ambiguo-notaria": {
"shapeCount": 30, "distinctFillColors": 5, "placeholderGreys": 0,
"textsWithCharacters": 12, "typeScale": (3, 32), "textFillShare": 1.0,
"layoutBoards": 3, "layoutChildSizingShare": 0.5, "containmentViolations": 0,
"structure": (3, 4), "styleRichness": 3, "rootHeightMismatch": 48,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
# "elegante" no pide estridencia: alcanza con UN cromatico bien elegido sobre neutrales,
# pero la paleta tiene que estar estructurada y declarada igual.
"chromaticFills": 1, "meanChromaticSaturation": 30,
"paletteStructured": True, "finalMessageListsHex": True,
}, },
"g5-06-landing-pizzeria": { "g5-06-landing-pizzeria": {
"shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0, "shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0,
@@ -797,6 +897,7 @@ THRESHOLDS = {
"structure": (3, 5), "styleRichness": 5, "rootHeightMismatch": 48, "structure": (3, 5), "styleRichness": 5, "rootHeightMismatch": 48,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION,
}, },
"g5-07-onboarding-mobile": { "g5-07-onboarding-mobile": {
"shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0, "shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0,
@@ -805,6 +906,7 @@ THRESHOLDS = {
"structure": (3, 2), "styleRichness": 3, "rootHeightMismatch": 32, "structure": (3, 2), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
}, },
"g5-08-reparacion-grises": { "g5-08-reparacion-grises": {
"shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0, "shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0,
@@ -813,6 +915,7 @@ THRESHOLDS = {
"structure": (2, 2), "styleRichness": 3, "rootHeightMismatch": 32, "structure": (2, 2), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34, "exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True, "fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
}, },
} }
@@ -838,6 +941,10 @@ def build_raw_metrics(audit, runtime):
"exportBeforeFinal": runtime["exportBeforeFinal"], "exportBeforeFinal": runtime["exportBeforeFinal"],
"forbiddenBehavior": runtime["forbiddenBehavior"], "forbiddenBehavior": runtime["forbiddenBehavior"],
"exceptionRate": runtime["exceptionRate"], "exceptionRate": runtime["exceptionRate"],
"chromaticFills": audit.get("chromaticFills", 0),
"meanChromaticSaturation": audit.get("meanChromaticSaturation", 0),
"paletteStructured": bool(audit.get("paletteStructured", False)),
"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 "rendersOk": bool(audit.get("cssLength", 0) > 0 and audit.get("markupLength", 0) > 0
and not audit.get("renderError")), and not audit.get("renderError")),
@@ -901,10 +1008,65 @@ def load_penpot_tools():
def load_system_prompt(): def load_system_prompt():
"""El bloque `instructions` verbatim del servidor MCP. Medir con otro system prompt mediria """El bloque `instructions` COMPLETO del servidor MCP, verbatim.
otra cosa: es exactamente lo que el modelo ve en produccion."""
Medir con otro system prompt mide otra cosa. Y esto se equivoco una vez, caro:
La primera corrida del baseline inyectaba solo la seccion inicial del documento (la que
`penpot_system_prompt.md` guarda como "Bloque completo, verbatim"), que son 2290 de los
16396 caracteres reales -- el 14%. El 86% que faltaba es justo el grounding de API:
"Core Shape Properties and Methods", "Layout Systems", "Text Elements" y "The `penpot` and
`penpotUtils` Objects", o sea insertChild, resize(), los layouts y penpotUtils.
Con ese recorte, produccion media shapes=0 en los 8 prompts: sin ninguna documentacion de
API, el modelo se inventaba una con forma de Figma y todas las llamadas lanzaban. Pero el
sintoma que el usuario reporto en su sesion real de Claude Code era otro y menos severo
("se conecta y crea cajas grises"), o sea shapes>0. Esa discrepancia era la firma de que el
harness, y no el modelo, estaba produciendo el resultado.
En una sesion real el servidor MCP entrega el documento entero como `instructions`, que es
tambien lo que devuelve `high_level_overview`. Asi que la fuente correcta es la Captura 1 de
penpot_api_docs.md, completa.
Nota deliberada sobre los seeds: los 34 seeds que llevan mensaje `system` cargan solo la
seccion inicial, no las 4554 tokens del documento entero -- no entrarian en el presupuesto
de 3300 tokens por ejemplo. Eso es aceptable porque la contramedida que los seeds tienen que
ensenar (la regla de "don't pick your own colours" y el razonamiento que la desambigua) vive
entera en esa seccion inicial.
"""
if os.environ.get("GATE5_SYSTEM_PROMPT_SECTION_ONLY", "").lower() in ("1", "true", "yes"):
print("[SYSTEM] usando SOLO la seccion inicial del documento (modo de diagnostico)")
return LINT.extract_system_block() return LINT.extract_system_block()
lineas = (SCHEMAS_DIR / "penpot_api_docs.md").read_text(encoding="utf-8").splitlines()
try:
inicio = next(n for n, l in enumerate(lineas) if l.strip() == "Salida verbatim:") + 3
fin = next(n for n in range(inicio + 5, len(lineas)) if lineas[n].rstrip() == "```")
except StopIteration:
raise Gate5Error(
"no se pudo extraer la Captura 1 (high_level_overview) de penpot_api_docs.md; "
"ese bloque es el system prompt real del servidor y sin el la medicion no es fiel"
)
completo = "\n".join(lineas[inicio:fin])
# La captura viene de `high_level_overview`, que cierra con un separador y la frase
# "You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it
# again." Eso es framing de la RESPUESTA DE LA TOOL, no parte del bloque `instructions` que
# el servidor inyecta. Dejarlo adentro le estaria diciendo al modelo que ya leyo un documento
# por haberlo recibido, lo que altera su decision de llamar o no a la tool.
corte = completo.rfind("\n--\nYou have hereby read")
if corte != -1:
completo = completo[:corte]
seccion = LINT.extract_system_block()
if not completo.startswith(seccion[:200]):
raise Gate5Error(
"la Captura 1 de penpot_api_docs.md no arranca como el bloque de "
"penpot_system_prompt.md: uno de los dos archivos se desincronizo"
)
print(f"[SYSTEM] documento completo del servidor: {len(completo)} chars "
f"({len(completo.splitlines())} lineas)")
return completo
def chat_completion(cfg, messages, tools): def chat_completion(cfg, messages, tools):
payload = { payload = {
@@ -1073,10 +1235,14 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
except Gate5Error as e: except Gate5Error as e:
print(f"[WARN] {prompt_id}: no se pudo exportar el PNG ({e})") print(f"[WARN] {prompt_id}: no se pudo exportar el PNG ({e})")
# Ante un brief ambiguo el modelo tiene que ELEGIR una paleta y DECIRSELA al usuario, para
# que la pueda ajustar. Una paleta elegida en silencio no es colaborable.
hex_en_final = set(HEX_EN_TEXTO.findall(final_content or ""))
runtime = { runtime = {
"exportBeforeFinal": bool(export_calls), "exportBeforeFinal": bool(export_calls),
"forbiddenBehavior": len(veto_hits), "forbiddenBehavior": len(veto_hits),
"exceptionRate": (exec_exceptions / exec_calls) if exec_calls else 0.0, "exceptionRate": (exec_exceptions / exec_calls) if exec_calls else 0.0,
"finalMessageListsHex": len(hex_en_final) >= 3,
} }
raw = build_raw_metrics(audit, runtime) raw = build_raw_metrics(audit, runtime)
resultado = score_prompt(prompt_id, raw) resultado = score_prompt(prompt_id, raw)