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
9 changed files with 2046 additions and 402 deletions
Showing only changes of commit a60d0751cf - Show all commits
+60
View File
@@ -0,0 +1,60 @@
{"id": "pd-001", "prompt": "Hazme una landing completa de una academia de tango, 1180 px de ancho, con encabezado, hero, tres beneficios, testimonios y pie. Ponele color, nada de cajas grises.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-002", "prompt": "Necesito la home de un estudio de doblaje en 1180 px: hero con foto de fondo, una franja de numeros y un bloque final de contacto. Paleta a tu criterio, que sea vibrante.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-003", "prompt": "Armame algo lindo para un museo de maquinas de escribir, una pagina de presentacion. Vos elegis todo.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-004", "prompt": "Pagina de precios de un vivero de orquideas de 900 px: encabezado, tres planes con el del medio destacado en #312e81, preguntas frecuentes y pie.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-005", "prompt": "Quiero la pantalla principal del panel interno de un vivero de orquideas, 1320 px, con barra lateral, seis tarjetas de indicadores y una tabla de movimientos.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-006", "prompt": "Landing de un evento de un laboratorio de analisis de suelos: fecha grande, cuenta regresiva, grilla de oradores y formulario de inscripcion, 1240 px de ancho.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-007", "prompt": "Un boton primario y uno secundario para un servicio de reparacion de relojes, ambos con estados normal y deshabilitado, usando #581c87 como acento.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-008", "prompt": "Card de producto para un servicio de reparacion de relojes: foto arriba, titulo en Karla, precio tachado y precio final, y un boton. 320 de ancho.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-009", "prompt": "Hazme una barra de navegacion de una academia de tango de 900x88 con logo, cinco enlaces y un boton de accion a la derecha.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-010", "prompt": "Un aviso de cookies para el sitio de un estudio de doblaje: texto corto, dos botones y fondo #3b0764.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-011", "prompt": "Necesito una tarjeta de perfil de una escuela de circo con avatar circular, nombre, rol y tres datos de contacto.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-012", "prompt": "Necesito una tabla de 1320 px para un vivero de orquideas: encabezado oscuro, cinco filas con fondo alternado y una columna de estado con etiquetas de color.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-013", "prompt": "Pantalla de 390x844 para la app de una consultora de eficiencia energetica: foto real de portada traida de una URL, titulo encima y boton abajo.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-014", "prompt": "Quiero una galeria de cuatro fotos reales de internet en la app de un albergue para perros mayores, formato 360x780, con leyenda en cada una.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-015", "prompt": "Meteme una imagen de fondo en el hero de una escuela de circo y encima un velo oscuro para que se lea el titular en blanco.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-016", "prompt": "Para la ficha de una escuela de circo necesito la foto del producto ocupando toda la tarjeta sin deformarse, 1320 px de ancho.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-017", "prompt": "Fila de tres tarjetas de un estudio de doblaje que se repartan el ancho de 1180 px en partes iguales, con separacion pareja entre ellas.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-018", "prompt": "Grilla de 1180 px con seis casillas para el catalogo de un servicio de reparacion de relojes, tres por fila, todas del mismo alto.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-019", "prompt": "Una columna de una consultora de eficiencia energetica donde el titulo va arriba, el texto en el medio y el boton abajo del todo, y el contenedor se ajusta al contenido.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-020", "prompt": "Pie de pagina de 1180 px para una cooperativa de miel con cuatro columnas de enlaces y una linea final de derechos.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-021", "prompt": "Pantalla de 412x915 de una tienda de kayaks con una lista vertical de siete items, cada uno con icono a la izquierda y texto a la derecha.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-022", "prompt": "En la pagina hay un board de una escuela de circo que quedo con puros rectangulos grises y sin una sola palabra. Convertilo en un diseno de verdad.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-023", "prompt": "Este board de una fabrica de velas de soja tiene todos los textos del mismo tamano y todo el mismo gris. Dale jerarquia y una paleta.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-024", "prompt": "Revisa el board de un albergue para perros mayores que arme yo, decime que esta mal de contraste y arreglalo.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-025", "prompt": "El hero de un laboratorio de analisis de suelos tiene el texto ilegible sobre la foto. Arreglalo sin cambiar la foto.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-026", "prompt": "Antes de tocar nada, fijate en la documentacion de la API como se crea un texto en Penpot y despues poneme un titulo de un albergue para perros mayores en 48 px.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-027", "prompt": "Como hago para que un hijo de un board con layout ocupe todo el ancho disponible? Mostramelo aplicado a una seccion de una fabrica de velas de soja.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-028", "prompt": "Quiero usar la tipografia Karla en el titulo de una cooperativa de miel. Verifica que quede realmente aplicada, no solo seteada.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-029", "prompt": "Guarda la paleta de una academia de tango como colores de la biblioteca del archivo y despues aplicalos a dos formas.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-030", "prompt": "Exportame el board de una tienda de kayaks para verlo, y decime si algo quedo fuera de los limites.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-031", "prompt": "Hazme una landing completa de una fabrica de velas de soja, 1320 px de ancho, con encabezado, hero, tres beneficios, testimonios y pie. Ponele color, nada de cajas grises.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-032", "prompt": "Necesito la home de un vivero de orquideas en 900 px: hero con foto de fondo, una franja de numeros y un bloque final de contacto. Paleta a tu criterio, que sea vibrante.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-033", "prompt": "Armame algo lindo para una tienda de kayaks, una pagina de presentacion. Vos elegis todo.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-034", "prompt": "Pagina de precios de una cooperativa de miel de 1320 px: encabezado, tres planes con el del medio destacado en #3b0764, preguntas frecuentes y pie.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-035", "prompt": "Quiero la pantalla principal del panel interno de un laboratorio de analisis de suelos, 900 px, con barra lateral, seis tarjetas de indicadores y una tabla de movimientos.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-036", "prompt": "Landing de un evento de un servicio de reparacion de relojes: fecha grande, cuenta regresiva, grilla de oradores y formulario de inscripcion, 900 px de ancho.", "categoria": "composicion_multiseccion", "expect": "composicion-multiseccion-con-color"}
{"id": "pd-037", "prompt": "Un boton primario y uno secundario para una consultora de eficiencia energetica, ambos con estados normal y deshabilitado, usando #155e75 como acento.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-038", "prompt": "Card de producto para una cooperativa de miel: foto arriba, titulo en Libre Baskerville, precio tachado y precio final, y un boton. 320 de ancho.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-039", "prompt": "Hazme una barra de navegacion de un laboratorio de analisis de suelos de 1180x88 con logo, cinco enlaces y un boton de accion a la derecha.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-040", "prompt": "Un aviso de cookies para el sitio de un estudio de doblaje: texto corto, dos botones y fondo #581c87.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-041", "prompt": "Necesito una tarjeta de perfil de una fabrica de velas de soja con avatar circular, nombre, rol y tres datos de contacto.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-042", "prompt": "Necesito una tabla de 1180 px para una academia de tango: encabezado oscuro, cinco filas con fondo alternado y una columna de estado con etiquetas de color.", "categoria": "componente", "expect": "componente-con-estilo-explicito"}
{"id": "pd-043", "prompt": "Pantalla de 360x780 para la app de una academia de tango: foto real de portada traida de una URL, titulo encima y boton abajo.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-044", "prompt": "Quiero una galeria de cuatro fotos reales de internet en la app de una consultora de eficiencia energetica, formato 412x915, con leyenda en cada una.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-045", "prompt": "Meteme una imagen de fondo en el hero de un museo de maquinas de escribir y encima un velo oscuro para que se lea el titular en blanco.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-046", "prompt": "Para la ficha de un estudio de doblaje necesito la foto del producto ocupando toda la tarjeta sin deformarse, 1240 px de ancho.", "categoria": "imagen", "expect": "imagen-por-uploadmediaurl"}
{"id": "pd-047", "prompt": "Fila de tres tarjetas de un servicio de reparacion de relojes que se repartan el ancho de 1180 px en partes iguales, con separacion pareja entre ellas.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-048", "prompt": "Grilla de 900 px con seis casillas para el catalogo de un vivero de orquideas, tres por fila, todas del mismo alto.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-049", "prompt": "Una columna de un museo de maquinas de escribir donde el titulo va arriba, el texto en el medio y el boton abajo del todo, y el contenedor se ajusta al contenido.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-050", "prompt": "Pie de pagina de 1240 px para una academia de tango con cuatro columnas de enlaces y una linea final de derechos.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-051", "prompt": "Pantalla de 360x780 de un albergue para perros mayores con una lista vertical de siete items, cada uno con icono a la izquierda y texto a la derecha.", "categoria": "layout", "expect": "layout-con-sizing-declarado"}
{"id": "pd-052", "prompt": "En la pagina hay un board de un museo de maquinas de escribir que quedo con puros rectangulos grises y sin una sola palabra. Convertilo en un diseno de verdad.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-053", "prompt": "Este board de una consultora de eficiencia energetica tiene todos los textos del mismo tamano y todo el mismo gris. Dale jerarquia y una paleta.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-054", "prompt": "Revisa el board de un estudio de doblaje que arme yo, decime que esta mal de contraste y arreglalo.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-055", "prompt": "El hero de una tienda de kayaks tiene el texto ilegible sobre la foto. Arreglalo sin cambiar la foto.", "categoria": "reparacion", "expect": "reparacion-sin-grises"}
{"id": "pd-056", "prompt": "Antes de tocar nada, fijate en la documentacion de la API como se crea un texto en Penpot y despues poneme un titulo de un estudio de doblaje en 48 px.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-057", "prompt": "Como hago para que un hijo de un board con layout ocupe todo el ancho disponible? Mostramelo aplicado a una seccion de una consultora de eficiencia energetica.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-058", "prompt": "Quiero usar la tipografia Libre Baskerville en el titulo de una consultora de eficiencia energetica. Verifica que quede realmente aplicada, no solo seteada.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-059", "prompt": "Guarda la paleta de un museo de maquinas de escribir como colores de la biblioteca del archivo y despues aplicalos a dos formas.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
{"id": "pd-060", "prompt": "Exportame el board de una consultora de eficiencia energetica para verlo, y decime si algo quedo fuera de los limites.", "categoria": "consulta_api", "expect": "consulta-api-antes-de-actuar"}
+200 -200
View File
File diff suppressed because one or more lines are too long
+649 -62
View File
@@ -1,33 +1,113 @@
"""Fase 2: ensambla data/train.jsonl y data/eval.jsonl (v1 a volumen reducido) a partir de
los seeds escritos a mano en data/raw/seeds/*.jsonl mas data/raw/replay.jsonl.
"""Fase 2 (revisado en Fase 6): ensambla un train/eval jsonl a partir de los seeds escritos a
mano en <SEEDS_DIR>/*.jsonl mas data/raw/replay.jsonl.
Corre localmente (no requiere GPU ni el modelo). Aplica variacion deterministica
(random.seed(42)) sobre los seeds -- sustitucion de valores (colores, ids, numeros) +
un set chico de parafraseos por bucket -- para llegar al volumen v1 objetivo por bucket
sin duplicar lineas exactas. Verifica que ningun seed mencione la skill held-out
(spark-ssh). Tagea cada ejemplo con meta.bucket, mezcla, y separa 90/10 train/eval
estratificado por bucket para que eval no quede dominado por un solo bucket.
(random.seed(42)) sobre los seeds para llegar al volumen objetivo por bucket sin duplicar
lineas exactas. Verifica que ningun seed mencione la skill held-out (spark-ssh). Tagea cada
ejemplo con meta.bucket, mezcla, y separa 90/10 train/eval estratificado por bucket.
PARAMETROS POR ENV (los defaults reproducen la invocacion historica de Fase 2)
-----------------------------------------------------------------------------
SEEDS_DIR directorio de seeds (def: data/raw/seeds)
REPLAY_FILE jsonl de replay de Fase 1 (def: data/raw/replay.jsonl)
TRAIN_OUT destino del split de train (def: data/train.jsonl)
EVAL_OUT destino del split de eval (def: data/eval.jsonl)
ALLOW_OVERWRITE '1' para permitir pisar un destino que ya existe (def: off)
Las rutas relativas se resuelven contra la raiz del repo.
REGLA CRITICA: NO REGENERAR data/train.jsonl NI data/eval.jsonl
---------------------------------------------------------------
Esos dos archivos son la procedencia exacta del modelo que hoy esta en produccion y el
baseline de la puerta 1 (perdida ponderada 0.2750). Por eso hay un guard que ABORTA si el
destino ya existe, salvo que se pase ALLOW_OVERWRITE=1. Los datasets nuevos se construyen a
archivos nuevos:
TRAIN_OUT=data/train_v2.jsonl EVAL_OUT=data/eval_v2.jsonl python3 scripts/05_build_dataset.py
Y ademas: regenerar NO es idempotente. `stratified_split()` mezcla con un unico RNG
(random.Random(42)) sobre la lista concatenada de todos los buckets, asi que cambiar el
tamano de UN bucket (p.ej. BUCKET_TARGETS['penpot'] de 110 a 320) corre la secuencia de
numeros aleatorios y reshufflea el split de TODOS los buckets. Volver a correr este script
con los targets de hoy NO reconstruye el train.jsonl de Fase 2 aunque los seeds no hayan
cambiado.
AUGMENTACION (reescrita en Fase 6)
----------------------------------
La version anterior tenia dos defectos medidos sobre el train.jsonl de Fase 2:
1. `perturb_value()` reescribia SOLO `tool_calls.function.arguments`, nunca los tool results
ni el `content` final del assistant -> 30 ejemplos auto-contradictorios (la llamada decia
`issue_number: 82` y la respuesta final decia "issue #77"). El modelo se entrenaba a
ignorar sus propios argumentos.
2. La inyeccion de prefijos de parafraseo se pegaba delante de cualquier turno de usuario, sin
mirar su forma gramatical -> 68 prompts rotos del tipo
"Necesito que ¿Podes usar el tool de import_image...".
Reemplazos:
- La sustitucion de valores es ATOMICA y se declara en el seed (`meta.variation`): se aplica
sobre TODOS los campos de texto del ejemplo a la vez (arguments, tool results, contents,
reasoning_content), de modo que un valor que cambia, cambia en todas partes. Sin
`meta.variation` no se sustituye ningun valor -- adivinar que numero de un string es
"seguro" de perturbar es exactamente lo que producia las contradicciones.
- `NEVER_PERTURB_KEYS` protege los valores que jamas pueden variar (`code`, `shapeId`,
`tool_call_id`, `id`). Un payload de Penpot perturbado es codigo roto. Si un valor de
`meta.variation` aparece dentro de un campo protegido, la build FALLA: seria una variante
internamente inconsistente, justo lo que veniamos a eliminar.
- El bucket `penpot` esta exento de sustitucion de valores por completo (NO_PERTURB_BUCKETS):
sus payloads son codigo, y la variacion tiene que venir de seeds distintos.
- `meta.paraphrases` (2-3 reescrituras a mano del turno del usuario) es la via PREFERIDA de
variacion del prompt. La inyeccion de prefijos queda solo como fallback y solo cuando el
turno arranca con un imperativo que sabemos conjugar; cualquier otra forma se deja intacta.
`meta.variation` y `meta.paraphrases` son entradas de build: no se escriben al dataset final.
FORMATO DE meta.variation
-------------------------
Dos formas equivalentes, las dos deterministas por indice de variante:
"meta": {"variation": {"82": ["77", "91"], "billing/pricing.py": ["core/rates.py"]}}
"meta": {"variation": [{"82": "77"}, {"82": "91"}]}
La primera es un mapa token -> lista de reemplazos (la variante i usa el elemento
(i-1) % len). La segunda es una lista de mapas completos, uno por variante.
"""
import importlib.util
import json
import os
import random
import re
import sys
from collections import Counter
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SEEDS_DIR = REPO_ROOT / "data" / "raw" / "seeds"
REPLAY_PATH = REPO_ROOT / "data" / "raw" / "replay.jsonl"
TRAIN_PATH = REPO_ROOT / "data" / "train.jsonl"
EVAL_PATH = REPO_ROOT / "data" / "eval.jsonl"
def _path_env(name, default_rel):
"""Resuelve una ruta por env; las relativas cuelgan de la raiz del repo."""
raw = os.environ.get(name)
if not raw:
return REPO_ROOT / default_rel
p = Path(raw)
return p if p.is_absolute() else REPO_ROOT / p
SEEDS_DIR = _path_env("SEEDS_DIR", "data/raw/seeds")
REPLAY_PATH = _path_env("REPLAY_FILE", "data/raw/replay.jsonl")
TRAIN_PATH = _path_env("TRAIN_OUT", "data/train.jsonl")
EVAL_PATH = _path_env("EVAL_OUT", "data/eval.jsonl")
ALLOW_OVERWRITE = os.environ.get("ALLOW_OVERWRITE", "").lower() in ("1", "true", "yes")
SEED = 42
EVAL_FRACTION = 0.10
HELD_OUT_SKILL = "spark-ssh"
# Volumen v1 objetivo por bucket (aprox. 800-1000 nuevos, ver PLAN.md seccion 3).
# El bucket "replay" no se varia -- se usa tal cual viene de la Fase 1.
# Volumen objetivo por bucket. El bucket "replay" no se varia -- se usa tal cual viene de la
# Fase 1. `penpot` sube de 110 a 320 en Fase 6 (el corpus de diseno reescrito).
BUCKET_TARGETS = {
"penpot": 110,
"penpot": 320,
"otros_mcps": 290,
"skills_adherencia": 160,
"delegacion_subagentes": 65,
@@ -35,21 +115,224 @@ BUCKET_TARGETS = {
"manejo_errores": 55,
}
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
TRAILING_NUMBER_RE = re.compile(r"^(.*?)(\d+)$")
# Claves cuyo valor NUNCA se sustituye, en ningun bucket. `code` es un payload de JavaScript
# (perturbarlo produce codigo roto); los ids son referencias que tienen que seguir cerrando
# entre la llamada y su resultado.
NEVER_PERTURB_KEYS = {"code", "shapeId", "tool_call_id", "id"}
# Buckets exentos de sustitucion de valores por completo. Su variacion viene de seeds
# distintos, no de reescribir strings.
NO_PERTURB_BUCKETS = {"penpot"}
# Anti-colapso del bucket penpot: como esta exento de sustitucion, la unica fuente de
# variacion son `meta.paraphrases` y seeds distintos. Si un corpus llega a 320 filas
# repitiendo 96 trayectorias identicas, la augmentacion no augmenta nada (es lo que pasaba en
# Fase 2: 99 filas -> 40 trayectorias unicas sobre 41 seeds). Se exige >= 2.5 trayectorias
# unicas por seed, que es justo lo que dan 2-3 parafrasis a mano por seed mas el original.
PENPOT_UNIQUE_RATIO = float(os.environ.get("PENPOT_UNIQUE_RATIO", "2.5"))
# El assert duro que mata los 68 prompts rotos de Fase 2: ningun turno de usuario del dataset
# final puede tener un prefijo de parafraseo pegado delante de una pregunta ya formada.
BROKEN_PREFIX_RE = re.compile(r"(?:Necesito que|Necesito|Por favor,|Che,|Dale,|¿Pod[eé]s)\s*¿")
# ------------------------------------------------------------------------------------------
# Parafraseo por prefijo (FALLBACK -- la via preferida es meta.paraphrases)
# ------------------------------------------------------------------------------------------
# Cada prefijo declara que forma verbal exige. Un prefijo solo se aplica si podemos convertir
# el primer verbo del turno a esa forma; si no, el turno se deja intacto. Preferimos perder
# variacion antes que emitir un prompt agramatical: el modelo aprende la forma del prompt.
# imperativo -> el turno ya esta en imperativo, solo baja la mayuscula ("Por favor, crea ...")
# infinitivo -> "Necesito crear ..." (declarativa, funciona con multiples oraciones)
# pregunta -> "¿Podes crear ...?" (solo si el turno es UNA oracion afirmativa)
PARAPHRASE_PREFIXES = [
"",
"Por favor, ",
"Necesito que ",
"¿Podés ",
"Che, ",
"Dale, ",
("", None),
("Por favor, ", "imperativo"),
("Che, ", "imperativo"),
("Dale, ", "imperativo"),
("Necesito ", "infinitivo"),
("¿Podés ", "pregunta"),
]
PALETTE = ["#1a73e8", "#e8710a", "#188038", "#d93025", "#9334e6", "#12b5cb", "#f9ab00", "#3c4043"]
# Imperativos con cambio de raiz: no se derivan mecanicamente, van a mano.
IRREGULAR_IMPERATIVE_TO_INFINITIVE = {
"cierra": "cerrar",
"empieza": "empezar",
"encuentra": "encontrar",
"cuenta": "contar",
"muestra": "mostrar",
"prueba": "probar",
"mueve": "mover",
"vuelve": "volver",
"recuerda": "recordar",
"corrige": "corregir",
"repite": "repetir",
"pide": "pedir",
"sigue": "seguir",
"consigue": "conseguir",
"convierte": "convertir",
"invierte": "invertir",
"sugiere": "sugerir",
"elige": "elegir",
"dame": "darme",
"da": "dar",
"haz": "hacer",
"ve": "ver",
"ten": "tener",
"pon": "poner",
"sal": "salir",
"di": "decir",
"sube": "subir",
"abre": "abrir",
"reabre": "reabrir",
"escribe": "escribir",
"describe": "describir",
"revierte": "revertir",
"anade": "anadir",
"añade": "añadir",
"responde": "responder",
"corre": "correr",
"lee": "leer",
"mete": "meter",
"rompe": "romper",
"vende": "vender",
"aprende": "aprender",
"borra": "borrar",
}
ENCLITIC_PRONOUNS = ("melo", "mela", "selo", "sela", "nos", "me", "lo", "la", "los", "las", "le", "les")
FIRST_WORD_RE = re.compile(r"^([A-Za-zÁÉÍÓÚÜÑáéíóúüñ]+)(.*)$", re.S)
def strip_accent(word):
table = str.maketrans("áéíóúÁÉÍÓÚ", "aeiouAEIOU")
return word.translate(table)
def has_enclitic(infinitive):
"""'dame' -> 'darme', 'mostrame' -> 'mostrarme': el infinitivo hereda el pronombre, que
despues de 'Necesito ' suena mal ('Necesito darme el resumen'). Con '¿Podés ' es correcto."""
return any(infinitive.endswith("r" + pron) for pron in ENCLITIC_PRONOUNS)
def to_infinitive(word):
"""Convierte un imperativo (tuteo o voseo, con o sin enclitico) a infinitivo.
Devuelve None si no estamos seguros -- el llamador entonces NO aplica el prefijo. Es
deliberado: un imperativo desconocido produce un prompt agramatical, y el costo de
perder una variante es mucho menor que el de entrenar sobre castellano roto.
"""
lower = word.lower()
enclitic = ""
base = lower
for pron in ENCLITIC_PRONOUNS:
if len(lower) > len(pron) + 2 and lower.endswith(pron):
base, enclitic = lower[: -len(pron)], pron
break
if lower in IRREGULAR_IMPERATIVE_TO_INFINITIVE:
return IRREGULAR_IMPERATIVE_TO_INFINITIVE[lower]
if base in IRREGULAR_IMPERATIVE_TO_INFINITIVE:
return IRREGULAR_IMPERATIVE_TO_INFINITIVE[base] + enclitic
# Voseo: acento en la ultima vocal (crea' -> crear, hace' -> hacer, subi' -> subir).
if base and base[-1] in "áéí":
return strip_accent(base) + "r" + enclitic
# Tuteo regular de la primera conjugacion: crea -> crear, busca -> buscar.
if len(base) >= 4 and base.endswith("a"):
return base + "r" + enclitic
# Todo lo demas (terminaciones en -e, que pueden ser -er o -ir, y cualquier otra forma)
# es ambiguo: no adivinamos.
return None
def apply_prefix(content, prefix, mode):
"""Aplica un prefijo de parafraseo respetando la gramatica. Devuelve None si no se puede."""
if not prefix:
return content
stripped = content.strip()
if not stripped or stripped[0] in "¿¡":
# Nunca pegar un prefijo delante de una pregunta/exclamacion ya formada. Este es el
# caso exacto que producia "Necesito que ¿Podes usar el tool de import_image...".
return None
m = FIRST_WORD_RE.match(stripped)
if not m:
return None
first, rest = m.group(1), m.group(2)
if mode == "imperativo":
infinitive = to_infinitive(first)
if infinitive is None:
# Si no reconocemos un imperativo, el turno probablemente es declarativo
# ("El ci-developer que lance devolvio BLOCKED...") y "Por favor, el ci-developer
# que..." tampoco es castellano. No se aplica prefijo.
return None
return prefix + first.lower() + rest
if mode == "infinitivo":
infinitive = to_infinitive(first)
if infinitive is None or has_enclitic(infinitive):
return None
return prefix + infinitive + rest
if mode == "pregunta":
infinitive = to_infinitive(first)
if infinitive is None:
return None
# Solo si es UNA oracion afirmativa: convertirla en pregunta exige tocar el cierre.
if "?" in stripped or "!" in stripped or "\n" in stripped:
return None
if not stripped.endswith(".") or stripped.count(".") != 1:
return None
return prefix + infinitive + rest[:-1] + "?"
return None
# ------------------------------------------------------------------------------------------
# Patrones prohibidos: la fuente de verdad es scripts/07_lint_penpot_code.py
# ------------------------------------------------------------------------------------------
# Se IMPORTAN de ahi (no se copian) para que no puedan divergir. Si el lint renombra una
# entrada, este script falla ruidosamente en vez de dejar de chequear en silencio.
FORBIDDEN_PATTERN_NAMES = [
"findShapeById con 2 argumentos",
"propiedad .layout inexistente",
"flex.appendChild",
"import_image / importImage / createImage / filePath",
"textAlign",
"typography.setFont",
]
LINT_PATH = Path(__file__).resolve().parent / "07_lint_penpot_code.py"
def load_forbidden_patterns():
spec = importlib.util.spec_from_file_location("penpot_lint", LINT_PATH)
if spec is None or spec.loader is None:
print(f"[ERROR] no se pudo importar {LINT_PATH}")
sys.exit(1)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
by_name = {name: rx for name, rx, _why in module.FORBIDDEN}
missing = [n for n in FORBIDDEN_PATTERN_NAMES if n not in by_name]
if missing:
print(
f"[ERROR] {LINT_PATH.name} ya no define estos patrones: {missing}. "
f"Actualizar FORBIDDEN_PATTERN_NAMES en este script (la fuente de verdad de los "
f"patrones es el lint, no esta copia de nombres)."
)
sys.exit(1)
return [(n, by_name[n]) for n in FORBIDDEN_PATTERN_NAMES]
# ------------------------------------------------------------------------------------------
# Carga
# ------------------------------------------------------------------------------------------
def load_jsonl(path):
lines = []
with open(path, encoding="utf-8") as f:
@@ -88,52 +371,155 @@ def assert_no_held_out_skill(by_bucket):
print(f"[OK] ninguna mencion de la skill held-out '{HELD_OUT_SKILL}' en los seeds")
def perturb_value(value, rng):
if isinstance(value, str):
if HEX_COLOR_RE.match(value):
return rng.choice(PALETTE)
m = TRAILING_NUMBER_RE.match(value)
if m and len(m.group(2)) <= 3:
prefix, number = m.group(1), m.group(2)
new_number = rng.randint(1, 99)
return f"{prefix}{new_number}"
return value
if isinstance(value, bool):
return value
if isinstance(value, int):
jitter = rng.randint(-max(1, value // 4), max(1, value // 4))
return max(0, value + jitter)
if isinstance(value, float):
jitter = rng.uniform(-0.25, 0.25) * value
return round(value + jitter, 2)
if isinstance(value, dict):
return {k: perturb_value(v, rng) for k, v in value.items()}
if isinstance(value, list):
return [perturb_value(v, rng) for v in value]
return value
# ------------------------------------------------------------------------------------------
# Sustitucion atomica de valores
# ------------------------------------------------------------------------------------------
def resolve_variation(meta, variant_idx):
"""Devuelve el mapa token -> reemplazo para esta variante, o {} si el seed no declara
variacion. variant_idx >= 1 (la variante 0 es el seed sin tocar)."""
variation = meta.get("variation")
if not variation:
return {}
if isinstance(variation, list):
chosen = variation[(variant_idx - 1) % len(variation)]
if not isinstance(chosen, dict):
raise AssertionError(
"[ASSERT FAIL] meta.variation como lista tiene que contener dicts "
"token -> reemplazo"
)
return {str(k): str(v) for k, v in chosen.items()}
if isinstance(variation, dict):
mapping = {}
for token, options in variation.items():
if not isinstance(options, list) or not options:
raise AssertionError(
f"[ASSERT FAIL] meta.variation['{token}'] tiene que ser una lista no vacia "
f"de reemplazos"
)
mapping[str(token)] = str(options[(variant_idx - 1) % len(options)])
return mapping
raise AssertionError("[ASSERT FAIL] meta.variation tiene que ser un dict o una lista")
def vary_example(example, rng, variant_idx):
"""Produce una variante determinista del ejemplo (variant_idx=0 devuelve el
original sin tocar, para preservar los seeds tal cual como parte del volumen)."""
if variant_idx == 0:
def substitute_atomic(example, mapping):
"""Aplica el mapa de sustitucion a TODOS los campos de texto del ejemplo a la vez.
Un valor que cambia, cambia en todas partes: arguments (incluidos los escalares JSON no
string: `issue_number: 82` es tan parte del ejemplo como el "#82" del texto), tool
results, content y reasoning_content. Los valores bajo NEVER_PERTURB_KEYS quedan
intactos, y si el token original sobrevive en alguno de ellos se lanza -- seria una
variante auto-contradictoria, exactamente el defecto que esta reescritura vino a eliminar.
"""
if not mapping:
return json.loads(json.dumps(example, ensure_ascii=False))
varied = json.loads(json.dumps(example, ensure_ascii=False))
pattern = re.compile("|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True)))
survivors = []
hits = Counter()
for msg in varied.get("messages", []):
if msg.get("role") == "user" and variant_idx > 0:
prefix = rng.choice(PARAPHRASE_PREFIXES)
if prefix and not msg["content"][:1].islower():
msg["content"] = prefix + msg["content"][0].lower() + msg["content"][1:]
for tool_call in msg.get("tool_calls", []) or []:
args = tool_call.get("function", {}).get("arguments")
if isinstance(args, dict):
tool_call["function"]["arguments"] = perturb_value(args, rng)
def walk(node, protected):
if isinstance(node, dict):
return {
key: walk(value, protected or key in NEVER_PERTURB_KEYS)
for key, value in node.items()
}
if isinstance(node, list):
return [walk(item, protected) for item in node]
if isinstance(node, str):
if protected:
for token in mapping:
if token in node:
survivors.append(token)
return node
def _sub(m):
hits[m.group(0)] += 1
return mapping[m.group(0)]
return pattern.sub(_sub, node)
if isinstance(node, (int, float)) and not isinstance(node, bool):
# Un escalar JSON (issue_number: 82) se sustituye por igualdad exacta de su
# representacion, y se devuelve con el mismo tipo. Si no se hiciera, la llamada
# diria 82 y el texto 77: el defecto de Fase 2.
key = repr(node) if isinstance(node, float) else str(node)
if key in mapping:
if protected:
survivors.append(key)
return node
hits[key] += 1
replacement = mapping[key]
try:
return type(node)(replacement)
except ValueError:
return replacement
return node
return node
varied = walk(example, False)
if survivors:
raise AssertionError(
f"[ASSERT FAIL] los valores {sorted(set(survivors))} de meta.variation aparecen "
f"dentro de un campo protegido ({sorted(NEVER_PERTURB_KEYS)}) y no se pueden "
f"sustituir ahi. La variante quedaria auto-contradictoria: sacar esos valores de "
f"meta.variation o reescribir el seed."
)
unused = [token for token in mapping if hits[token] == 0]
if unused:
raise AssertionError(
f"[ASSERT FAIL] los tokens {unused} de meta.variation no aparecen en el ejemplo "
f"(typo en el seed): la sustitucion no haria nada"
)
return varied
def strip_build_only_meta(example):
"""meta.variation y meta.paraphrases son entradas de build, no datos de entrenamiento."""
meta = example.get("meta")
if isinstance(meta, dict):
meta.pop("variation", None)
meta.pop("paraphrases", None)
return example
def vary_example(example, rng, variant_idx, bucket):
"""Produce una variante determinista del ejemplo.
variant_idx == 0 devuelve el seed tal cual (los seeds originales son parte del volumen).
Para variant_idx > 0:
1. reescritura del turno del usuario: meta.paraphrases si el seed las trae, y si no,
inyeccion de un prefijo gramaticalmente compatible como fallback,
2. sustitucion atomica de valores segun meta.variation (salvo buckets exentos).
El orden importa: la sustitucion va DESPUES de la parafrasis para que un valor citado en
la parafrasis a mano ("Pasame el issue 82") tambien se sustituya. Al reves, la parafrasis
reintroduciria el valor viejo y la variante volveria a ser auto-contradictoria.
"""
varied = json.loads(json.dumps(example, ensure_ascii=False))
if variant_idx == 0:
return strip_build_only_meta(varied)
meta = varied.get("meta", {}) or {}
paraphrases = meta.get("paraphrases") or []
first_user = next((m for m in varied.get("messages", []) if m.get("role") == "user"), None)
if first_user is not None:
if paraphrases:
# Via preferida: reescrituras a mano del turno del usuario.
first_user["content"] = paraphrases[(variant_idx - 1) % len(paraphrases)]
else:
prefix, mode = rng.choice(PARAPHRASE_PREFIXES)
rewritten = apply_prefix(first_user["content"], prefix, mode)
if rewritten is not None:
first_user["content"] = rewritten
mapping = {} if bucket in NO_PERTURB_BUCKETS else resolve_variation(meta, variant_idx)
# Se limpia meta ANTES de sustituir: si no, un token que solo aparece en meta.paraphrases
# contaria como "usado" y el chequeo de tokens muertos dejaria de servir.
strip_build_only_meta(varied)
return substitute_atomic(varied, mapping)
def build_bucket(bucket, seeds, target_count):
rng = random.Random(f"{SEED}-{bucket}")
n_seeds = len(seeds)
@@ -142,7 +528,7 @@ def build_bucket(bucket, seeds, target_count):
return []
if target_count <= n_seeds:
print(f"[WARN] bucket '{bucket}': target ({target_count}) <= seeds ({n_seeds}), se usan solo los seeds originales")
return [vary_example(ex, rng, 0) for ex in seeds]
return [vary_example(ex, rng, 0, bucket) for ex in seeds]
result = []
variant_idx = 0
@@ -153,12 +539,188 @@ def build_bucket(bucket, seeds, target_count):
for i in seed_order:
if len(result) >= target_count:
break
result.append(vary_example(seeds[i], rng, variant_idx))
result.append(vary_example(seeds[i], rng, variant_idx, bucket))
variant_idx += 1
print(f"[INFO] bucket '{bucket}': {n_seeds} seeds -> {len(result)} ejemplos ({variant_idx} pasadas de variacion)")
return result
# ------------------------------------------------------------------------------------------
# Asserts sobre el corpus ensamblado
# ------------------------------------------------------------------------------------------
def trajectory_key(example):
"""Identidad de la trayectoria: messages + tools, sin meta."""
return json.dumps(
{"messages": example.get("messages"), "tools": example.get("tools")},
ensure_ascii=False,
sort_keys=True,
)
def iter_code_calls(example):
"""(tool_call_id, code) de cada llamada a execute_code, en orden de aparicion."""
for msg in example.get("messages", []) or []:
for tool_call in msg.get("tool_calls") or []:
args = tool_call.get("function", {}).get("arguments")
if isinstance(args, dict) and isinstance(args.get("code"), str):
yield tool_call.get("id"), args["code"]
def iter_code_payloads(example):
for _call_id, code in iter_code_calls(example):
yield code
def assert_penpot_not_collapsed(examples, n_seeds):
"""El bucket penpot esta exento de sustitucion de valores, asi que si la augmentacion no
produce trayectorias realmente distintas, 320 filas son 96 ejemplos repetidos. En Fase 2
esto era 99 filas -> 40 trayectorias unicas sobre 41 seeds."""
rows = [ex for ex in examples if ex.get("meta", {}).get("bucket") == "penpot"]
if not rows:
return
unique_trajectories = len({trajectory_key(ex) for ex in rows})
unique_code = len({code for ex in rows for code in iter_code_payloads(ex)})
minimum = PENPOT_UNIQUE_RATIO * n_seeds
print(
f"[INFO] penpot: {len(rows)} filas, {unique_trajectories} trayectorias unicas, "
f"{unique_code} payloads de code unicos, {n_seeds} seeds "
f"(minimo exigido: {minimum:.0f} trayectorias unicas)"
)
assert unique_trajectories >= minimum, (
f"[ASSERT FAIL] el bucket penpot colapso: {unique_trajectories} trayectorias unicas "
f"sobre {n_seeds} seeds (minimo {minimum:.0f} = {PENPOT_UNIQUE_RATIO} x seeds). La "
f"augmentacion no esta augmentando nada: agregar meta.paraphrases a los seeds o "
f"escribir seeds nuevos. Perturbar los payloads NO es una opcion (son codigo)."
)
# El bucket esta exento de sustitucion: los payloads de code del corpus generado tienen
# que ser exactamente los de los seeds, ni uno mas ni uno menos.
assert unique_code > 0, "[ASSERT FAIL] el bucket penpot no tiene ningun payload de code"
AUTHORED_PREFIX_LEN = 40
def authored_user_openings(by_bucket, replay_examples):
"""Los primeros caracteres de cada turno de usuario escrito a mano (seeds, parafrasis a
mano y replay). 'Che, ¿que significa X?' es castellano perfectamente valido cuando lo
escribio una persona; lo que esta prohibido es que lo produzca nuestra inyeccion de
prefijos. Se comparan solo las primeras letras para que una sustitucion de valores mas
adelante en la frase no rompa la comparacion."""
openings = set()
def add(text):
if isinstance(text, str) and text:
openings.add(text[:AUTHORED_PREFIX_LEN])
sources = list(replay_examples)
for examples in by_bucket.values():
sources.extend(examples)
for ex in sources:
for paraphrase in (ex.get("meta", {}) or {}).get("paraphrases") or []:
add(paraphrase)
for msg in ex.get("messages", []) or []:
if msg.get("role") == "user":
add(msg.get("content") or "")
return openings
def assert_no_broken_prefixes(examples, authored_openings):
"""El assert que mata los 68 prompts rotos: cero turnos de usuario con un prefijo de
parafraseo pegado delante de una pregunta ya formada."""
offenders = []
for ex in examples:
for msg in ex.get("messages", []) or []:
if msg.get("role") != "user":
continue
content = msg.get("content") or ""
if BROKEN_PREFIX_RE.search(content):
if content[:AUTHORED_PREFIX_LEN] in authored_openings:
continue # escrito a mano, no inyectado
offenders.append(content[:120])
assert not offenders, (
f"[ASSERT FAIL] {len(offenders)} turno(s) de usuario con un prefijo de parafraseo "
f"pegado delante de una pregunta ya formada. Primero: {offenders[0]!r}"
)
print("[OK] 0 turnos de usuario con prefijo de parafraseo agramatical")
def error_result_call_ids(example):
"""tool_call_id cuyo resultado NO es JSON, es decir, un string de error real."""
ids = set()
for msg in example.get("messages", []) or []:
if msg.get("role") != "tool":
continue
content = (msg.get("content") or "").strip()
if content and not content.startswith(("{", "[")):
ids.add(msg.get("tool_call_id"))
return ids
def is_corrected_mistake(example, call_id, rx):
"""Mismo criterio que 07_lint_penpot_code.py: un payload puede traer un patron prohibido
si y solo si (a) su tool result fue un error real y (b) una llamada POSTERIOR del mismo
ejemplo hace lo mismo SIN el patron. O sea: el error ocurrio de verdad y fue corregido.
Es el material de los grupos A1 y D (auto-correccion desde errores reales)."""
if call_id not in error_result_call_ids(example):
return False
seen = False
for cid, code in iter_code_calls(example):
if seen and not rx.search(code):
return True
if cid == call_id:
seen = True
return False
def assert_no_forbidden_patterns(examples, patterns):
"""Cero ocurrencias de la API prohibida de Penpot en el corpus ensamblado.
Alcance: los payloads de `code` (donde la API se INVOCA de verdad), de todos los buckets.
La prosa (reasoning_content, content, tool results) queda deliberadamente fuera: un seed
tiene que poder nombrar la forma equivocada para advertir contra ella, y la puerta que
mide la prosa es otra. La fuente de verdad de los patrones es 07_lint_penpot_code.py.
"""
problems = []
for idx, ex in enumerate(examples):
bucket = ex.get("meta", {}).get("bucket", "sin_bucket")
for call_id, code in iter_code_calls(ex):
for name, rx in patterns:
if not rx.search(code):
continue
if is_corrected_mistake(ex, call_id, rx):
continue
for m in rx.finditer(code):
line_start = code.rfind("\n", 0, m.start()) + 1
line_end = code.find("\n", m.end())
line = code[line_start: line_end if line_end != -1 else len(code)]
# La clave `layout` SI existe en la salida de shapeStructure(): esas
# lineas no son la propiedad inexistente del shape.
if "shapeStructure" in line:
continue
problems.append(
f"ejemplo #{idx} (bucket={bucket}): [{name}] ...{line.strip()[:120]}..."
)
assert not problems, (
f"[ASSERT FAIL] {len(problems)} ocurrencia(s) de API prohibida de Penpot en los "
f"payloads de code del corpus ensamblado (fuente de verdad de los patrones: "
f"{LINT_PATH.name}):\n - " + "\n - ".join(problems[:20])
)
print(f"[OK] 0 ocurrencias de los {len(patterns)} patrones prohibidos de Penpot en los payloads de code")
def guard_output(path):
if path.exists() and not ALLOW_OVERWRITE:
print(
f"[ABORT] {path} ya existe.\n"
f" data/train.jsonl y data/eval.jsonl son la procedencia exacta del modelo "
f"en produccion y el baseline de la puerta 1: regenerarlos los destruye, y ademas "
f"NO es idempotente (ver el docstring).\n"
f" Escribi a archivos nuevos con TRAIN_OUT=... EVAL_OUT=..., o corre con "
f"ALLOW_OVERWRITE=1 si de verdad queres pisarlo."
)
sys.exit(1)
def stratified_split(all_examples, rng):
by_bucket = {}
for ex in all_examples:
@@ -179,6 +741,7 @@ def stratified_split(all_examples, rng):
def write_jsonl(path, examples):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
for ex in examples:
f.write(json.dumps(ex, ensure_ascii=False))
@@ -187,6 +750,25 @@ def write_jsonl(path, examples):
def main():
print("=" * 78)
print("CONFIGURACION DE ESTA BUILD")
print("=" * 78)
for label, value in [
("SEEDS_DIR", SEEDS_DIR),
("REPLAY_FILE", REPLAY_PATH),
("TRAIN_OUT", TRAIN_PATH),
("EVAL_OUT", EVAL_PATH),
("ALLOW_OVERWRITE", ALLOW_OVERWRITE),
("BUCKET_TARGETS", BUCKET_TARGETS),
]:
print(f" {label:18} {value}")
print("=" * 78)
guard_output(TRAIN_PATH)
guard_output(EVAL_PATH)
forbidden_patterns = load_forbidden_patterns()
by_bucket = load_seeds()
assert_no_held_out_skill(by_bucket)
@@ -195,6 +777,8 @@ def main():
seeds = by_bucket.get(bucket, [])
all_examples.extend(build_bucket(bucket, seeds, target_count))
assert_penpot_not_collapsed(all_examples, len(by_bucket.get("penpot", [])))
replay_examples = load_jsonl(REPLAY_PATH)
for ex in replay_examples:
ex.setdefault("meta", {})["bucket"] = "replay"
@@ -203,6 +787,9 @@ def main():
print(f"[INFO] total combinado: {len(all_examples)} ejemplos")
assert_no_broken_prefixes(all_examples, authored_user_openings(by_bucket, replay_examples))
assert_no_forbidden_patterns(all_examples, forbidden_patterns)
split_rng = random.Random(SEED)
train, eval_ = stratified_split(all_examples, split_rng)
+119 -21
View File
@@ -1,5 +1,9 @@
"""Fase 2: valida data/train.jsonl y data/eval.jsonl contra el tokenizer/chat_template REAL
del modelo de produccion.
"""Fase 2 (revisado en Fase 6): valida los jsonl de dataset (por defecto data/train.jsonl y
data/eval.jsonl) contra el tokenizer/chat_template REAL del modelo de produccion.
Nota sobre emails de contacto en el copy de los seeds: `example.com` ya esta en
SAFE_EMAIL_DOMAINS, asi que una direccion como `reservas@example.com` en el texto de una
landing pasa el gate de secretos sin cambios. Cualquier otro dominio lo hace fallar.
Corre DENTRO del contenedor `qwen-lora-train` en spark (necesita `transformers` con el
chat_template.jinja real de Qwen3.6, no una version instalada localmente). Invocar via:
@@ -23,26 +27,64 @@ usa en inferencia/produccion y no se toca.
Por cada ejemplo: tokenizer.apply_chat_template(messages, tools=..., tokenize=True,
return_assistant_tokens_mask=True, return_dict=True) -- assert sin excepcion, mascara de
assistant no vacia. Filtra (no trunca) ejemplos que excedan MAX_TOKENS. Re-corre un gate de
secretos (regex explicitas, igual que 04_sanitize.py, sin depender de detect-secrets --
puede no estar instalado en este contenedor) sobre train.jsonl/eval.jsonl como ultima linea
de defensa. Reporta un resumen final por bucket.
assistant no vacia. Re-corre un gate de secretos (regex explicitas, igual que 04_sanitize.py,
sin depender de detect-secrets -- puede no estar instalado en este contenedor) sobre los
archivos validados como ultima linea de defensa. Reporta un resumen final por bucket con el
histograma de longitudes (p50/p90/p99/max).
PARAMETROS POR ENV
------------------
VALIDATE_FILES lista de jsonl separados por coma (def: data/train.jsonl,data/eval.jsonl)
MAX_TOKENS tope duro de longitud (def: 8192)
PRESERVE_THINKING '1' para conservar el thinking de turnos previos (def: off)
MODEL_PATH ruta del tokenizer (o argv[1])
Las rutas relativas se resuelven contra la raiz del repo. Ejemplo de Fase 6:
VALIDATE_FILES=data/train_lora2.jsonl,data/eval_lora2.jsonl \
MAX_TOKENS=3000 PRESERVE_THINKING=1 \
python3 scripts/06_validate_dataset.py
**El tope de longitud ABORTA, no filtra.** Hasta la Fase 6 este script imprimia `[FILTERED]`
e incrementaba un contador, pero la fila se quedaba en el archivo y `10_train.py` (que corre
sin `max_seq_length` y con batch 1) la entrenaba igual: era un falso sentido de seguridad que
podia reventar el presupuesto de memoria a mitad de corrida, horas adentro. Ahora un solo
ejemplo por encima de MAX_TOKENS termina en `sys.exit(1)`.
**PRESERVE_THINKING tiene que coincidir con el del training.** El template condiciona el
thinking de los turnos previos a `preserve_thinking`; si este script valida con el flag
apagado y el training entrena con el prendido, se esta midiendo otra cosa (otra longitud y
otra mascara).
"""
import json
import os
import re
import sys
from pathlib import Path
from transformers import AutoTokenizer
MODEL_PATH = sys.argv[1] if len(sys.argv) > 1 else "/workspace/ft-models/Qwen--Qwen3.6-35B-A3B"
REPO_ROOT = Path(__file__).resolve().parent.parent
def _resolve(raw):
"""Las rutas relativas cuelgan de la raiz del repo."""
p = Path(raw.strip())
return p if p.is_absolute() else REPO_ROOT / p
MODEL_PATH = (
sys.argv[1] if len(sys.argv) > 1
else os.environ.get("MODEL_PATH", "/workspace/ft-models/Qwen--Qwen3.6-35B-A3B")
)
TRAIN_CHAT_TEMPLATE_PATH = REPO_ROOT / "data" / "chat_template_train.jinja"
MAX_TOKENS = 8192
MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "8192"))
PRESERVE_THINKING = os.environ.get("PRESERVE_THINKING", "").lower() in ("1", "true", "yes")
DATASET_FILES = [
REPO_ROOT / "data" / "train.jsonl",
REPO_ROOT / "data" / "eval.jsonl",
_resolve(part)
for part in os.environ.get("VALIDATE_FILES", "data/train.jsonl,data/eval.jsonl").split(",")
if part.strip()
]
# Mismos patrones explicitos que 04_sanitize.py (subset sin dependencia de detect-secrets,
@@ -107,10 +149,23 @@ def strip_tools_for_check(tools):
return tools
def percentile(sorted_values, q):
"""Percentil por el metodo del vecino mas cercano (sin numpy: este contenedor puede no
tenerlo y no vale la pena una dependencia por esto)."""
if not sorted_values:
return 0
idx = min(len(sorted_values) - 1, max(0, int(round(q * (len(sorted_values) - 1)))))
return sorted_values[idx]
def validate_example(tokenizer, example):
messages = example["messages"]
tools = strip_tools_for_check(example.get("tools"))
template_kwargs = {}
if PRESERVE_THINKING:
template_kwargs["preserve_thinking"] = True
rendered = tokenizer.apply_chat_template(
messages,
tools=tools,
@@ -118,6 +173,7 @@ def validate_example(tokenizer, example):
return_assistant_tokens_mask=True,
return_dict=True,
add_generation_prompt=False,
**template_kwargs,
)
input_ids = rendered["input_ids"]
@@ -141,9 +197,13 @@ def main():
print(f"[INFO] reemplazando chat_template por la variante de training con masking: {TRAIN_CHAT_TEMPLATE_PATH}")
tokenizer.chat_template = TRAIN_CHAT_TEMPLATE_PATH.read_text(encoding="utf-8")
print(f"[INFO] archivos: {', '.join(str(p) for p in DATASET_FILES)}")
print(f"[INFO] MAX_TOKENS={MAX_TOKENS} (aborta, no filtra) PRESERVE_THINKING={PRESERVE_THINKING}")
summary = {}
lengths_by_bucket = {}
too_long = []
total_exceptions = 0
total_filtered_length = 0
total_ok = 0
for path in DATASET_FILES:
@@ -156,7 +216,7 @@ def main():
for lineno, example in examples:
bucket = example.get("meta", {}).get("bucket", "sin_bucket")
stats = summary.setdefault(bucket, {"ok": 0, "exceptions": 0, "filtered_length": 0})
stats = summary.setdefault(bucket, {"ok": 0, "exceptions": 0, "too_long": 0})
try:
n_tokens, mask_sum = validate_example(tokenizer, example)
@@ -166,34 +226,72 @@ def main():
print(f"[EXCEPTION] {path.name}:{lineno} (bucket={bucket}): {e}")
continue
lengths_by_bucket.setdefault(bucket, []).append(n_tokens)
if n_tokens > MAX_TOKENS:
stats["filtered_length"] += 1
total_filtered_length += 1
print(f"[FILTERED] {path.name}:{lineno} (bucket={bucket}): {n_tokens} tokens > {MAX_TOKENS}")
stats["too_long"] += 1
too_long.append((path.name, lineno, bucket, n_tokens))
print(f"[TOO LONG] {path.name}:{lineno} (bucket={bucket}): {n_tokens} tokens > {MAX_TOKENS}")
continue
stats["ok"] += 1
total_ok += 1
print("\n[INFO] re-corriendo gate de secretos sobre train.jsonl/eval.jsonl")
print("\n[INFO] re-corriendo gate de secretos sobre los archivos validados")
secret_problems = []
for path in DATASET_FILES:
secret_problems.extend(secrets_gate(path))
print("\n=== RESUMEN POR BUCKET ===")
for bucket, stats in sorted(summary.items()):
print(f" {bucket}: ok={stats['ok']} filtrados_por_longitud={stats['filtered_length']} excepciones={stats['exceptions']}")
print(f" {bucket}: ok={stats['ok']} sobre_max_tokens={stats['too_long']} excepciones={stats['exceptions']}")
print(f"\n=== TOTAL: ok={total_ok} filtrados_por_longitud={total_filtered_length} excepciones={total_exceptions} ===")
# Histograma de longitudes: es lo que permite decidir si MAX_TOKENS esta bien puesto ANTES
# de quemar una corrida de entrenamiento. Un p99 pegado al tope significa que el proximo
# seed largo aborta la build; un maximo muy por debajo significa que se puede bajar el tope
# (y con el, el pico de memoria).
print("\n=== HISTOGRAMA DE TOKENS POR BUCKET (p50 / p90 / p99 / max) ===")
all_lengths = []
for bucket, lengths in sorted(lengths_by_bucket.items()):
ordered = sorted(lengths)
all_lengths.extend(ordered)
print(
f" {bucket:24} n={len(ordered):5} p50={percentile(ordered, 0.50):6} "
f"p90={percentile(ordered, 0.90):6} p99={percentile(ordered, 0.99):6} "
f"max={ordered[-1]:6}"
)
if all_lengths:
ordered = sorted(all_lengths)
print(
f" {'TOTAL':24} n={len(ordered):5} p50={percentile(ordered, 0.50):6} "
f"p90={percentile(ordered, 0.90):6} p99={percentile(ordered, 0.99):6} "
f"max={ordered[-1]:6} (MAX_TOKENS={MAX_TOKENS})"
)
print(f"\n=== TOTAL: ok={total_ok} sobre_max_tokens={len(too_long)} excepciones={total_exceptions} ===")
if secret_problems:
print(f"\n[GATE FAIL] {len(secret_problems)} secretos sobrevivientes en train/eval:")
print(f"\n[GATE FAIL] {len(secret_problems)} secretos sobrevivientes:")
for problem in secret_problems:
print(f" - {problem}")
else:
print("\n[GATE OK] 0 secretos sobrevivientes en train.jsonl/eval.jsonl")
print("\n[GATE OK] 0 secretos sobrevivientes en los archivos validados")
if total_exceptions > 0 or secret_problems:
if too_long:
print(f"\n[GATE FAIL] {len(too_long)} ejemplo(s) superan MAX_TOKENS={MAX_TOKENS}:")
for fname, lineno, bucket, n_tokens in too_long:
print(f" - {fname}:{lineno} (bucket={bucket}): {n_tokens} tokens")
print(
" Estas filas NO se filtran solas: 10_train.py corre sin max_seq_length y con\n"
" batch 1, asi que las entrenaria enteras y podria reventar el presupuesto de\n"
" memoria a mitad de corrida. Hay que arreglar el dataset, no el validador.\n"
" Como arreglarlo: partir la trayectoria larga en DOS ejemplos, persistiendo los\n"
" ids que el segundo necesita en `storage` (penpot.local_storage) al final del\n"
" primero y leyendolos al principio del segundo. Ademas de bajar la longitud, es\n"
" mejor pedagogia: es exactamente lo que la descripcion de execute_code pide."
)
if total_exceptions > 0 or secret_problems or too_long:
sys.exit(1)
sys.exit(0)
+29
View File
@@ -73,6 +73,10 @@ N_TRAIN = 900
EVAL_FRACTION = 0.10
N_CALIBRATION = 256
# Minimo de prompts de usuario distintos en la porcion nueva, como multiplo del numero de seeds.
# 96 seeds x (1 original + 3 parafraseos) = 384 >= 240. Si baja de ahi es que faltan parafraseos.
MIN_PROMPT_RATIO = 2.5
# grupo de seed -> porcion de la mezcla
PORTION_OF_GROUP = {
"A1": "api_forma_correcta", "A2": "api_forma_correcta", "A3": "api_forma_correcta",
@@ -340,6 +344,31 @@ def main():
print("[OK] cero patrones de la API vieja en la mezcla final "
"(fuera de la porcion de recuperacion de error, donde son el material didactico)")
# Guarda anti-colapso, analoga a la de 05_build_dataset.py. Sin ella, la porcion nueva puede
# ser 420 ejemplos que son 96 trayectorias repetidas 4.4 veces cada una, y nada lo diria: el
# conteo de filas se ve perfecto. Es exactamente lo que le pasa a la v1, donde 99 filas de
# penpot colapsan a 40 trayectorias objetivo unicas.
nuevos = [ex for ex in mixed if not ex["meta"]["porcion"].startswith("replay")]
def first_user(ex):
for m in ex["messages"]:
if m.get("role") == "user":
return m.get("content", "")
return ""
n_prompts = len(set(first_user(ex) for ex in nuevos))
n_trayectorias = len(set(json.dumps(ex["messages"], ensure_ascii=False) for ex in nuevos))
min_prompts = round(MIN_PROMPT_RATIO * len(seeds))
print(f"\n[INFO] porcion nueva: {len(nuevos)} ejemplos, {n_trayectorias} trayectorias unicas, "
f"{n_prompts} prompts de usuario distintos (minimo {min_prompts})")
if n_prompts < min_prompts:
raise SystemExit(
f"[ABORT] solo {n_prompts} prompts de usuario distintos en la porcion nueva, minimo "
f"{min_prompts} ({MIN_PROMPT_RATIO} x {len(seeds)} seeds).\n"
f" La variacion sale UNICAMENTE de `meta.paraphrases` (2-3 reescrituras a mano "
f"del turno del usuario por seed).\n"
f" Seeds sin `meta.paraphrases`: "
f"{sum(1 for s in seeds if not s.get('meta', {}).get('paraphrases'))} de {len(seeds)}."
)
counts = Counter(ex["meta"]["porcion"] for ex in mixed)
print("\n=== COMPOSICION DE LA MEZCLA ===")
penpot_new = 0
+370 -109
View File
@@ -1,12 +1,37 @@
"""Fase 4 -- genera data/holdout_prompts.jsonl: ~200 prompts held-out para la Puerta 2
(validez de tool-calls), cubriendo los 5 MCPs, sin overlap con train.jsonl/eval.jsonl.
"""Fase 4 (rebalanceado en Fase 6) -- genera data/holdout_prompts.jsonl: ~200 prompts
held-out para la Puerta 2 (validez de tool-calls), cubriendo los 5 MCPs, sin overlap con
train.jsonl/eval.jsonl.
Cada linea: {"prompt": "...", "mcp": "penpot|gitea|github-personal|docmost|atlassian",
"tools": [...schema real del MCP...]}. Corre localmente, no requiere GPU.
"expect": "...", "tools": [...schema real del MCP...]}. Corre localmente, no requiere GPU.
Variacion deterministica (random.seed(43), semilla distinta de la de 05_build_dataset.py
para no reusar la misma secuencia) sobre plantillas por MCP -- nunca copia textual de un
ejemplo de train/eval (se verifica al final contra el texto normalizado de ambos archivos).
para no reusar la misma secuencia) sobre plantillas por MCP.
QUE CAMBIO EN LA FASE 6
-----------------------
1. **Rebalanceo a penpot=60 / 35 por cada uno de los otros cuatro MCPs.** La Fase 6 solo
agrega capacidad de diseno en Penpot: ahi es donde hace falta resolucion de medicion.
2. **PENPOT_DESIGN_TEMPLATES**: prompts de diseno real (landing, card, navbar, dashboard,
pantalla mobile, precios, galeria, formulario...), con dominios, medidas y estilos
variados. Son la materia prima del holdout de la puerta 5.
3. **Chequeo de shingles de 6-gramas** contra train.jsonl/eval.jsonl. Antes solo se
comparaba igualdad de string completo, que cualquier parafrasis esquiva: el holdout
podia estar contaminado sin que nada lo notara. Ahora un solapamiento de shingles por
encima de MAX_SHINGLE_OVERLAP hace fallar la build.
4. **Pools de valores disjuntos**: los dominios, colores, medidas, repos y nombres que usa
el holdout no aparecen en los seeds ni en train/eval, y se asierta.
5. **Campo `expect`** por prompt, para que las puertas puedan clasificar. En particular:
- El template de "exportar a 2x de resolucion" NO estaba roto: es una **sonda de
invencion de parametros** (`scale` no existe en el schema de `export_shape`). Se queda,
reetiquetado como `no-invented-args`, y ahora la puerta 2 efectivamente lo detecta
(ver el cambio de `validate_tool_call` en 32_gate2_toolcalls.py).
- El template de `createBoolean` tampoco estaba roto: `createBoolean(boolType, shapes)`
es API real y verificada (ver data/schemas/PENPOT_API_VERIFIED.md). El bug era testear
algo que ningun seed ensenaba; los seeds del grupo B2 de la Fase 6 lo ensenan.
Las funciones de shingles se exportan a proposito: scripts/35_build_penpot_design_holdout.py
las importa para usar EXACTAMENTE el mismo criterio de contaminacion.
"""
import json
import random
@@ -14,6 +39,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCHEMAS_DIR = REPO_ROOT / "data" / "schemas"
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_prompts.jsonl"
@@ -21,102 +47,55 @@ OUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
SEED = 43
TARGET_TOTAL = 200
# Fase 6: el grueso de la medicion se mueve a penpot, que es la unica capacidad que el
# LoRA #2 toca. 60 + 4*35 = 200, el mismo total que en las Fases 4-5.
MCP_TARGETS = {
"penpot": 40,
"gitea": 40,
"github-personal": 40,
"docmost": 40,
"atlassian": 40,
"penpot": 60,
"gitea": 35,
"github-personal": 35,
"docmost": 35,
"atlassian": 35,
}
PENPOT_TEMPLATES = [
"Crea un rectangulo de {w}x{h} en el board '{board}' con color {color}.",
"Necesito un texto que diga '{text}' dentro del board '{board}', alineado a la izquierda.",
"Cambia el fill del shape '{shape}' a {color} y verifica que se aplico.",
"Agrega un board nuevo llamado '{board}' de {w}x{h} px en la pagina actual.",
"Exporta el shape '{shape}' como PNG a 2x de resolucion.",
"Lista los shapes del board '{board}' y decime cuales son grupos.",
"Mueve el shape '{shape}' a la posicion x={w}, y={h} dentro de '{board}'.",
"Crea un boolean de union entre '{shape}' y otro rectangulo superpuesto.",
]
# --- deteccion de contaminacion ----------------------------------------------------
GITEA_TEMPLATES = [
"Crea una rama llamada '{branch}' en el repo '{repo}' desde main.",
"Abri un issue en '{repo}' titulado '{text}' con la label 'bug'.",
"Lista los pull requests abiertos del repo '{repo}'.",
"Crea un release '{branch}' en '{repo}' con las notas '{text}'.",
"Busca commits recientes en '{repo}' que mencionen '{text}'.",
"Agrega un comentario '{text}' al issue numero {num} de '{repo}'.",
"Revisa el estado de los actions/workflows del repo '{repo}'.",
"Mergea (solo si el usuario lo pide explicitamente) el PR numero {num} de '{repo}'.",
]
GITHUB_TEMPLATES = [
"Crea un pull request en '{repo}' desde la rama '{branch}' hacia main, titulo '{text}'.",
"Lista los issues abiertos de '{repo}' con label 'enhancement'.",
"Agrega un comentario '{text}' al PR numero {num} de '{repo}'.",
"Busca en '{repo}' el codigo que define la funcion '{text}'.",
"Crea un issue en '{repo}' titulado '{text}' asignado a mi usuario.",
"Revisa los commits recientes de la rama '{branch}' en '{repo}'.",
"Lista los releases publicados de '{repo}'.",
"Solicita una review de Copilot para el PR numero {num} de '{repo}'.",
]
DOCMOST_TEMPLATES = [
"Crea una pagina llamada '{text}' en el space '{repo}' con una tabla de {num} columnas.",
"Busca en Docmost paginas que mencionen '{text}'.",
"Actualiza la pagina '{text}' agregando una fila mas a la tabla existente.",
"Comenta '{text}' en la pagina con id conocido del space '{repo}'.",
"Lista las paginas del space '{repo}' ordenadas por actualizacion reciente.",
"Crea una subpagina '{text}' bajo la pagina principal del space '{repo}'.",
"Revisa si hay comentarios nuevos en el space '{repo}' desde ayer.",
"Mueve la pagina '{text}' a otro parent dentro del space '{repo}'.",
]
ATLASSIAN_TEMPLATES = [
"Crea un issue de Jira en el proyecto '{repo}' titulado '{text}', tipo Bug.",
"Busca issues de Jira asignados a mi usuario con JQL en el proyecto '{repo}'.",
"Agrega un comentario '{text}' al issue {repo}-{num} de Jira.",
"Transiciona el issue {repo}-{num} a 'In Progress'.",
"Crea una pagina de Confluence '{text}' en el espacio '{repo}'.",
"Busca en Confluence paginas del espacio '{repo}' que mencionen '{text}'.",
"Agrega un worklog de 1h al issue {repo}-{num} con el comentario '{text}'.",
"Lista los tipos de issue disponibles en el proyecto '{repo}' de Jira.",
]
MCP_TEMPLATES = {
"penpot": PENPOT_TEMPLATES,
"gitea": GITEA_TEMPLATES,
"github-personal": GITHUB_TEMPLATES,
"docmost": DOCMOST_TEMPLATES,
"atlassian": ATLASSIAN_TEMPLATES,
}
WORDS = [
"dashboard", "login", "checkout", "onboarding", "reporte semanal", "bugfix urgente",
"modal de confirmacion", "flujo de pago", "endpoint de usuarios", "cache de sesion",
"migracion de datos", "notificaciones push", "tema oscuro", "landing page",
"formulario de contacto", "panel de admin", "integracion con Stripe", "tabla de precios",
"footer del sitio", "header responsive",
]
REPOS = ["backend-core", "frontend-app", "infra-tools", "data-pipeline", "mobile-client"]
BRANCHES = ["feature/nueva-vista", "fix/timeout-api", "chore/deps", "hotfix/prod", "feature/dark-mode"]
BOARDS = ["Dashboard Principal", "Mobile Screens", "Design System", "Landing v2", "Checkout Flow"]
SHAPES = ["boton-cta", "card-producto", "icono-menu", "titulo-principal", "footer-logo"]
COLORS = ["#1a73e8", "#e8710a", "#188038", "#d93025", "#9334e6", "#12b5cb"]
def load_tools(mcp_name):
return json.loads((SCHEMAS_DIR / f"{mcp_name}.json").read_text(encoding="utf-8"))
SHINGLE_N = 6
# Fraccion maxima de shingles de 6-gramas de un prompt held-out que puede aparecer en
# train/eval. Un prompt genuinamente nuevo comparte a lo sumo giros sueltos ("en el repo
# de", "de la pagina con"); una parafrasis de un ejemplo de training comparte la mayoria.
MAX_SHINGLE_OVERLAP = 0.34
def normalize(text):
return " ".join(text.lower().split())
def load_existing_texts():
texts = set()
for path in (TRAIN_PATH, EVAL_PATH):
def shingles(text, n=SHINGLE_N):
"""Conjunto de n-gramas de palabras del texto normalizado.
Si el texto tiene menos de n palabras se devuelve el texto entero como unico shingle:
para prompts muy cortos la igualdad exacta es el unico criterio sensato.
"""
tokens = normalize(text).split()
if not tokens:
return set()
if len(tokens) < n:
return {" ".join(tokens)}
return {" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1)}
def shingle_overlap(text, reference_shingles, n=SHINGLE_N):
"""Fraccion de shingles del texto que ya existen en el corpus de referencia."""
own = shingles(text, n)
if not own:
return 0.0
return len(own & reference_shingles) / len(own)
def iter_user_texts(paths):
for path in paths:
if not Path(path).exists():
continue
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
@@ -125,18 +104,265 @@ def load_existing_texts():
example = json.loads(line)
for msg in example.get("messages", []):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
texts.add(normalize(msg["content"]))
return texts
yield msg["content"]
def build_prompts(rng, mcp_name, count):
templates = MCP_TEMPLATES[mcp_name]
prompts = []
for i in range(count):
template = templates[i % len(templates)]
text = template.format(
w=rng.choice([80, 120, 200, 320, 480]),
h=rng.choice([40, 60, 100, 240, 360]),
def load_reference_corpus(paths=(TRAIN_PATH, EVAL_PATH)):
"""Devuelve (textos_normalizados, shingles) de los turnos de usuario de train/eval."""
texts = set()
ref_shingles = set()
for content in iter_user_texts(paths):
texts.add(normalize(content))
ref_shingles |= shingles(content)
return texts, ref_shingles
def assert_no_contamination(prompt, existing_texts, reference_shingles, origen=""):
"""Hard-fail si el prompt es copia literal o parafrasis cercana de train/eval."""
if normalize(prompt) in existing_texts:
raise AssertionError(f"prompt held-out{origen} colisiona literal con train/eval: {prompt!r}")
overlap = shingle_overlap(prompt, reference_shingles)
if overlap > MAX_SHINGLE_OVERLAP:
raise AssertionError(
f"prompt held-out{origen} solapa {overlap:.0%} de sus shingles de {SHINGLE_N}-gramas "
f"con train/eval (maximo {MAX_SHINGLE_OVERLAP:.0%}): {prompt!r}"
)
return overlap
# --- pools de valores ---------------------------------------------------------------
#
# Pools que usaban los seeds y el holdout anterior a la Fase 6. Se conservan SOLO para
# asertar que los pools nuevos no reusan ninguno de sus valores: compartir el vocabulario
# de valores con el corpus de training es una fuga silenciosa que ninguna comparacion de
# strings completos detecta.
POOLS_PRE_FASE6 = {
"words": [
"dashboard", "login", "checkout", "onboarding", "reporte semanal", "bugfix urgente",
"modal de confirmacion", "flujo de pago", "endpoint de usuarios", "cache de sesion",
"migracion de datos", "notificaciones push", "tema oscuro", "landing page",
"formulario de contacto", "panel de admin", "integracion con Stripe", "tabla de precios",
"footer del sitio", "header responsive",
],
"repos": ["backend-core", "frontend-app", "infra-tools", "data-pipeline", "mobile-client"],
"branches": [
"feature/nueva-vista", "fix/timeout-api", "chore/deps", "hotfix/prod", "feature/dark-mode",
],
"boards": [
"Dashboard Principal", "Mobile Screens", "Design System", "Landing v2", "Checkout Flow",
],
"shapes": ["boton-cta", "card-producto", "icono-menu", "titulo-principal", "footer-logo"],
"colors": ["#1a73e8", "#e8710a", "#188038", "#d93025", "#9334e6", "#12b5cb"],
"widths": [80, 120, 200, 320, 480],
"heights": [40, 60, 100, 240, 360],
}
# Pools del holdout. Disjuntos de los de arriba y ausentes del corpus de seeds/train/eval.
WORDS = [
"buscador con filtros", "historial de pedidos", "widget de clima", "lector de codigos QR",
"linea de tiempo", "mapa de sucursales", "editor de firmas", "panel de alertas",
"carrusel de novedades", "calendario de turnos", "resumen de consumo", "selector de idioma",
]
REPOS = [
"servicio-facturacion", "gateway-eventos", "cliente-escritorio", "motor-reportes",
"sincronizador-offline", "portal-proveedores", "indexador-catalogo", "bus-notificaciones",
"consola-soporte",
]
BRANCHES = [
"feature/panel-metricas", "fix/reintentos-webhook", "chore/actualizar-lockfile",
"hotfix/zona-horaria", "feature/modo-lectura",
]
BOARDS = [
"Catalogo Impreso", "Pantallas Kiosco", "Sistema Tipografico", "Portada Editorial",
"Flujo de Reserva",
]
SHAPES = ["chip-etiqueta", "banner-promo", "avatar-usuario", "separador-seccion", "badge-descuento"]
COLORS = ["#0e5f8a", "#c2410c", "#5b21b6", "#9d174d", "#166534", "#7e22ce", "#0d9488", "#831843"]
WIDTHS = [640, 720, 880, 1120, 1360]
HEIGHTS = [72, 96, 180, 420, 640]
# Dominios de diseno: ninguno aparece en los seeds (los seeds usan pizzeria, SaaS, clinica,
# inmobiliaria, e-commerce...). La variedad de dominio es lo que impide que el modelo
# memorice una composicion y la repita.
DOMINIOS = [
"una tostaduria de cafe de especialidad",
"un estudio de tatuajes",
"una veterinaria felina",
"una escuela de buceo",
"una libreria de comics",
"un vivero de plantas de interior",
"una fabrica de bicicletas de bambu",
"un observatorio astronomico",
"una heladeria artesanal",
"un taller de ceramica",
"un club de ajedrez",
"una panaderia sin gluten",
"un refugio de montana",
"una tienda de instrumentos de viento",
"una clinica de fisioterapia deportiva",
"un festival de cine documental",
]
ESTILOS = [
"minimalista y luminoso",
"editorial con mucho contraste",
"retro setentoso",
"brutalista",
"pastel y suave",
"oscuro con acentos neon",
]
# --- plantillas ---------------------------------------------------------------------
#
# Cada plantilla es (texto, expect). Valores de `expect`:
# tool-call-valido : caso normal, se espera una tool_call bien formada.
# no-invented-args : sonda -- el pedido tienta a inventar un argumento que el schema no
# declara (scale, filePath). La puerta 2 lo cuenta como
# unknown_argument.
# api-verificada : el pedido requiere un miembro de API real pero poco frecuente
# (createBoolean); mide si el modelo lo conoce en vez de improvisar.
# diseno-penpot : pedido de diseno real; alimenta ademas la puerta 5.
PENPOT_TEMPLATES = [
("Crea un rectangulo de {w}x{h} en el board '{board}' con color {color}.", "tool-call-valido"),
("Necesito un texto que diga '{text}' dentro del board '{board}', alineado a la izquierda.", "tool-call-valido"),
("Cambia el fill del shape '{shape}' a {color} y verifica que se aplico.", "tool-call-valido"),
("Agrega un board nuevo llamado '{board}' de {w}x{h} px en la pagina actual.", "tool-call-valido"),
("Exporta el shape '{shape}' como PNG a 2x de resolucion.", "no-invented-args"),
("Exporta el shape '{shape}' como PNG y dejalo guardado en /tmp/{shape}.png.", "no-invented-args"),
("Lista los shapes del board '{board}' y decime cuales son grupos.", "tool-call-valido"),
("Mueve el shape '{shape}' a la posicion x={w}, y={h} dentro de '{board}'.", "tool-call-valido"),
("Crea un boolean de union entre '{shape}' y otro rectangulo superpuesto.", "api-verificada"),
("Recorta el shape '{shape}' contra un circulo con una operacion booleana de interseccion.", "api-verificada"),
]
PENPOT_DESIGN_TEMPLATES = [
("Armame una landing completa de {dominio}, {w} px de ancho, con hero, tres beneficios y footer. Estilo {estilo}, color principal {color}.", "diseno-penpot"),
("Necesito una card de producto para {dominio}: foto arriba, titulo, precio y boton, de {w}x{h} y con esquinas redondeadas.", "diseno-penpot"),
("Hazme un navbar de {w}x{h} para el sitio de {dominio}, con logo a la izquierda y cuatro enlaces a la derecha sobre fondo {color}.", "diseno-penpot"),
("Hazme un dashboard para {dominio} con barra lateral, cuatro tarjetas de metricas y una tabla debajo, {w} de ancho.", "diseno-penpot"),
("Pantalla mobile de {w}x{h} para la app de {dominio}: onboarding de tres pasos con ilustracion y boton primario {color}.", "diseno-penpot"),
("Seccion de precios con tres planes para {dominio}, el del medio destacado, paleta {estilo}.", "diseno-penpot"),
("Galeria de seis fotos de {dominio} en grid de tres columnas, con titulo y bajada arriba.", "diseno-penpot"),
("Formulario de contacto de {dominio}: cuatro campos, un area de texto y boton de enviar, {w} de ancho.", "diseno-penpot"),
("Hero de {w}x{h} para {dominio} con foto de fondo, un velo oscuro encima y titular en blanco.", "diseno-penpot"),
("Hazme el encabezado y el pie de {dominio} con la misma paleta {estilo} y acento {color}.", "diseno-penpot"),
("Pantalla de pago de {dominio}: resumen de compra a la derecha, datos de tarjeta a la izquierda, {w} de ancho.", "diseno-penpot"),
("Armame un cuadro comparativo de {num} columnas para {dominio}, encabezado en {color} y filas alternadas.", "diseno-penpot"),
("Necesito la escala tipografica de {dominio} en un board de {w}x{h}: seis tamanos del display al pie de foto.", "diseno-penpot"),
("Defini la paleta de {dominio} en la biblioteca del archivo y aplicala a un boton primario y a uno secundario.", "diseno-penpot"),
("Pantalla de perfil de {dominio} de {w}x{h}, con avatar, datos de la cuenta y actividad reciente.", "diseno-penpot"),
("El board '{board}' quedo lleno de cajas grises sin nada de texto; convertilo en un diseno real de {dominio} usando {color}.", "diseno-penpot"),
("Tres testimonios de clientes de {dominio} en tarjetas con foto, cita y nombre, {w} de ancho.", "diseno-penpot"),
("Un cotizador simple para {dominio} de {w}x{h}: dos selectores, un deslizador y el resultado en grande.", "diseno-penpot"),
("Pantalla de resultados de busqueda de {dominio}: filtros a la izquierda y ocho resultados en dos columnas, {w} de ancho.", "diseno-penpot"),
("Banner promocional de {w}x{h} para {dominio}, estilo {estilo}, con un descuento bien visible y un boton.", "diseno-penpot"),
]
GITEA_TEMPLATES = [
("Crea una rama llamada '{branch}' en el repo '{repo}' desde main.", "tool-call-valido"),
("Abri un issue en '{repo}' titulado '{text}' con la label 'bug'.", "tool-call-valido"),
("Decime que pull requests siguen abiertos hoy en '{repo}'.", "tool-call-valido"),
("Crea un release '{branch}' en '{repo}' con las notas '{text}'.", "tool-call-valido"),
("Busca commits recientes en '{repo}' que mencionen '{text}'.", "tool-call-valido"),
("Agrega un comentario '{text}' al issue numero {num} de '{repo}'.", "tool-call-valido"),
("Revisa el estado de los actions/workflows del repo '{repo}'.", "tool-call-valido"),
("Mergea (solo si el usuario lo pide explicitamente) el PR numero {num} de '{repo}'.", "tool-call-valido"),
]
GITHUB_TEMPLATES = [
("Crea un pull request en '{repo}' desde la rama '{branch}' hacia main, titulo '{text}'.", "tool-call-valido"),
("Lista los issues abiertos de '{repo}' con label 'enhancement'.", "tool-call-valido"),
("Agrega un comentario '{text}' al PR numero {num} de '{repo}'.", "tool-call-valido"),
("Busca en '{repo}' el codigo que define la funcion '{text}'.", "tool-call-valido"),
("Crea un issue en '{repo}' titulado '{text}' asignado a mi usuario.", "tool-call-valido"),
("Revisa los commits recientes de la rama '{branch}' en '{repo}'.", "tool-call-valido"),
("Lista los releases publicados de '{repo}'.", "tool-call-valido"),
("Solicita una review de Copilot para el PR numero {num} de '{repo}'.", "tool-call-valido"),
]
DOCMOST_TEMPLATES = [
("Crea una pagina llamada '{text}' en el space '{repo}' con una tabla de {num} columnas.", "tool-call-valido"),
("Busca en Docmost paginas que mencionen '{text}'.", "tool-call-valido"),
("Actualiza la pagina '{text}' agregando una fila mas a la tabla existente.", "tool-call-valido"),
("Comenta '{text}' en la pagina con id conocido del space '{repo}'.", "tool-call-valido"),
("Lista las paginas del space '{repo}' ordenadas por actualizacion reciente.", "tool-call-valido"),
("Crea una subpagina '{text}' bajo la pagina principal del space '{repo}'.", "tool-call-valido"),
("Fijate si quedo algun comentario sin responder en '{repo}' de esta semana.", "tool-call-valido"),
("Mueve la pagina '{text}' a otro parent dentro del space '{repo}'.", "tool-call-valido"),
]
ATLASSIAN_TEMPLATES = [
("Crea un issue de Jira en el proyecto '{repo}' titulado '{text}', tipo Bug.", "tool-call-valido"),
("Busca issues de Jira asignados a mi usuario con JQL en el proyecto '{repo}'.", "tool-call-valido"),
("Agrega un comentario '{text}' al issue {repo}-{num} de Jira.", "tool-call-valido"),
("Transiciona el issue {repo}-{num} a 'In Progress'.", "tool-call-valido"),
("Crea una pagina de Confluence '{text}' en el espacio '{repo}'.", "tool-call-valido"),
("Busca en Confluence paginas del espacio '{repo}' que mencionen '{text}'.", "tool-call-valido"),
("Agrega un worklog de 1h al issue {repo}-{num} con el comentario '{text}'.", "tool-call-valido"),
("Lista los tipos de issue disponibles en el proyecto '{repo}' de Jira.", "tool-call-valido"),
]
MCP_TEMPLATES = {
"penpot": PENPOT_TEMPLATES + PENPOT_DESIGN_TEMPLATES,
"gitea": GITEA_TEMPLATES,
"github-personal": GITHUB_TEMPLATES,
"docmost": DOCMOST_TEMPLATES,
"atlassian": ATLASSIAN_TEMPLATES,
}
def load_tools(mcp_name):
return json.loads((SCHEMAS_DIR / f"{mcp_name}.json").read_text(encoding="utf-8"))
def load_corpus_text():
"""Texto normalizado de todos los seeds + train + eval, para el chequeo de pools."""
chunks = []
for path in sorted(SEEDS_DIR.glob("*.jsonl")) + sorted((SEEDS_DIR / "_parts").glob("*.jsonl")):
chunks.append(path.read_text(encoding="utf-8"))
for path in (TRAIN_PATH, EVAL_PATH):
if path.exists():
chunks.append(path.read_text(encoding="utf-8"))
return normalize(" ".join(chunks))
def assert_pools_disjuntos(corpus_text):
"""Los pools del holdout no comparten valores con los del corpus de training.
Dos chequeos complementarios: (a) ningun valor de texto del holdout aparece en el
corpus de seeds/train/eval; (b) los pools numericos son disjuntos de los que usaba el
holdout anterior a la Fase 6, que si se solapaban con los seeds.
"""
pools_texto = {
"words": WORDS, "repos": REPOS, "branches": BRANCHES, "boards": BOARDS,
"shapes": SHAPES, "colors": COLORS, "dominios": DOMINIOS, "estilos": ESTILOS,
}
for nombre, valores in pools_texto.items():
previos = set(POOLS_PRE_FASE6.get(nombre, []))
repetidos = sorted(set(valores) & previos)
if repetidos:
raise AssertionError(f"pool '{nombre}' reusa valores del corpus viejo: {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: "
"el holdout tiene que usar vocabulario propio"
)
for nombre, valores in (("widths", WIDTHS), ("heights", HEIGHTS)):
repetidos = sorted(set(valores) & set(POOLS_PRE_FASE6[nombre]))
if repetidos:
raise AssertionError(f"pool numerico '{nombre}' reusa valores viejos: {repetidos}")
print("[INFO] pools de valores disjuntos verificados "
f"({sum(len(v) for v in pools_texto.values())} valores de texto, "
f"{len(WIDTHS) + len(HEIGHTS)} numericos)")
def render(rng, template):
return template.format(
w=rng.choice(WIDTHS),
h=rng.choice(HEIGHTS),
board=rng.choice(BOARDS),
color=rng.choice(COLORS),
shape=f"{rng.choice(SHAPES)}-{rng.randint(1, 99)}",
@@ -144,24 +370,53 @@ def build_prompts(rng, mcp_name, count):
repo=rng.choice(REPOS),
branch=f"{rng.choice(BRANCHES)}-{rng.randint(1, 99)}",
num=rng.randint(1, 500),
dominio=rng.choice(DOMINIOS),
estilo=rng.choice(ESTILOS),
)
prompts.append(text)
def build_prompts(rng, mcp_name, count, seen):
"""Un prompt por plantilla, ciclando, sin repetir texto ya generado.
Las plantillas con pocos placeholders (p.ej. solo {repo}) colisionaban al reusarse:
el holdout anterior tenia 15 prompts duplicados sobre 200, o sea 15 mediciones
desperdiciadas. Se reintenta con otros valores hasta obtener un texto nuevo.
"""
templates = MCP_TEMPLATES[mcp_name]
prompts = []
for i in range(count):
template, expect = templates[i % len(templates)]
for _ in range(200):
text = render(rng, template)
if text not in seen:
break
else:
raise AssertionError(
f"la plantilla {template!r} no tiene suficientes combinaciones de valores "
"para generar un prompt nuevo: agregar valores a los pools"
)
seen.add(text)
prompts.append((text, expect))
return prompts
def main():
rng = random.Random(SEED)
existing_texts = load_existing_texts()
print(f"[INFO] {len(existing_texts)} prompts de usuario existentes en train.jsonl/eval.jsonl")
existing_texts, reference_shingles = load_reference_corpus()
print(f"[INFO] {len(existing_texts)} prompts de usuario existentes en train.jsonl/eval.jsonl, "
f"{len(reference_shingles)} shingles de {SHINGLE_N}-gramas de referencia")
assert_pools_disjuntos(load_corpus_text())
examples = []
max_overlap = 0.0
seen = set()
for mcp_name, count in MCP_TARGETS.items():
tools = load_tools(mcp_name)
prompts = build_prompts(rng, mcp_name, count)
for prompt in prompts:
if normalize(prompt) in existing_texts:
raise AssertionError(f"prompt held-out colisiona con train/eval: {prompt!r}")
examples.append({"prompt": prompt, "mcp": mcp_name, "tools": tools})
for prompt, expect in build_prompts(rng, mcp_name, count, seen):
overlap = assert_no_contamination(prompt, existing_texts, reference_shingles)
max_overlap = max(max_overlap, overlap)
examples.append({"prompt": prompt, "mcp": mcp_name, "expect": expect, "tools": tools})
rng.shuffle(examples)
@@ -173,9 +428,15 @@ def main():
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
print(f"[INFO] {len(examples)} prompts held-out escritos en {OUT_PATH}")
print(f"[INFO] solapamiento maximo de shingles contra train/eval: {max_overlap:.0%} "
f"(umbral {MAX_SHINGLE_OVERLAP:.0%})")
for mcp_name in MCP_TARGETS:
n = sum(1 for ex in examples if ex["mcp"] == mcp_name)
print(f" {mcp_name}: {n}")
print("[INFO] por expect:")
for expect in sorted({ex["expect"] for ex in examples}):
n = sum(1 for ex in examples if ex["expect"] == expect)
print(f" {expect}: {n}")
if __name__ == "__main__":
+79 -9
View File
@@ -12,13 +12,27 @@ configurado en docker-compose.eval.yml) lo hace vLLM en el servidor -- este scri
valida la RESPUESTA ya parseada (nunca re-implementa el parser con una regex propia):
- Si el modelo decide llamar una tool: valida que el nombre exista en el schema del MCP,
que los argumentos parseen como JSON valido, y que las propiedades "required" del
schema esten presentes.
que los argumentos parseen como JSON valido, que las propiedades "required" del
schema esten presentes, y que NINGUNA clave de argumento este ausente de
`parameters.properties` del schema.
- Si el modelo NO llama ninguna tool: se cuenta aparte (no es un error per se, algunos
prompts pueden resolverse sin tool-call, pero se reporta la tasa).
Reporta: % de prompts con tool_call sintacticamente valido (parseado sin excepcion por
vLLM, arguments=JSON valido, nombre y campos requeridos correctos) por MCP y global.
vLLM, arguments=JSON valido, nombre, campos requeridos y claves de argumento correctos)
por MCP y global.
FASE 6 -- la puerta premiaba la invencion de parametros
-------------------------------------------------------
Hasta la Fase 5 `validate_tool_call` solo chequeaba los `required` del schema: un argumento
INVENTADO que el schema no declara (`scale`, `filePath` en `export_shape`) **pasaba la
puerta**. Es decir, la puerta 2 premiaba activamente el comportamiento que la Fase 6 quiere
eliminar. Ahora toda clave ausente de `parameters.properties` es un fallo, contabilizado en
su propia categoria `unknown_argument` -- separada de `missing_required`, porque son errores
distintos y queremos poder medir la invencion por si sola.
El formato del JSON de resultados se mantiene compatible con los `gate2_results*.json` de las
Fases 4-5: las claves viejas siguen ahi con el mismo significado, las nuevas son aditivas.
"""
import argparse
import json
@@ -75,25 +89,47 @@ def to_openai_tools(tools):
return openai_tools
# Categorias de fallo, contabilizadas por separado. `unknown_argument` es la que mide
# invencion de parametros y por eso no se mezcla con `missing_required`.
FAILURE_KINDS = ("invalid_json", "unknown_tool", "missing_required", "unknown_argument")
def validate_tool_call(tool_call, tools):
"""Devuelve (ok, mensaje_de_error, categoria_de_fallo).
La categoria es None cuando la llamada es valida.
"""
name = tool_call["function"]["name"]
raw_args = tool_call["function"]["arguments"]
try:
args = json.loads(raw_args)
except json.JSONDecodeError as e:
return False, f"arguments no es JSON valido: {e}"
return False, f"arguments no es JSON valido: {e}", "invalid_json"
tool_def = tool_by_name(tools, name)
if tool_def is None:
return False, f"tool_call a nombre inexistente en el schema del MCP: {name}"
return False, f"tool_call a nombre inexistente en el schema del MCP: {name}", "unknown_tool"
schema = tool_def.get("inputSchema") or tool_def.get("parameters") or {}
required = schema.get("required", [])
missing = [r for r in required if r not in args]
if missing:
return False, f"faltan campos requeridos {missing} en la llamada a {name}"
return False, f"faltan campos requeridos {missing} en la llamada a {name}", "missing_required"
return True, None
# Invencion de parametros: cualquier clave que el schema no declare. Solo se puede
# juzgar si el schema declara `properties`; si no las declara (schema abierto), no hay
# con que comparar y no se penaliza.
properties = schema.get("properties")
if isinstance(properties, dict) and properties and isinstance(args, dict):
unknown = sorted(k for k in args if k not in properties)
if unknown:
return (
False,
f"argumentos inventados {unknown} ausentes de properties del schema de {name}",
"unknown_argument",
)
return True, None, None
def call_vllm(prompt, tools, timeout=240):
@@ -121,7 +157,15 @@ def main():
print(f"[INFO] {len(examples)} prompts held-out, endpoint={BASE_URL}")
results = []
stats = defaultdict(lambda: {"total": 0, "valid_tool_call": 0, "no_tool_call": 0, "invalid": 0})
# Las cuatro claves originales se mantienen tal cual para poder comparar contra los
# gate2_results*.json de las Fases 4-5; las de FAILURE_KINDS son aditivas.
def new_stats():
base = {"total": 0, "valid_tool_call": 0, "no_tool_call": 0, "invalid": 0}
base.update({kind: 0 for kind in FAILURE_KINDS})
base["request_error"] = 0
return base
stats = defaultdict(new_stats)
t0 = time.time()
for i, ex in enumerate(examples):
@@ -134,6 +178,8 @@ def main():
results.append({"mcp": mcp, "prompt": ex["prompt"], "error": str(e)})
stats[mcp]["invalid"] += 1
stats["__global__"]["invalid"] += 1
stats[mcp]["request_error"] += 1
stats["__global__"]["request_error"] += 1
continue
message = response["choices"][0]["message"]
@@ -149,6 +195,7 @@ def main():
results.append({
"mcp": mcp,
"prompt": ex["prompt"],
"expect": ex.get("expect"),
"tool_calls": None,
"valid": None,
"content": content,
@@ -159,11 +206,14 @@ def main():
all_valid = True
errors = []
error_kinds = []
for tc in tool_calls:
ok, err = validate_tool_call(tc, ex["tools"])
ok, err, kind = validate_tool_call(tc, ex["tools"])
if not ok:
all_valid = False
errors.append(err)
if kind not in error_kinds:
error_kinds.append(kind)
if all_valid:
stats[mcp]["valid_tool_call"] += 1
@@ -171,13 +221,20 @@ def main():
else:
stats[mcp]["invalid"] += 1
stats["__global__"]["invalid"] += 1
# Un prompt puede acumular mas de una categoria si emitio varias tool_calls;
# se cuenta una vez por categoria distinta, nunca dos veces la misma.
for kind in error_kinds:
stats[mcp][kind] += 1
stats["__global__"][kind] += 1
results.append({
"mcp": mcp,
"prompt": ex["prompt"],
"expect": ex.get("expect"),
"tool_calls": [tc["function"]["name"] for tc in tool_calls],
"valid": all_valid,
"errors": errors,
"error_kinds": error_kinds,
"content": content,
"reasoning": reasoning,
"finish_reason": response["choices"][0].get("finish_reason"),
@@ -198,6 +255,19 @@ def main():
f"pct_valid={pct_valid:.1f}%"
)
print("\n--- desglose de fallos por categoria ---")
for mcp in sorted(stats):
s = stats[mcp]
detalle = " ".join(f"{kind}={s.get(kind, 0)}" for kind in FAILURE_KINDS)
print(f" {mcp:20s} {detalle} request_error={s.get('request_error', 0)}")
# La invencion de parametros se reporta aparte porque es la metrica que la Fase 6
# quiere llevar a cero: hasta la Fase 5 estos casos contaban como validos.
inventados = [r for r in results if "unknown_argument" in (r.get("error_kinds") or [])]
print(f"\n[INFO] prompts con argumentos inventados: {len(inventados)}")
for r in inventados[:10]:
print(f" - [{r['mcp']}] {r['prompt'][:90]} -> {r['errors']}")
with open(RESULTS_PATH, "w", encoding="utf-8") as f:
json.dump({"stats": stats, "results": results}, f, ensure_ascii=False, indent=2)
print(f"\n[INFO] resultados detallados en {RESULTS_PATH}")
+250
View File
@@ -17,10 +17,25 @@ training], aleleba-pr, web-ui-test, agent-orchestrator):
accion real); se verifica que la respuesta NO ejecuta ninguna tool_call de esa
skill/MCP.
3. **Contenido** (agregado en Fase 6): un prompt cuya respuesta correcta depende de una
convencion NO OBVIA de alguno de los otros MCPs o del rol del agente, verificada con un
criterio mecanico sobre el texto (regex/substring, nunca el juicio del propio modelo).
Reporta el checklist completo, comparando contra baseline de produccion cuando existe.
POR QUE LAS CHECKLISTS DE CONTENIDO (riesgo #12 del PLAN.md)
------------------------------------------------------------
La regresion mas probable de un LoRA #2 penpot-heavy no es la que miden las puertas: es la
erosion silenciosa de convenciones no obvias que solo viven en unos pocos seeds -- la fila
separadora `| --- |` de las tablas GFM de Docmost, el bug de staleness de `update_page`, el
formato de los mensajes de commit, "nunca mergear el PR", los `arguments` como dict JSON.
Ninguna puerta las miraba. Cada CONTENT_CHECK trae su criterio verificable propio y produce
filas con la misma forma que el resto, asi que el JSON de resultados sigue siendo compatible
con los gate3_results*.json de las Fases 4-5.
"""
import json
import os
import re
import time
from pathlib import Path
@@ -104,6 +119,209 @@ CHECKLISTS = [
]
# --- checklists de contenido (Fase 6) -----------------------------------------------
#
# Cada check recibe la respuesta completa (content + reasoning + tool_calls) y devuelve
# (paso, detalle). El detalle se guarda en el JSON para poder auditar a mano por que paso o
# fallo, igual que los `hits` de las checklists de adherencia.
def _texto(response):
return (response["content"] + "\n" + response["reasoning"]).lower()
def _tiene(texto, opciones):
return [o for o in opciones if o in texto]
def check_tabla_gfm(response):
"""Tabla GFM de Docmost: fila separadora `| --- |` Y cada fila en su propia linea.
Sin la separadora el editor de Docmost colapsa la tabla entera en un parrafo. Se acepta
tanto el markdown escrito en lineas fisicas como el `\\n` escapado dentro de un string
JSON de tool call, que es como viaja en la practica.
"""
texto = response["content"] + "\n" + response["reasoning"]
separadora = re.search(r"\|\s*:?-{3,}:?\s*\|", texto) is not None
filas_fisicas = len(re.findall(r"(?m)^\s*\|.*\|\s*$", texto))
filas_escapadas = len(re.findall(r"\\n\s*\|", texto))
filas = max(filas_fisicas, filas_escapadas + 1)
detalle = [f"separadora={separadora}", f"filas={filas}"]
return (separadora and filas >= 3), detalle
def check_staleness_update_page(response):
"""Bug de staleness: reenviar el payload IDENTICO, no cambiar de formato."""
texto = _texto(response)
reenvia = _tiene(texto, ["reenv", "volver a enviar", "volves a enviar", "reintent", "de nuevo"])
identico = _tiene(texto, ["identico", "identica", "mismo payload", "mismo contenido", "el mismo"])
cambia_formato = _tiene(texto, ["cambiar el formato", "cambiar de formato", "otro formato", "usar html"])
detalle = [f"reenvia={reenvia}", f"identico={identico}", f"cambia_formato={cambia_formato}"]
return (bool(reenvia) and bool(identico) and not cambia_formato), detalle
def check_docmost_busca_pageid(response):
"""Sin pageId hay que buscar la pagina primero, no inventar un id."""
texto = _texto(response)
busca = _tiene(texto, ["search", "list_pages", "buscar la pagina", "buscar la pagina primero",
"listar las paginas", "busco la pagina"])
return bool(busca), busca
def check_commit_sin_atribucion(response):
"""Regla absoluta del repo: ninguna atribucion a una IA en el mensaje de commit."""
texto = _texto(response)
prohibido = _tiene(texto, ["co-authored-by", "claude", "generated with", "anthropic", "🤖"])
return (not prohibido), [f"atribuciones={prohibido}"]
def check_commit_formato(response):
"""Mensaje de commit con prefijo convencional (`feat:`, `fix:`, `chore:`...)."""
texto = _texto(response)
match = re.search(r"\b(feat|fix|chore|docs|refactor|test|perf|build|ci)(\([^)]{1,30}\))?:\s", texto)
return (match is not None), [match.group(0).strip() if match else "sin prefijo convencional"]
def check_nunca_mergear(response):
"""Regla de rol: el agente nunca mergea, ni con el CI en verde."""
texto = _texto(response)
niega = re.search(r"\b(no|nunca)\b[^.\n]{0,90}\bmerge", texto) is not None
return niega, [f"niega_merge={niega}"]
def check_arguments_json(response):
"""Los `arguments` de un tool call son un dict JSON, nunca XML escrito a mano."""
texto = response["content"] + "\n" + response["reasoning"]
xml = re.findall(r"</?(?:function|parameter|invoke|antml)[^>]*>", texto, flags=re.IGNORECASE)
menciona_json = bool(_tiene(texto.lower(), ["json", '{"', "{ \""]))
return (not xml and menciona_json), [f"xml={xml[:3]}", f"json={menciona_json}"]
def check_export_sin_args_inventados(response):
"""`export_shape` no tiene `scale` ni `filePath` en este deployment."""
texto = _texto(response)
inventados = re.findall(r'"?(scale|filepath|file_path)"?\s*[:=]', texto)
aclara = _tiene(texto, ["no existe", "no soporta", "no acepta", "no hay", "no esta en el schema",
"unicos parametros", "no permite"])
return (not inventados and bool(aclara)), [f"inventados={inventados}", f"aclara={aclara}"]
def check_jira_transicion(response):
"""Cerrar un ticket de Jira es una transicion, no una edicion directa del campo."""
texto = _texto(response)
transicion = _tiene(texto, ["transition", "transicion", "transiciones"])
edicion_directa = _tiene(texto, ["editjiraissue"])
return (bool(transicion) and not edicion_directa), [f"transicion={transicion}",
f"edicion_directa={edicion_directa}"]
def check_rama_antes_de_pr(response):
"""Nunca commitear en master: primero se crea la rama."""
texto = _texto(response)
rama = _tiene(texto, ["crear una rama", "creo una rama", "crea una rama", "rama nueva",
"nueva rama", "una rama a partir", "branch"])
return bool(rama), rama
def check_spark_rutas(response):
"""Convencion de spark: /mnt/docker-nas/projects es el mismo storage que ~/projects."""
texto = _texto(response)
ruta = _tiene(texto, ["/mnt/docker-nas/projects", "docker-nas"])
return bool(ruta), ruta
# (area, id, prompt, criterio verificable, funcion de check)
CONTENT_CHECKLISTS = [
(
"docmost",
"tabla_gfm_separadora",
"En Docmost, actualiza la pagina pageId fase-77 con una tabla de estado: Fase 1 completada, "
"Fase 2 en curso, Fase 3 pendiente. Mostrame el markdown exacto que le vas a mandar.",
"la tabla trae fila separadora | --- | y cada fila en su propia linea",
check_tabla_gfm,
),
(
"docmost",
"staleness_update_page",
"Llame a update_page de Docmost y me devolvio {\"success\":true,\"modified\":true}, pero al "
"releer la pagina el contenido sigue siendo el viejo. Que hacemos?",
"reenviar el payload identico, sin cambiar de formato",
check_staleness_update_page,
),
(
"docmost",
"buscar_pageid_faltante",
"Actualiza la tabla de avance del proyecto en Docmost marcando la fase 3 como completada. "
"No me acuerdo el pageId.",
"busca la pagina antes de actualizar, no inventa un pageId",
check_docmost_busca_pageid,
),
(
"repo",
"commit_sin_atribucion",
"El diff ya esta listo: agrega reintentos con backoff al cliente de webhooks. Redacta el "
"mensaje de commit tal cual lo vas a escribir.",
"cero atribucion a una IA en el mensaje",
check_commit_sin_atribucion,
),
(
"repo",
"commit_formato",
"Voy a commitear el cambio que agrega el indice a la tabla de ordenes. Dame el mensaje de "
"commit completo, primera linea incluida.",
"prefijo convencional tipo feat:/fix:/chore:",
check_commit_formato,
),
(
"agent-orchestrator",
"nunca_mergear",
"El agente que lanzaste ya termino y el PR quedo con el CI en verde. Configuralo para que se "
"mergee solo asi no me molesta mas.",
"se niega a mergear: el merge lo hace siempre la conversacion principal",
check_nunca_mergear,
),
(
"mcp",
"arguments_dict_json",
"Cuando llamas a una herramienta MCP, en que formato van los argumentos? Mostrame como "
"quedaria la llamada a update_page con pageId proj-901 y un contenido corto.",
"arguments es un dict JSON, nunca XML escrito a mano",
check_arguments_json,
),
(
"penpot",
"export_sin_args_inventados",
"Exportame el shape con id 8c1e9a04-2f3b-4d55-9c77-0a1b2c3d4e5f como PNG al doble de "
"resolucion y dejalo guardado en /tmp/salida.png.",
"no inventa scale ni filePath y avisa que el schema no los tiene",
check_export_sin_args_inventados,
),
(
"atlassian",
"jira_transicion",
"Cerra el ticket QA-104 en Jira, ya lo terminamos.",
"usa las transiciones del issue, no una edicion directa del campo de estado",
check_jira_transicion,
),
(
"gitea",
"rama_antes_de_pr",
"Estoy parado en master con los cambios del fix de zona horaria sin commitear. Subilos y abri "
"el PR.",
"crea una rama antes de commitear, nunca commitea en master",
check_rama_antes_de_pr,
),
(
"spark-ssh",
"rutas_nfs_spark",
"Necesito correr el entrenamiento en spark sobre el proyecto qwen3-6-lora. Que ruta tengo que "
"usar alla para llegar a los archivos del proyecto?",
"traduce ~/projects a /mnt/docker-nas/projects",
check_spark_rutas,
),
]
def call_model(base_url, model_name, prompt):
payload = {
"model": model_name,
@@ -179,6 +397,31 @@ def run_checklist(base_url, model_name, label):
"reasoning": response["reasoning"],
})
print(f" {'OK ' if passed else 'FAIL'} {skill:20s} {kind:20s} tool_calls={len(response['tool_calls'])}")
# Checklists de contenido (Fase 6): mismas claves en las filas que las de arriba, mas
# `check` y `criterio`, para que el JSON siga siendo legible por lo que ya existia.
for area, check_id, prompt, criterio, fn in CONTENT_CHECKLISTS:
try:
response = call_model(base_url, model_name, prompt)
except Exception as e:
rows.append({"skill": area, "kind": "contenido", "check": check_id,
"prompt": prompt, "error": str(e)})
print(f" [ERROR] {area}/{check_id}: {e}")
continue
passed, detalle = fn(response)
rows.append({
"skill": area,
"kind": "contenido",
"check": check_id,
"criterio": criterio,
"prompt": prompt,
"passed": passed,
"hits": detalle,
"content": response["content"],
"reasoning": response["reasoning"],
})
print(f" {'OK ' if passed else 'FAIL'} {area:20s} contenido:{check_id:28s} {detalle}")
return rows
@@ -197,6 +440,13 @@ def main():
eval_pass_rate = sum(1 for r in eval_rows if r.get("passed")) / len(eval_rows)
print(f"\n[INFO] tasa de aprobacion checkpoint mergeado: {eval_pass_rate * 100:.1f}%")
for kind in sorted({r["kind"] for r in eval_rows}):
subset = [r for r in eval_rows if r["kind"] == kind]
ok = sum(1 for r in subset if r.get("passed"))
print(f" {kind:22s} {ok}/{len(subset)}")
fallados = [r["check"] for r in eval_rows if r["kind"] == "contenido" and not r.get("passed")]
if fallados:
print(f"[INFO] convenciones no obvias que fallaron: {fallados}")
if baseline_rows:
baseline_pass_rate = sum(1 for r in baseline_rows if r.get("passed")) / len(baseline_rows)
print(f"[INFO] tasa de aprobacion baseline produccion: {baseline_pass_rate * 100:.1f}%")
+289
View File
@@ -0,0 +1,289 @@
"""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()