Phase 6: train a second LoRA for real Penpot UI design capability #5
@@ -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
File diff suppressed because one or more lines are too long
+649
-62
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}%")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user