Phase 6.3.17: fix the audit root, add fill metrics, and stop the gate degrading the file
Three defects, the first found by inspecting the user's Penpot file live while the gate reported something else. The audit resolved the page root with penpotUtils.findShapeById(rootId). Every new Penpot page shares the same root frame id, 00000000-0000-0000-0000-000000000000, and findShapeById searches globally, so that lookup always returned the root of the FIRST page in the file - the user's empty one - rather than the page the gate had just created. That is why the baseline reported shapeCount 0 on all ten prompts while the file actually held 28 shapes and 20 texts. It resolves by pageId now, which is unique. With that fixed the baseline reproduces the reported symptom exactly rather than something worse: the button gets 4 shapes, all grey; the navbar gets 10 shapes and 6 real texts with a single distinct fill colour. Production does create structure and text. What it never manages is to apply colour - it creates the shapes in a call that works, then sets fills in a later call using Figma syntax, that call throws, and the shapes keep the #B1B2B5 default. So "creates grey boxes" was accurate and "creates nothing" was my measurement error. Two metrics now capture that directly, since neither distinct-colour counts nor placeholder-grey counts see it - #FFFFFF and #000000 are not mid greys, so a design where every fill is a default passes both. explicitFillShare is the share of filled shapes whose colour is not one of Penpot's three defaults, thresholded by difficulty (0.80 high, 0.65 medium, 0.50 low, since a small artefact's structural neutrals weigh heavily in a ratio). And onlyDefaultColors is a veto: true when the whole palette is those three. White and black stay legitimate when chosen - the distinction is co-presence, not the hex. They are only suspicious when they are all there is; alongside chosen brand colours they also count as neutrals in paletteStructured. resumen() crashed adding None scores from failed prompts. Beyond the crash, an unmeasured prompt must not average in as a zero: "could not measure" and "the model did it badly" are different, and averaging them would have made a mid-run plugin outage look like cheap quality. They are reported separately and any missing prompt makes the verdict invalid, because a baseline with 5 of 10 measured is not a baseline. The gate now empties its own page after exporting the PNG. Across three runs the plugin reliably handled 5 or 6 heavy prompts and then degraded to 30-second timeouts on createPage - that is not random flakiness, it tracks the file growing by one page per prompt, so the gate was manufacturing its own failure. The evidence that matters is the PNG plus the JSON metrics, not the live page. A page is kept only when its PNG failed, so there is something to inspect. Exit code 3 now distinguishes "plugin degraded mid-run, reload it and resume these ids" from "plugin not connected".
This commit is contained in:
@@ -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 = None
|
||||||
|
for intento in range(1, AUDIT_MAX_RETRIES + 1):
|
||||||
texto, es_error = self.execute_code(code)
|
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)
|
||||||
|
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'}")
|
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