"""Fase 6 -- genera data/holdout_penpot_design.jsonl: ~60 prompts de diseno de Penpot, held-out, deterministicos (seed=44). Son los prompts sobre los que se mide la **tasa de API prohibida** de la puerta 5, que es el disparador de fallback de la fase: si sobre estos 60 prompts el checkpoint v2-bf16 usa API inexistente en mas del 5% de los casos (mas de 3 de 60), no se sigue con la cuantizacion y se cambia a reentrenar desde el base con los seeds ya corregidos. python3 scripts/35_build_penpot_design_holdout.py Cada linea: {"id", "prompt", "categoria", "expect"}. No lleva schemas de tools: la puerta 5 corre un loop de agente real contra el MCP en vivo y arma ella misma el payload. DISJUNCION ---------- Se verifica contra data/train.jsonl, data/eval.jsonl y todos los seeds de data/raw/seeds/ con el MISMO chequeo de shingles de 6-gramas que usa scripts/31_build_holdout_prompts.py -- se importa de ahi a proposito, para que no haya dos definiciones de "contaminado" que puedan divergir. Como el nombre del modulo empieza con un digito no se puede importar con `import`; se carga por ruta con importlib. Los prompts mezclan a proposito dos registros: pedidos vagos ("algo lindo para...") y pedidos con medidas, colores y copy exactos. El modelo tiene que rendir en los dos: el fallo de produccion que origina la fase ("hazme una landing de una pizzeria con colores vibrantes") es del registro vago. """ import importlib.util import json import random from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent SEEDS_DIR = REPO_ROOT / "data" / "raw" / "seeds" TRAIN_PATH = REPO_ROOT / "data" / "train.jsonl" EVAL_PATH = REPO_ROOT / "data" / "eval.jsonl" OUT_PATH = REPO_ROOT / "data" / "holdout_penpot_design.jsonl" BUILDER_PATH = REPO_ROOT / "scripts" / "31_build_holdout_prompts.py" SEED = 44 TARGET_TOTAL = 60 def load_builder(): """Carga 31_build_holdout_prompts.py por ruta (el nombre empieza con digito).""" spec = importlib.util.spec_from_file_location("holdout_builder", BUILDER_PATH) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module BUILDER = load_builder() normalize = BUILDER.normalize shingles = BUILDER.shingles shingle_overlap = BUILDER.shingle_overlap assert_no_contamination = BUILDER.assert_no_contamination MAX_SHINGLE_OVERLAP = BUILDER.MAX_SHINGLE_OVERLAP # Pools propios de este holdout: ni los seeds ni el holdout de la puerta 2 los usan. # La disjuncion se asierta contra el corpus antes de generar nada. DOMINIOS = [ "una escuela de circo", "un servicio de reparacion de relojes", "una cooperativa de miel", "un estudio de doblaje", "una tienda de kayaks", "un laboratorio de analisis de suelos", "una academia de tango", "un albergue para perros mayores", "una fabrica de velas de soja", "un museo de maquinas de escribir", "una consultora de eficiencia energetica", "un vivero de orquideas", ] COLORES = ["#134e4a", "#3b0764", "#312e81", "#713f12", "#581c87", "#155e75"] ANCHOS = [1180, 1240, 1320, 900] MOBILE = ["390x844", "412x915", "360x780"] # Fuentes reales de Google Fonts (Penpot expone 1914), elegidas entre las que NINGUN seed # nombra: si el holdout pidiera una fuente que el training ya usa, no mediria nada nuevo. TIPOGRAFIAS = ["Libre Baskerville", "Karla", "Asap"] # (categoria, plantilla, expect). `expect` describe que tiene que pasar para que el prompt # cuente como resuelto; la puerta 5 lo usa para elegir el subconjunto de metricas aplicable. PLANTILLAS = [ # --- composicion multiseccion: la clase del fallo de produccion -------------------- ("composicion_multiseccion", "Hazme una landing completa de {dominio}, {ancho} px de ancho, con encabezado, hero, tres beneficios, testimonios y pie. Ponele color, nada de cajas grises.", "composicion-multiseccion-con-color"), ("composicion_multiseccion", "Necesito la home de {dominio} en {ancho} px: hero con foto de fondo, una franja de numeros y un bloque final de contacto. Paleta a tu criterio, que sea vibrante.", "composicion-multiseccion-con-color"), ("composicion_multiseccion", "Armame algo lindo para {dominio}, una pagina de presentacion. Vos elegis todo.", "composicion-multiseccion-con-color"), ("composicion_multiseccion", "Pagina de precios de {dominio} de {ancho} px: encabezado, tres planes con el del medio destacado en {color}, preguntas frecuentes y pie.", "composicion-multiseccion-con-color"), ("composicion_multiseccion", "Quiero la pantalla principal del panel interno de {dominio}, {ancho} px, con barra lateral, seis tarjetas de indicadores y una tabla de movimientos.", "composicion-multiseccion-con-color"), ("composicion_multiseccion", "Landing de un evento de {dominio}: fecha grande, cuenta regresiva, grilla de oradores y formulario de inscripcion, {ancho} px de ancho.", "composicion-multiseccion-con-color"), # --- componente acotado ----------------------------------------------------------- ("componente", "Un boton primario y uno secundario para {dominio}, ambos con estados normal y deshabilitado, usando {color} como acento.", "componente-con-estilo-explicito"), ("componente", "Card de producto para {dominio}: foto arriba, titulo en {tipografia}, precio tachado y precio final, y un boton. 320 de ancho.", "componente-con-estilo-explicito"), ("componente", "Hazme una barra de navegacion de {dominio} de {ancho}x88 con logo, cinco enlaces y un boton de accion a la derecha.", "componente-con-estilo-explicito"), ("componente", "Un aviso de cookies para el sitio de {dominio}: texto corto, dos botones y fondo {color}.", "componente-con-estilo-explicito"), ("componente", "Necesito una tarjeta de perfil de {dominio} con avatar circular, nombre, rol y tres datos de contacto.", "componente-con-estilo-explicito"), ("componente", "Necesito una tabla de {ancho} px para {dominio}: encabezado oscuro, cinco filas con fondo alternado y una columna de estado con etiquetas de color.", "componente-con-estilo-explicito"), # --- pantallas mobile ------------------------------------------------------------- ("imagen", "Pantalla de {mobile} para la app de {dominio}: foto real de portada traida de una URL, titulo encima y boton abajo.", "imagen-por-uploadmediaurl"), ("imagen", "Quiero una galeria de cuatro fotos reales de internet en la app de {dominio}, formato {mobile}, con leyenda en cada una.", "imagen-por-uploadmediaurl"), ("imagen", "Meteme una imagen de fondo en el hero de {dominio} y encima un velo oscuro para que se lea el titular en blanco.", "imagen-por-uploadmediaurl"), ("imagen", "Para la ficha de {dominio} necesito la foto del producto ocupando toda la tarjeta sin deformarse, {ancho} px de ancho.", "imagen-por-uploadmediaurl"), # --- layout: flex y grid ---------------------------------------------------------- ("layout", "Fila de tres tarjetas de {dominio} que se repartan el ancho de {ancho} px en partes iguales, con separacion pareja entre ellas.", "layout-con-sizing-declarado"), ("layout", "Grilla de {ancho} px con seis casillas para el catalogo de {dominio}, tres por fila, todas del mismo alto.", "layout-con-sizing-declarado"), ("layout", "Una columna de {dominio} donde el titulo va arriba, el texto en el medio y el boton abajo del todo, y el contenedor se ajusta al contenido.", "layout-con-sizing-declarado"), ("layout", "Pie de pagina de {ancho} px para {dominio} con cuatro columnas de enlaces y una linea final de derechos.", "layout-con-sizing-declarado"), ("layout", "Pantalla de {mobile} de {dominio} con una lista vertical de siete items, cada uno con icono a la izquierda y texto a la derecha.", "layout-con-sizing-declarado"), # --- reparacion de disenos grises ------------------------------------------------- ("reparacion", "En la pagina hay un board de {dominio} que quedo con puros rectangulos grises y sin una sola palabra. Convertilo en un diseno de verdad.", "reparacion-sin-grises"), ("reparacion", "Este board de {dominio} tiene todos los textos del mismo tamano y todo el mismo gris. Dale jerarquia y una paleta.", "reparacion-sin-grises"), ("reparacion", "Revisa el board de {dominio} que arme yo, decime que esta mal de contraste y arreglalo.", "reparacion-sin-grises"), ("reparacion", "El hero de {dominio} tiene el texto ilegible sobre la foto. Arreglalo sin cambiar la foto.", "reparacion-sin-grises"), # --- consulta de API antes de actuar ---------------------------------------------- ("consulta_api", "Antes de tocar nada, fijate en la documentacion de la API como se crea un texto en Penpot y despues poneme un titulo de {dominio} en 48 px.", "consulta-api-antes-de-actuar"), ("consulta_api", "Como hago para que un hijo de un board con layout ocupe todo el ancho disponible? Mostramelo aplicado a una seccion de {dominio}.", "consulta-api-antes-de-actuar"), ("consulta_api", "Quiero usar la tipografia {tipografia} en el titulo de {dominio}. Verifica que quede realmente aplicada, no solo seteada.", "consulta-api-antes-de-actuar"), ("consulta_api", "Guarda la paleta de {dominio} como colores de la biblioteca del archivo y despues aplicalos a dos formas.", "consulta-api-antes-de-actuar"), ("consulta_api", "Exportame el board de {dominio} para verlo, y decime si algo quedo fuera de los limites.", "consulta-api-antes-de-actuar"), ] def load_corpus(): """Textos de usuario y shingles de train + eval + todos los seeds.""" paths = [TRAIN_PATH, EVAL_PATH] paths += sorted(SEEDS_DIR.glob("*.jsonl")) paths += sorted((SEEDS_DIR / "_parts").glob("*.jsonl")) return BUILDER.load_reference_corpus(paths) def load_corpus_text(): chunks = [] for path in [TRAIN_PATH, EVAL_PATH] + sorted(SEEDS_DIR.glob("*.jsonl")) + \ sorted((SEEDS_DIR / "_parts").glob("*.jsonl")): if path.exists(): chunks.append(path.read_text(encoding="utf-8")) return normalize(" ".join(chunks)) def assert_pools_disjuntos(corpus_text): """Ningun valor de los pools aparece en los seeds/train/eval ni en el holdout de la puerta 2 (scripts/31_build_holdout_prompts.py).""" pools = { "dominios": DOMINIOS, "colores": COLORES, "mobile": MOBILE, "tipografias": TIPOGRAFIAS, } pools_gate2 = { "dominios": BUILDER.DOMINIOS, "colores": BUILDER.COLORS, "mobile": [], "tipografias": [], } for nombre, valores in pools.items(): repetidos = sorted(set(valores) & set(pools_gate2[nombre])) if repetidos: raise AssertionError( f"pool '{nombre}' comparte valores con el holdout de la puerta 2: {repetidos}" ) for valor in valores: if normalize(valor) in corpus_text: raise AssertionError( f"valor '{valor}' del pool '{nombre}' aparece en los seeds/train/eval" ) repetidos = sorted(set(ANCHOS) & set(BUILDER.WIDTHS)) if repetidos: raise AssertionError(f"pool 'anchos' comparte valores con el holdout de la puerta 2: {repetidos}") print(f"[INFO] pools disjuntos verificados ({sum(len(v) for v in pools.values())} valores)") def render(rng, plantilla): return plantilla.format( dominio=rng.choice(DOMINIOS), color=rng.choice(COLORES), ancho=rng.choice(ANCHOS), mobile=rng.choice(MOBILE), tipografia=rng.choice(TIPOGRAFIAS), ) def main(): rng = random.Random(SEED) existing_texts, reference_shingles = load_corpus() print(f"[INFO] corpus de referencia: {len(existing_texts)} turnos de usuario, " f"{len(reference_shingles)} shingles") assert_pools_disjuntos(load_corpus_text()) rows = [] seen = set() max_overlap = 0.0 # Dos pasadas sobre las 30 plantillas -> 60 prompts, con valores distintos en cada una. for vuelta in range(TARGET_TOTAL // len(PLANTILLAS)): for idx, (categoria, plantilla, expect) in enumerate(PLANTILLAS): for _ in range(200): prompt = render(rng, plantilla) if prompt not in seen: break else: raise AssertionError(f"sin combinaciones nuevas para la plantilla {plantilla!r}") seen.add(prompt) overlap = assert_no_contamination( prompt, existing_texts, reference_shingles, origen=" de diseno" ) max_overlap = max(max_overlap, overlap) rows.append({ "id": f"pd-{vuelta * len(PLANTILLAS) + idx + 1:03d}", "prompt": prompt, "categoria": categoria, "expect": expect, }) if len(rows) < TARGET_TOTAL: raise AssertionError(f"solo se generaron {len(rows)} prompts, se esperaban {TARGET_TOTAL}") if len(set(r["prompt"] for r in rows)) != len(rows): raise AssertionError("hay prompts duplicados en el holdout de diseno") with open(OUT_PATH, "w", encoding="utf-8") as f: for row in rows: f.write(json.dumps(row, ensure_ascii=False) + "\n") print(f"[INFO] {len(rows)} prompts de diseno escritos en {OUT_PATH}") print(f"[INFO] solapamiento maximo de shingles: {max_overlap:.0%} " f"(umbral {MAX_SHINGLE_OVERLAP:.0%})") for categoria in sorted({r["categoria"] for r in rows}): n = sum(1 for r in rows if r["categoria"] == categoria) print(f" {categoria}: {n}") print(f"[INFO] umbral de la puerta 5: tasa de API prohibida <= 5% " f"({int(0.05 * len(rows))}/{len(rows)})") if __name__ == "__main__": main()