Phase 6: train a second LoRA for real Penpot UI design capability #5
@@ -83,7 +83,21 @@ MCP_RETRY_BACKOFF = float(os.environ.get("PENPOT_MCP_RETRY_BACKOFF", "3"))
|
|||||||
MAX_TURNS = int(os.environ.get("GATE5_MAX_TURNS", "14"))
|
MAX_TURNS = int(os.environ.get("GATE5_MAX_TURNS", "14"))
|
||||||
MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096"))
|
MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096"))
|
||||||
HTTP_TIMEOUT = int(os.environ.get("GATE5_HTTP_TIMEOUT", "600"))
|
HTTP_TIMEOUT = int(os.environ.get("GATE5_HTTP_TIMEOUT", "600"))
|
||||||
MCP_TIMEOUT = int(os.environ.get("GATE5_MCP_TIMEOUT", "180"))
|
MCP_TIMEOUT = int(os.environ.get("GATE5_MCP_TIMEOUT", "240"))
|
||||||
|
|
||||||
|
# Fallos que vuelven como respuesta HTTP EXITOSA con texto de error adentro. El limite de 30
|
||||||
|
# segundos NO es del cliente: es el del servidor MCP para una tarea del plugin, asi que no se
|
||||||
|
# puede subir desde aca -- solo reintentar. Y hay que reintentarlos explicitamente porque, al no
|
||||||
|
# ser errores de transporte, el reintento de `_request` no los ve: la request fue un exito.
|
||||||
|
# La auditoria es la llamada mas pesada de la puerta y es la que se cayo asi en el prompt
|
||||||
|
# critico de la fase.
|
||||||
|
REINTENTABLES_EN_RESULTADO = re.compile(
|
||||||
|
r"timed out after \d+ seconds|Tool execution failed: Error: Task ", re.I)
|
||||||
|
AUDIT_MAX_RETRIES = int(os.environ.get("GATE5_AUDIT_RETRIES", "5"))
|
||||||
|
|
||||||
|
# Vaciar la pagina de la puerta despues de medirla y exportar su PNG. Se puede apagar para
|
||||||
|
# inspeccion manual con GATE5_KEEP_PAGES=1.
|
||||||
|
LIMPIAR_PAGINAS = os.environ.get("GATE5_KEEP_PAGES", "").lower() not in ("1", "true", "yes")
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -98,6 +112,9 @@ 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
|
||||||
|
# El plugin respondio pero se degrado a mitad de corrida (timeouts en createPage). No es lo mismo
|
||||||
|
# que "no esta conectado": hay que pedirle al usuario que lo RECARGUE y retomar los que faltan.
|
||||||
|
EXIT_PLUGIN_DEGRADADO = 3
|
||||||
|
|
||||||
|
|
||||||
class Gate5Error(RuntimeError):
|
class Gate5Error(RuntimeError):
|
||||||
@@ -399,7 +416,17 @@ class PenpotMCP:
|
|||||||
`execute_code` (el metodo de abajo) NO desenvuelve: lo que ve el modelo como tool result
|
`execute_code` (el metodo de abajo) NO desenvuelve: lo que ve el modelo como tool result
|
||||||
tiene que ser byte a byte lo que le llegaria en produccion, envoltorio incluido.
|
tiene que ser byte a byte lo que le llegaria en produccion, envoltorio incluido.
|
||||||
"""
|
"""
|
||||||
texto, es_error = self.execute_code(code)
|
texto = es_error = None
|
||||||
|
for intento in range(1, AUDIT_MAX_RETRIES + 1):
|
||||||
|
texto, es_error = self.execute_code(code)
|
||||||
|
if not REINTENTABLES_EN_RESULTADO.search(texto or ""):
|
||||||
|
break
|
||||||
|
if intento == AUDIT_MAX_RETRIES:
|
||||||
|
break
|
||||||
|
espera = MCP_RETRY_BACKOFF * intento
|
||||||
|
print(f"[MCP] '{que}' fallo con timeout de tarea del servidor "
|
||||||
|
f"(intento {intento}/{AUDIT_MAX_RETRIES}). Reintento en {espera:.0f}s")
|
||||||
|
time.sleep(espera)
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(texto)
|
parsed = json.loads(texto)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -458,10 +485,35 @@ return {
|
|||||||
# Payload de auditoria. Lo inyecta la puerta, asi que tiene que respetar la misma API verificada
|
# Payload de auditoria. Lo inyecta la puerta, asi que tiene que respetar la misma API verificada
|
||||||
# que se le exige al modelo: findShapeById de 1 argumento, board.flex/board.grid (nunca .layout),
|
# que se le exige al modelo: findShapeById de 1 argumento, board.flex/board.grid (nunca .layout),
|
||||||
# includeChildren (nunca withChildren), sin asignaciones a propiedades read-only.
|
# includeChildren (nunca withChildren), sin asignaciones a propiedades read-only.
|
||||||
|
DELETE_PAGE_JS = """
|
||||||
|
// Borra la pagina que creo LA PUERTA, nunca una del usuario: se exige que el nombre empiece con
|
||||||
|
// el prefijo gate5/ y se compara el id, asi que no hay forma de que apunte a otra cosa.
|
||||||
|
const PAGE_ID = %PAGE_ID%;
|
||||||
|
const page = penpotUtils.getPageById(PAGE_ID);
|
||||||
|
if (!page) { return { borrada: false, motivo: "no existe" }; }
|
||||||
|
if (String(page.name).indexOf("gate5/") !== 0) {
|
||||||
|
return { borrada: false, motivo: "no es una pagina de la puerta: " + page.name };
|
||||||
|
}
|
||||||
|
const nombre = page.name;
|
||||||
|
const shapes = penpotUtils.findShapes(function () { return true; }, page.root);
|
||||||
|
for (let i = shapes.length - 1; i >= 0; i--) { shapes[i].remove(); }
|
||||||
|
return { borrada: true, nombre: nombre, shapesBorrados: shapes.length };
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
AUDIT_JS = """
|
AUDIT_JS = """
|
||||||
const ROOT_ID = %ROOT_ID%;
|
// El root se resuelve por PAGE_ID, nunca por rootId.
|
||||||
const root = penpotUtils.findShapeById(ROOT_ID);
|
// Por que: en Penpot TODAS las paginas nuevas comparten el mismo id de root frame,
|
||||||
if (!root) { return { auditError: "root no encontrado" }; }
|
// "00000000-0000-0000-0000-000000000000". Y `penpotUtils.findShapeById` busca GLOBALMENTE, asi
|
||||||
|
// que buscar por ese id devuelve el root de la PRIMERA pagina del archivo -- la del usuario,
|
||||||
|
// vacia -- y no la que la puerta acaba de crear. Esa fue la causa de que el primer baseline
|
||||||
|
// midiera shapeCount=0 en los 10 prompts mientras el archivo real tenia 28 shapes y 20 textos.
|
||||||
|
// El pageId si es unico.
|
||||||
|
const PAGE_ID = %PAGE_ID%;
|
||||||
|
const page = penpotUtils.getPageById(PAGE_ID);
|
||||||
|
if (!page) { return { auditError: "pagina no encontrada: " + PAGE_ID }; }
|
||||||
|
const root = page.root;
|
||||||
|
if (!root) { return { auditError: "la pagina no tiene root" }; }
|
||||||
|
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
const collect = (shape, depth) => {
|
const collect = (shape, depth) => {
|
||||||
@@ -618,6 +670,23 @@ function aHsl(hex) {
|
|||||||
}
|
}
|
||||||
return { h: h, s: sat * 100, l: l * 100 };
|
return { h: h, s: sat * 100, l: l * 100 };
|
||||||
}
|
}
|
||||||
|
// FILL EXPLICITO. Los tres defaults de Penpot: board blanco, rectangulo #B1B2B5, texto negro.
|
||||||
|
// Un diseno cuyos UNICOS colores son esos tres no es un diseno: son shapes que nunca recibieron
|
||||||
|
// fill. Esta metrica sola habria diagnosticado el bug de produccion, que las de "colores
|
||||||
|
// distintos" y "cero grises" no ven -- #FFFFFF y #000000 no son gris medio, asi que pasan.
|
||||||
|
const DEFAULTS = ["#FFFFFF", "#B1B2B5", "#000000"];
|
||||||
|
let conFill = 0, conFillExplicito = 0;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const fs = Array.isArray(n.shape.fills) ? n.shape.fills : [];
|
||||||
|
const hexes = fs.filter(function (f) { return f && typeof f.fillColor === "string"; })
|
||||||
|
.map(function (f) { return f.fillColor.toUpperCase(); });
|
||||||
|
if (!hexes.length) { continue; }
|
||||||
|
conFill++;
|
||||||
|
if (hexes.some(function (h) { return DEFAULTS.indexOf(h) === -1; })) { conFillExplicito++; }
|
||||||
|
}
|
||||||
|
const soloDefaults = distintos.length > 0
|
||||||
|
&& distintos.every(function (h) { return DEFAULTS.indexOf(h) !== -1; });
|
||||||
|
|
||||||
const cromaticos = [];
|
const cromaticos = [];
|
||||||
const neutrales = [];
|
const neutrales = [];
|
||||||
for (const hex of distintos) {
|
for (const hex of distintos) {
|
||||||
@@ -639,7 +708,11 @@ for (let i = 0; i < cromaticos.length; i++) {
|
|||||||
const saturacionMedia = cromaticos.length
|
const saturacionMedia = cromaticos.length
|
||||||
? cromaticos.reduce(function (a, c) { return a + c.s; }, 0) / cromaticos.length : 0;
|
? cromaticos.reduce(function (a, c) { return a + c.s; }, 0) / cromaticos.length : 0;
|
||||||
|
|
||||||
return {
|
const resultado = {
|
||||||
|
explicitFillShare: conFill > 0 ? conFillExplicito / conFill : 0,
|
||||||
|
shapesWithFill: conFill,
|
||||||
|
shapesWithExplicitFill: conFillExplicito,
|
||||||
|
onlyDefaultColors: soloDefaults,
|
||||||
chromaticFills: cromaticos.length,
|
chromaticFills: cromaticos.length,
|
||||||
chromaticSamples: cromaticos.slice(0, 8).map(function (c) {
|
chromaticSamples: cromaticos.slice(0, 8).map(function (c) {
|
||||||
return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }),
|
return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }),
|
||||||
@@ -678,6 +751,13 @@ return {
|
|||||||
markupLength: markupLen,
|
markupLength: markupLen,
|
||||||
renderError: renderError
|
renderError: renderError
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// El servidor MCP valida el resultado contra un schema y rechaza la llamada ENTERA si algun
|
||||||
|
// campo no es serializable ("Invalid input: expected string, received function"). Un solo objeto
|
||||||
|
// de Penpot que se cuele en el return -- todos traen metodos -- tira abajo la auditoria completa
|
||||||
|
// y con ella la medicion de ese prompt. Forzar datos planos es mas barato y mas robusto que
|
||||||
|
// cazar el campo culpable cada vez que se agrega una metrica.
|
||||||
|
return JSON.parse(JSON.stringify(resultado));
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -687,8 +767,12 @@ def build_setup_js(page_name, seed_board):
|
|||||||
.replace("%SEED%", js_string(seed_board) if seed_board else "null"))
|
.replace("%SEED%", js_string(seed_board) if seed_board else "null"))
|
||||||
|
|
||||||
|
|
||||||
def build_audit_js(root_id):
|
def build_audit_js(page_id):
|
||||||
return AUDIT_JS.replace("%ROOT_ID%", js_string(root_id))
|
return AUDIT_JS.replace("%PAGE_ID%", js_string(page_id))
|
||||||
|
|
||||||
|
|
||||||
|
def build_delete_page_js(page_id):
|
||||||
|
return DELETE_PAGE_JS.replace("%PAGE_ID%", js_string(page_id))
|
||||||
|
|
||||||
|
|
||||||
def self_check_gate_payloads():
|
def self_check_gate_payloads():
|
||||||
@@ -776,6 +860,8 @@ 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"),
|
||||||
|
("explicitFillShare", "share de shapes con fill NO-default", "min"),
|
||||||
|
("onlyDefaultColors", "la paleta son SOLO los 3 defaults de Penpot [VETO]", "max"),
|
||||||
("chromaticFills", "fills cromaticos (sat HSL >= 45, L 15-85)", "min"),
|
("chromaticFills", "fills cromaticos (sat HSL >= 45, L 15-85)", "min"),
|
||||||
("meanChromaticSaturation", "saturacion media de los fills cromaticos", "min"),
|
("meanChromaticSaturation", "saturacion media de los fills cromaticos", "min"),
|
||||||
("paletteStructured", "paleta estructurada (marca + acento + neutrales)", "bool"),
|
("paletteStructured", "paleta estructurada (marca + acento + neutrales)", "bool"),
|
||||||
@@ -797,7 +883,7 @@ METRICS = [
|
|||||||
("rendersOk", "el arbol renderiza (generateStyle/generateMarkup)", "bool"),
|
("rendersOk", "el arbol renderiza (generateStyle/generateMarkup)", "bool"),
|
||||||
]
|
]
|
||||||
|
|
||||||
VETO_METRICS = ("placeholderGreys", "forbiddenBehavior")
|
VETO_METRICS = ("placeholderGreys", "forbiddenBehavior", "onlyDefaultColors")
|
||||||
|
|
||||||
# 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
|
||||||
@@ -823,6 +909,22 @@ UMBRALES_VIBRACION = {
|
|||||||
}
|
}
|
||||||
UMBRALES_VIBRACION_NO_APLICA = {k: None for k in UMBRALES_VIBRACION}
|
UMBRALES_VIBRACION_NO_APLICA = {k: None for k in UMBRALES_VIBRACION}
|
||||||
|
|
||||||
|
# Fill explicito. `onlyDefaultColors` es VETO y aplica a TODOS los prompts: un diseno cuyos unicos
|
||||||
|
# colores son los tres defaults de Penpot es el bug de produccion, no un diseno sobrio.
|
||||||
|
#
|
||||||
|
# La distincion que pedia el usuario -- blanco y negro son legitimos cuando el modelo los ELIGE --
|
||||||
|
# se resuelve por CO-PRESENCIA, no penalizando el hex: #FFFFFF y #000000 solo son sospechosos si
|
||||||
|
# son lo UNICO que hay. Si conviven con colores de marca elegidos, `onlyDefaultColors` es false y
|
||||||
|
# ademas cuentan como neutrales legitimos en `paletteStructured`. Por eso son dos metricas y no
|
||||||
|
# una: `explicitFillShare` mide cuanto del diseno recibio color deliberado, y `onlyDefaultColors`
|
||||||
|
# atrapa el caso degenerado donde no recibio ninguno.
|
||||||
|
#
|
||||||
|
# El umbral de share se grada por dificultad: un boton primario (baja) tiene pocos shapes y algun
|
||||||
|
# neutral estructural pesa mucho en la proporcion; una landing (alta) no tiene excusa.
|
||||||
|
UMBRALES_FILL_EXPLICITO_BAJA = {"explicitFillShare": 0.50, "onlyDefaultColors": False}
|
||||||
|
UMBRALES_FILL_EXPLICITO_MEDIA = {"explicitFillShare": 0.65, "onlyDefaultColors": False}
|
||||||
|
UMBRALES_FILL_EXPLICITO = {"explicitFillShare": 0.80, "onlyDefaultColors": False}
|
||||||
|
|
||||||
THRESHOLDS = {
|
THRESHOLDS = {
|
||||||
"g5-01-boton-primario": {
|
"g5-01-boton-primario": {
|
||||||
"shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0,
|
"shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0,
|
||||||
@@ -832,6 +934,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION_NO_APLICA,
|
||||||
|
**UMBRALES_FILL_EXPLICITO_BAJA,
|
||||||
},
|
},
|
||||||
"g5-02-navbar-flex": {
|
"g5-02-navbar-flex": {
|
||||||
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
|
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
|
||||||
@@ -841,6 +944,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION_NO_APLICA,
|
||||||
|
**UMBRALES_FILL_EXPLICITO_BAJA,
|
||||||
},
|
},
|
||||||
"g5-03-card-producto": {
|
"g5-03-card-producto": {
|
||||||
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
|
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
|
||||||
@@ -850,6 +954,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION_NO_APLICA,
|
||||||
|
**UMBRALES_FILL_EXPLICITO_MEDIA,
|
||||||
},
|
},
|
||||||
"g5-04-tokens-biblioteca": {
|
"g5-04-tokens-biblioteca": {
|
||||||
"shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0,
|
"shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0,
|
||||||
@@ -859,6 +964,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION_NO_APLICA,
|
||||||
|
**UMBRALES_FILL_EXPLICITO_MEDIA,
|
||||||
},
|
},
|
||||||
"g5-05-grid-tres-diferenciales": {
|
"g5-05-grid-tres-diferenciales": {
|
||||||
"shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0,
|
"shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0,
|
||||||
@@ -868,6 +974,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION_NO_APLICA,
|
||||||
|
**UMBRALES_FILL_EXPLICITO_MEDIA,
|
||||||
},
|
},
|
||||||
"g5-09-brief-ambiguo-escuela": {
|
"g5-09-brief-ambiguo-escuela": {
|
||||||
"shapeCount": 30, "distinctFillColors": 6, "placeholderGreys": 0,
|
"shapeCount": 30, "distinctFillColors": 6, "placeholderGreys": 0,
|
||||||
@@ -877,6 +984,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION,
|
||||||
|
**UMBRALES_FILL_EXPLICITO,
|
||||||
},
|
},
|
||||||
"g5-10-brief-ambiguo-notaria": {
|
"g5-10-brief-ambiguo-notaria": {
|
||||||
"shapeCount": 30, "distinctFillColors": 5, "placeholderGreys": 0,
|
"shapeCount": 30, "distinctFillColors": 5, "placeholderGreys": 0,
|
||||||
@@ -889,6 +997,7 @@ THRESHOLDS = {
|
|||||||
# pero la paleta tiene que estar estructurada y declarada igual.
|
# pero la paleta tiene que estar estructurada y declarada igual.
|
||||||
"chromaticFills": 1, "meanChromaticSaturation": 30,
|
"chromaticFills": 1, "meanChromaticSaturation": 30,
|
||||||
"paletteStructured": True, "finalMessageListsHex": True,
|
"paletteStructured": True, "finalMessageListsHex": True,
|
||||||
|
**UMBRALES_FILL_EXPLICITO,
|
||||||
},
|
},
|
||||||
"g5-06-landing-pizzeria": {
|
"g5-06-landing-pizzeria": {
|
||||||
"shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0,
|
"shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0,
|
||||||
@@ -898,6 +1007,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION,
|
||||||
|
**UMBRALES_FILL_EXPLICITO,
|
||||||
},
|
},
|
||||||
"g5-07-onboarding-mobile": {
|
"g5-07-onboarding-mobile": {
|
||||||
"shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0,
|
"shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0,
|
||||||
@@ -907,6 +1017,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION_NO_APLICA,
|
||||||
|
**UMBRALES_FILL_EXPLICITO,
|
||||||
},
|
},
|
||||||
"g5-08-reparacion-grises": {
|
"g5-08-reparacion-grises": {
|
||||||
"shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0,
|
"shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0,
|
||||||
@@ -916,6 +1027,7 @@ THRESHOLDS = {
|
|||||||
"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,
|
**UMBRALES_VIBRACION_NO_APLICA,
|
||||||
|
**UMBRALES_FILL_EXPLICITO,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,6 +1053,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"],
|
||||||
|
"explicitFillShare": audit.get("explicitFillShare", 0.0),
|
||||||
|
# Sin shapes no hay paleta que juzgar, pero "no hice nada" no puede pasar el veto: se
|
||||||
|
# trata como el caso malo, igual que rootHeightMismatch.
|
||||||
|
"onlyDefaultColors": bool(audit.get("onlyDefaultColors", True)),
|
||||||
"chromaticFills": audit.get("chromaticFills", 0),
|
"chromaticFills": audit.get("chromaticFills", 0),
|
||||||
"meanChromaticSaturation": audit.get("meanChromaticSaturation", 0),
|
"meanChromaticSaturation": audit.get("meanChromaticSaturation", 0),
|
||||||
"paletteStructured": bool(audit.get("paletteStructured", False)),
|
"paletteStructured": bool(audit.get("paletteStructured", False)),
|
||||||
@@ -1214,7 +1330,7 @@ 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(root_id), f"{prompt_id}: auditoria")
|
audit = mcp.execute_json(build_audit_js(setup["pageId"]), 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)}
|
||||||
@@ -1235,6 +1351,28 @@ 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})")
|
||||||
|
|
||||||
|
# --- vaciar la pagina de la puerta ---------------------------------------------------------
|
||||||
|
# La evidencia que queda es el PNG + las metricas del JSON, no la pagina viva. Dejarlas
|
||||||
|
# acumular tiene un costo medible: el plugin de Penpot se degrada de forma progresiva a
|
||||||
|
# medida que crece el archivo -- en tres corridas seguidas aguanto 5 o 6 prompts pesados y
|
||||||
|
# despues empezo a dar timeouts de 30 s en `createPage`. Con 26 paginas acumuladas eso deja
|
||||||
|
# de ser flakiness del MCP y pasa a ser un problema que la propia puerta se fabrica.
|
||||||
|
# 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"]),
|
||||||
|
f"{prompt_id}: limpieza de la pagina")
|
||||||
|
if info.get("borrada"):
|
||||||
|
print(f"[CLEAN] {info['nombre']}: {info['shapesBorrados']} shape(s) borrados "
|
||||||
|
f"(la evidencia queda en el PNG y en el JSON)")
|
||||||
|
else:
|
||||||
|
print(f"[WARN] {prompt_id}: no se limpio la pagina ({info.get('motivo')})")
|
||||||
|
except Gate5Error as e:
|
||||||
|
print(f"[WARN] {prompt_id}: no se pudo limpiar la pagina ({e})")
|
||||||
|
elif png_path is None:
|
||||||
|
print(f"[CLEAN] {prompt_id}: la pagina se CONSERVA porque no hubo PNG, para poder "
|
||||||
|
f"inspeccionar que paso")
|
||||||
|
|
||||||
# Ante un brief ambiguo el modelo tiene que ELEGIR una paleta y DECIRSELA al usuario, para
|
# 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.
|
# que la pueda ajustar. Una paleta elegida en silencio no es colaborable.
|
||||||
hex_en_final = set(HEX_EN_TEXTO.findall(final_content or ""))
|
hex_en_final = set(HEX_EN_TEXTO.findall(final_content or ""))
|
||||||
@@ -1409,29 +1547,48 @@ def load_prompts():
|
|||||||
return filas
|
return filas
|
||||||
|
|
||||||
|
|
||||||
def resumen(resultados, tag):
|
def resumen(resultados, tag, esperados=None):
|
||||||
scores = [r["score"] for r in resultados]
|
"""Resume la corrida.
|
||||||
|
|
||||||
|
Un prompt SIN medicion no cuenta como 0 en la media. "No pude medir" y "el modelo lo hizo
|
||||||
|
mal" son dos cosas distintas y promediarlas juntas produce un numero que no significa nada:
|
||||||
|
un corte del plugin en el prompt 6 abarataria artificialmente el promedio y despues se
|
||||||
|
compararia contra el modelo nuevo como si fuera calidad. Los no medidos se reportan aparte y
|
||||||
|
**invalidan el veredicto**: un baseline con 5 de 10 medidos no es un baseline.
|
||||||
|
"""
|
||||||
|
medidos = [r for r in resultados if r.get("score") is not None]
|
||||||
|
no_medidos = [r for r in resultados if r.get("score") is None]
|
||||||
|
scores = [r["score"] for r in medidos]
|
||||||
media = sum(scores) / len(scores) if scores else 0.0
|
media = sum(scores) / len(scores) if scores else 0.0
|
||||||
veto_limpio = sum(1 for r in resultados if not r["veto_violado"])
|
veto_limpio = sum(1 for r in medidos if not r["veto_violado"])
|
||||||
sobre_minimo = sum(1 for r in resultados if r["score"] >= PROMPT_SCORE_MIN)
|
sobre_minimo = sum(1 for r in medidos if r["score"] >= PROMPT_SCORE_MIN)
|
||||||
flagship = next((r for r in resultados if r["id"] == FLAGSHIP_PROMPT_ID), None)
|
flagship = next((r for r in medidos if r["id"] == FLAGSHIP_PROMPT_ID), None)
|
||||||
flagship_score = flagship["score"] if flagship else 0.0
|
flagship_score = flagship["score"] if flagship else 0.0
|
||||||
|
total_esperado = esperados if esperados is not None else len(resultados)
|
||||||
|
|
||||||
print(f"\n=== Puerta 5 -- calidad de diseno en Penpot (tag={tag}) ===\n")
|
print(f"\n=== Puerta 5 -- calidad de diseno en Penpot (tag={tag}) ===\n")
|
||||||
print(f" {'prompt':32s} {'dif':6s} {'score':>6s} {'pasa':>7s} {'grises':>7s} "
|
print(f" {'prompt':32s} {'dif':6s} {'score':>6s} {'pasa':>7s} {'grises':>7s} "
|
||||||
f"{'prohib':>7s} {'shapes':>7s} {'colores':>8s} {'textos':>7s}")
|
f"{'prohib':>7s} {'shapes':>7s} {'colores':>8s} {'textos':>7s}")
|
||||||
for r in resultados:
|
for r in medidos:
|
||||||
crudas = r["metricas_crudas"]
|
crudas = r["metricas_crudas"]
|
||||||
print(f" {r['id']:32s} {str(r['dificultad'])[:6]:6s} {r['score']:6.1f} "
|
print(f" {r['id']:32s} {str(r['dificultad'])[:6]:6s} {r['score']:6.1f} "
|
||||||
f"{r['pasadas']:3d}/{r['aplicables']:<3d} {crudas['placeholderGreys']:7d} "
|
f"{r['pasadas']:3d}/{r['aplicables']:<3d} {crudas['placeholderGreys']:7d} "
|
||||||
f"{crudas['forbiddenBehavior']:7d} {crudas['shapeCount']:7d} "
|
f"{crudas['forbiddenBehavior']:7d} {crudas['shapeCount']:7d} "
|
||||||
f"{crudas['distinctFillColors']:8d} {crudas['textsWithCharacters']:7d}")
|
f"{crudas['distinctFillColors']:8d} {crudas['textsWithCharacters']:7d}")
|
||||||
|
|
||||||
|
if no_medidos:
|
||||||
|
print(f"\n NO MEDIDOS ({len(no_medidos)}), no cuentan como 0 en la media:")
|
||||||
|
for r in no_medidos:
|
||||||
|
print(f" {r['id']:32s} {str(r.get('error'))[:110]}")
|
||||||
|
|
||||||
condiciones = [
|
condiciones = [
|
||||||
|
("todos los prompts medidos", len(medidos) == total_esperado,
|
||||||
|
f"{len(medidos)}/{total_esperado}"),
|
||||||
(f"score medio >= {MEAN_SCORE_MIN:.0f}", media >= MEAN_SCORE_MIN, f"{media:.1f}"),
|
(f"score medio >= {MEAN_SCORE_MIN:.0f}", media >= MEAN_SCORE_MIN, f"{media:.1f}"),
|
||||||
("veto limpio en 8/8", veto_limpio == len(resultados), f"{veto_limpio}/{len(resultados)}"),
|
(f"veto limpio en {total_esperado}/{total_esperado}", veto_limpio == total_esperado,
|
||||||
(f">= {MIN_PROMPTS_OVER_MIN}/8 prompts >= {PROMPT_SCORE_MIN:.0f}",
|
f"{veto_limpio}/{total_esperado}"),
|
||||||
sobre_minimo >= MIN_PROMPTS_OVER_MIN, f"{sobre_minimo}/{len(resultados)}"),
|
(f">= {MIN_PROMPTS_OVER_MIN}/{total_esperado} prompts >= {PROMPT_SCORE_MIN:.0f}",
|
||||||
|
sobre_minimo >= MIN_PROMPTS_OVER_MIN, f"{sobre_minimo}/{total_esperado}"),
|
||||||
(f"landing de pizzeria >= {PROMPT_SCORE_MIN:.0f}",
|
(f"landing de pizzeria >= {PROMPT_SCORE_MIN:.0f}",
|
||||||
flagship_score >= PROMPT_SCORE_MIN, f"{flagship_score:.1f}"),
|
flagship_score >= PROMPT_SCORE_MIN, f"{flagship_score:.1f}"),
|
||||||
]
|
]
|
||||||
@@ -1440,8 +1597,14 @@ def resumen(resultados, tag):
|
|||||||
print(f" [{'ok ' if ok else 'NO '}] {etiqueta:40s} -> {valor}")
|
print(f" [{'ok ' if ok else 'NO '}] {etiqueta:40s} -> {valor}")
|
||||||
|
|
||||||
aprueba = all(ok for _e, ok, _v in condiciones)
|
aprueba = all(ok for _e, ok, _v in condiciones)
|
||||||
print(f"\n VEREDICTO GLOBAL: {'APRUEBA' if aprueba else 'NO APRUEBA'}")
|
if no_medidos:
|
||||||
|
print(f"\n VEREDICTO GLOBAL: INVALIDO -- faltan {len(no_medidos)} prompt(s) por medir. "
|
||||||
|
f"Re-medilos con GATE5_ONLY={','.join(r['id'] for r in no_medidos)}")
|
||||||
|
else:
|
||||||
|
print(f"\n VEREDICTO GLOBAL: {'APRUEBA' if aprueba else 'NO APRUEBA'}")
|
||||||
return aprueba, {
|
return aprueba, {
|
||||||
|
"valido": not no_medidos,
|
||||||
|
"no_medidos": [r["id"] for r in no_medidos],
|
||||||
"score_medio": round(media, 2),
|
"score_medio": round(media, 2),
|
||||||
"veto_limpio": veto_limpio,
|
"veto_limpio": veto_limpio,
|
||||||
"prompts_sobre_minimo": sobre_minimo,
|
"prompts_sobre_minimo": sobre_minimo,
|
||||||
@@ -1453,7 +1616,31 @@ def resumen(resultados, tag):
|
|||||||
|
|
||||||
def run_full(cfg, tools, system_prompt):
|
def run_full(cfg, tools, system_prompt):
|
||||||
prompts = load_prompts()
|
prompts = load_prompts()
|
||||||
|
todos_los_prompts = list(prompts)
|
||||||
check_disjunto(prompts)
|
check_disjunto(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
|
||||||
|
# hora de produccion arriba Y descarta mediciones que eran validas.
|
||||||
|
solo = [x.strip() for x in os.environ.get("GATE5_ONLY", "").split(",") if x.strip()]
|
||||||
|
previos = []
|
||||||
|
if solo:
|
||||||
|
conocidos = {f["id"] for f in prompts}
|
||||||
|
desconocidos = [x for x in solo if x not in conocidos]
|
||||||
|
if desconocidos:
|
||||||
|
raise Gate5Error(f"GATE5_ONLY nombra prompts que no existen: {desconocidos}")
|
||||||
|
# Los resultados ya medidos que NO se vuelven a correr se conservan y se fusionan.
|
||||||
|
anterior = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json"
|
||||||
|
if anterior.exists():
|
||||||
|
try:
|
||||||
|
previos = [r for r in json.loads(anterior.read_text(encoding="utf-8")).get("prompts", [])
|
||||||
|
if r.get("id") not in solo and r.get("score") is not None]
|
||||||
|
print(f"[INFO] se conservan {len(previos)} prompt(s) ya medidos de "
|
||||||
|
f"{anterior.name}: {[r['id'] for r in previos]}")
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
print(f"[WARN] no se pudo leer el resultado anterior ({e}); se re-mide todo lo pedido")
|
||||||
|
prompts = [f for f in prompts if f["id"] in solo]
|
||||||
|
print(f"[INFO] GATE5_ONLY: se miden {len(prompts)} prompt(s): {[f['id'] for f in prompts]}")
|
||||||
self_check_gate_payloads()
|
self_check_gate_payloads()
|
||||||
|
|
||||||
mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"])
|
mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"])
|
||||||
@@ -1467,6 +1654,13 @@ def run_full(cfg, tools, system_prompt):
|
|||||||
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"
|
||||||
|
|
||||||
|
def fusionar(resultados_parciales):
|
||||||
|
"""Los recien medidos + los conservados, en el orden canonico del .jsonl."""
|
||||||
|
por_id = {r["id"]: r for r in previos}
|
||||||
|
por_id.update({r["id"]: r for r in resultados_parciales})
|
||||||
|
orden = [f["id"] for f in todos_los_prompts]
|
||||||
|
return [por_id[i] for i in orden if i in por_id]
|
||||||
|
|
||||||
def volcar(resultados_parciales, dt_parcial, veredicto_parcial=None, completo=False):
|
def volcar(resultados_parciales, dt_parcial, veredicto_parcial=None, completo=False):
|
||||||
"""Escribe el JSON de resultados. Se llama DESPUES DE CADA PROMPT.
|
"""Escribe el JSON de resultados. Se llama DESPUES DE CADA PROMPT.
|
||||||
|
|
||||||
@@ -1483,14 +1677,14 @@ def run_full(cfg, tools, system_prompt):
|
|||||||
"max_tokens": MAX_TOKENS,
|
"max_tokens": MAX_TOKENS,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"duracion_s": round(dt_parcial, 1),
|
"duracion_s": round(dt_parcial, 1),
|
||||||
"prompts_completados": len(resultados_parciales),
|
"prompts_completados": len(fusionar(resultados_parciales)),
|
||||||
"prompts_totales": len(prompts),
|
"prompts_totales": len(todos_los_prompts),
|
||||||
"metricas": [{"clave": k, "etiqueta": e, "comparador": c} for k, e, c in METRICS],
|
"metricas": [{"clave": k, "etiqueta": e, "comparador": c} for k, e, c in METRICS],
|
||||||
"veto": list(VETO_METRICS),
|
"veto": list(VETO_METRICS),
|
||||||
"umbrales": {pid: {k: (list(v) if isinstance(v, tuple) else v) for k, v in u.items()}
|
"umbrales": {pid: {k: (list(v) if isinstance(v, tuple) else v) for k, v in u.items()}
|
||||||
for pid, u in THRESHOLDS.items()},
|
for pid, u in THRESHOLDS.items()},
|
||||||
"veredicto": veredicto_parcial,
|
"veredicto": veredicto_parcial,
|
||||||
"prompts": resultados_parciales,
|
"prompts": fusionar(resultados_parciales),
|
||||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -1509,10 +1703,18 @@ def run_full(cfg, tools, system_prompt):
|
|||||||
volcar(resultados, time.time() - t0)
|
volcar(resultados, time.time() - t0)
|
||||||
dt = time.time() - t0
|
dt = time.time() - t0
|
||||||
|
|
||||||
aprueba, veredicto = resumen(resultados, cfg["GATE5_TAG"])
|
aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"],
|
||||||
|
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=True)
|
||||||
|
sin_medir = [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:")
|
||||||
|
print(f" GATE5_ONLY={','.join(sin_medir)}")
|
||||||
|
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 puerta no borra nada: las paginas "
|
||||||
f"quedan para inspeccion humana)")
|
f"quedan para inspeccion humana)")
|
||||||
|
|||||||
Reference in New Issue
Block a user