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
|
"""Fase 2 (revisado en Fase 6): ensambla un train/eval jsonl a partir de los seeds escritos a
|
||||||
los seeds escritos a mano en data/raw/seeds/*.jsonl mas data/raw/replay.jsonl.
|
mano en <SEEDS_DIR>/*.jsonl mas data/raw/replay.jsonl.
|
||||||
|
|
||||||
Corre localmente (no requiere GPU ni el modelo). Aplica variacion deterministica
|
Corre localmente (no requiere GPU ni el modelo). Aplica variacion deterministica
|
||||||
(random.seed(42)) sobre los seeds -- sustitucion de valores (colores, ids, numeros) +
|
(random.seed(42)) sobre los seeds para llegar al volumen objetivo por bucket sin duplicar
|
||||||
un set chico de parafraseos por bucket -- para llegar al volumen v1 objetivo por bucket
|
lineas exactas. Verifica que ningun seed mencione la skill held-out (spark-ssh). Tagea cada
|
||||||
sin duplicar lineas exactas. Verifica que ningun seed mencione la skill held-out
|
ejemplo con meta.bucket, mezcla, y separa 90/10 train/eval estratificado por bucket.
|
||||||
(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.
|
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 json
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
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"
|
def _path_env(name, default_rel):
|
||||||
EVAL_PATH = REPO_ROOT / "data" / "eval.jsonl"
|
"""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
|
SEED = 42
|
||||||
EVAL_FRACTION = 0.10
|
EVAL_FRACTION = 0.10
|
||||||
HELD_OUT_SKILL = "spark-ssh"
|
HELD_OUT_SKILL = "spark-ssh"
|
||||||
|
|
||||||
# Volumen v1 objetivo por bucket (aprox. 800-1000 nuevos, ver PLAN.md seccion 3).
|
# Volumen objetivo por bucket. El bucket "replay" no se varia -- se usa tal cual viene de la
|
||||||
# El bucket "replay" no se varia -- se usa tal cual viene de la Fase 1.
|
# Fase 1. `penpot` sube de 110 a 320 en Fase 6 (el corpus de diseno reescrito).
|
||||||
BUCKET_TARGETS = {
|
BUCKET_TARGETS = {
|
||||||
"penpot": 110,
|
"penpot": 320,
|
||||||
"otros_mcps": 290,
|
"otros_mcps": 290,
|
||||||
"skills_adherencia": 160,
|
"skills_adherencia": 160,
|
||||||
"delegacion_subagentes": 65,
|
"delegacion_subagentes": 65,
|
||||||
@@ -35,21 +115,224 @@ BUCKET_TARGETS = {
|
|||||||
"manejo_errores": 55,
|
"manejo_errores": 55,
|
||||||
}
|
}
|
||||||
|
|
||||||
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
# Claves cuyo valor NUNCA se sustituye, en ningun bucket. `code` es un payload de JavaScript
|
||||||
TRAILING_NUMBER_RE = re.compile(r"^(.*?)(\d+)$")
|
# (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 = [
|
PARAPHRASE_PREFIXES = [
|
||||||
"",
|
("", None),
|
||||||
"Por favor, ",
|
("Por favor, ", "imperativo"),
|
||||||
"Necesito que ",
|
("Che, ", "imperativo"),
|
||||||
"¿Podés ",
|
("Dale, ", "imperativo"),
|
||||||
"Che, ",
|
("Necesito ", "infinitivo"),
|
||||||
"Dale, ",
|
("¿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):
|
def load_jsonl(path):
|
||||||
lines = []
|
lines = []
|
||||||
with open(path, encoding="utf-8") as f:
|
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")
|
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):
|
# Sustitucion atomica de valores
|
||||||
if HEX_COLOR_RE.match(value):
|
# ------------------------------------------------------------------------------------------
|
||||||
return rng.choice(PALETTE)
|
def resolve_variation(meta, variant_idx):
|
||||||
m = TRAILING_NUMBER_RE.match(value)
|
"""Devuelve el mapa token -> reemplazo para esta variante, o {} si el seed no declara
|
||||||
if m and len(m.group(2)) <= 3:
|
variacion. variant_idx >= 1 (la variante 0 es el seed sin tocar)."""
|
||||||
prefix, number = m.group(1), m.group(2)
|
variation = meta.get("variation")
|
||||||
new_number = rng.randint(1, 99)
|
if not variation:
|
||||||
return f"{prefix}{new_number}"
|
return {}
|
||||||
return value
|
if isinstance(variation, list):
|
||||||
if isinstance(value, bool):
|
chosen = variation[(variant_idx - 1) % len(variation)]
|
||||||
return value
|
if not isinstance(chosen, dict):
|
||||||
if isinstance(value, int):
|
raise AssertionError(
|
||||||
jitter = rng.randint(-max(1, value // 4), max(1, value // 4))
|
"[ASSERT FAIL] meta.variation como lista tiene que contener dicts "
|
||||||
return max(0, value + jitter)
|
"token -> reemplazo"
|
||||||
if isinstance(value, float):
|
)
|
||||||
jitter = rng.uniform(-0.25, 0.25) * value
|
return {str(k): str(v) for k, v in chosen.items()}
|
||||||
return round(value + jitter, 2)
|
if isinstance(variation, dict):
|
||||||
if isinstance(value, dict):
|
mapping = {}
|
||||||
return {k: perturb_value(v, rng) for k, v in value.items()}
|
for token, options in variation.items():
|
||||||
if isinstance(value, list):
|
if not isinstance(options, list) or not options:
|
||||||
return [perturb_value(v, rng) for v in value]
|
raise AssertionError(
|
||||||
return value
|
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):
|
def substitute_atomic(example, mapping):
|
||||||
"""Produce una variante determinista del ejemplo (variant_idx=0 devuelve el
|
"""Aplica el mapa de sustitucion a TODOS los campos de texto del ejemplo a la vez.
|
||||||
original sin tocar, para preservar los seeds tal cual como parte del volumen)."""
|
|
||||||
if variant_idx == 0:
|
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))
|
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", []):
|
def walk(node, protected):
|
||||||
if msg.get("role") == "user" and variant_idx > 0:
|
if isinstance(node, dict):
|
||||||
prefix = rng.choice(PARAPHRASE_PREFIXES)
|
return {
|
||||||
if prefix and not msg["content"][:1].islower():
|
key: walk(value, protected or key in NEVER_PERTURB_KEYS)
|
||||||
msg["content"] = prefix + msg["content"][0].lower() + msg["content"][1:]
|
for key, value in node.items()
|
||||||
for tool_call in msg.get("tool_calls", []) or []:
|
}
|
||||||
args = tool_call.get("function", {}).get("arguments")
|
if isinstance(node, list):
|
||||||
if isinstance(args, dict):
|
return [walk(item, protected) for item in node]
|
||||||
tool_call["function"]["arguments"] = perturb_value(args, rng)
|
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
|
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):
|
def build_bucket(bucket, seeds, target_count):
|
||||||
rng = random.Random(f"{SEED}-{bucket}")
|
rng = random.Random(f"{SEED}-{bucket}")
|
||||||
n_seeds = len(seeds)
|
n_seeds = len(seeds)
|
||||||
@@ -142,7 +528,7 @@ def build_bucket(bucket, seeds, target_count):
|
|||||||
return []
|
return []
|
||||||
if target_count <= n_seeds:
|
if target_count <= n_seeds:
|
||||||
print(f"[WARN] bucket '{bucket}': target ({target_count}) <= seeds ({n_seeds}), se usan solo los seeds originales")
|
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 = []
|
result = []
|
||||||
variant_idx = 0
|
variant_idx = 0
|
||||||
@@ -153,12 +539,188 @@ def build_bucket(bucket, seeds, target_count):
|
|||||||
for i in seed_order:
|
for i in seed_order:
|
||||||
if len(result) >= target_count:
|
if len(result) >= target_count:
|
||||||
break
|
break
|
||||||
result.append(vary_example(seeds[i], rng, variant_idx))
|
result.append(vary_example(seeds[i], rng, variant_idx, bucket))
|
||||||
variant_idx += 1
|
variant_idx += 1
|
||||||
print(f"[INFO] bucket '{bucket}': {n_seeds} seeds -> {len(result)} ejemplos ({variant_idx} pasadas de variacion)")
|
print(f"[INFO] bucket '{bucket}': {n_seeds} seeds -> {len(result)} ejemplos ({variant_idx} pasadas de variacion)")
|
||||||
return result
|
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):
|
def stratified_split(all_examples, rng):
|
||||||
by_bucket = {}
|
by_bucket = {}
|
||||||
for ex in all_examples:
|
for ex in all_examples:
|
||||||
@@ -179,6 +741,7 @@ def stratified_split(all_examples, rng):
|
|||||||
|
|
||||||
|
|
||||||
def write_jsonl(path, examples):
|
def write_jsonl(path, examples):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
for ex in examples:
|
for ex in examples:
|
||||||
f.write(json.dumps(ex, ensure_ascii=False))
|
f.write(json.dumps(ex, ensure_ascii=False))
|
||||||
@@ -187,6 +750,25 @@ def write_jsonl(path, examples):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
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()
|
by_bucket = load_seeds()
|
||||||
assert_no_held_out_skill(by_bucket)
|
assert_no_held_out_skill(by_bucket)
|
||||||
|
|
||||||
@@ -195,6 +777,8 @@ def main():
|
|||||||
seeds = by_bucket.get(bucket, [])
|
seeds = by_bucket.get(bucket, [])
|
||||||
all_examples.extend(build_bucket(bucket, seeds, target_count))
|
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)
|
replay_examples = load_jsonl(REPLAY_PATH)
|
||||||
for ex in replay_examples:
|
for ex in replay_examples:
|
||||||
ex.setdefault("meta", {})["bucket"] = "replay"
|
ex.setdefault("meta", {})["bucket"] = "replay"
|
||||||
@@ -203,6 +787,9 @@ def main():
|
|||||||
|
|
||||||
print(f"[INFO] total combinado: {len(all_examples)} ejemplos")
|
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)
|
split_rng = random.Random(SEED)
|
||||||
train, eval_ = stratified_split(all_examples, split_rng)
|
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
|
"""Fase 2 (revisado en Fase 6): valida los jsonl de dataset (por defecto data/train.jsonl y
|
||||||
del modelo de produccion.
|
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
|
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:
|
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,
|
Por cada ejemplo: tokenizer.apply_chat_template(messages, tools=..., tokenize=True,
|
||||||
return_assistant_tokens_mask=True, return_dict=True) -- assert sin excepcion, mascara de
|
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
|
assistant no vacia. Re-corre un gate de secretos (regex explicitas, igual que 04_sanitize.py,
|
||||||
secretos (regex explicitas, igual que 04_sanitize.py, sin depender de detect-secrets --
|
sin depender de detect-secrets -- puede no estar instalado en este contenedor) sobre los
|
||||||
puede no estar instalado en este contenedor) sobre train.jsonl/eval.jsonl como ultima linea
|
archivos validados como ultima linea de defensa. Reporta un resumen final por bucket con el
|
||||||
de defensa. Reporta un resumen final por bucket.
|
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 json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from transformers import AutoTokenizer
|
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
|
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"
|
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 = [
|
DATASET_FILES = [
|
||||||
REPO_ROOT / "data" / "train.jsonl",
|
_resolve(part)
|
||||||
REPO_ROOT / "data" / "eval.jsonl",
|
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,
|
# 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
|
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):
|
def validate_example(tokenizer, example):
|
||||||
messages = example["messages"]
|
messages = example["messages"]
|
||||||
tools = strip_tools_for_check(example.get("tools"))
|
tools = strip_tools_for_check(example.get("tools"))
|
||||||
|
|
||||||
|
template_kwargs = {}
|
||||||
|
if PRESERVE_THINKING:
|
||||||
|
template_kwargs["preserve_thinking"] = True
|
||||||
|
|
||||||
rendered = tokenizer.apply_chat_template(
|
rendered = tokenizer.apply_chat_template(
|
||||||
messages,
|
messages,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
@@ -118,6 +173,7 @@ def validate_example(tokenizer, example):
|
|||||||
return_assistant_tokens_mask=True,
|
return_assistant_tokens_mask=True,
|
||||||
return_dict=True,
|
return_dict=True,
|
||||||
add_generation_prompt=False,
|
add_generation_prompt=False,
|
||||||
|
**template_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
input_ids = rendered["input_ids"]
|
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}")
|
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")
|
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 = {}
|
summary = {}
|
||||||
|
lengths_by_bucket = {}
|
||||||
|
too_long = []
|
||||||
total_exceptions = 0
|
total_exceptions = 0
|
||||||
total_filtered_length = 0
|
|
||||||
total_ok = 0
|
total_ok = 0
|
||||||
|
|
||||||
for path in DATASET_FILES:
|
for path in DATASET_FILES:
|
||||||
@@ -156,7 +216,7 @@ def main():
|
|||||||
|
|
||||||
for lineno, example in examples:
|
for lineno, example in examples:
|
||||||
bucket = example.get("meta", {}).get("bucket", "sin_bucket")
|
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:
|
try:
|
||||||
n_tokens, mask_sum = validate_example(tokenizer, example)
|
n_tokens, mask_sum = validate_example(tokenizer, example)
|
||||||
@@ -166,34 +226,72 @@ def main():
|
|||||||
print(f"[EXCEPTION] {path.name}:{lineno} (bucket={bucket}): {e}")
|
print(f"[EXCEPTION] {path.name}:{lineno} (bucket={bucket}): {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
lengths_by_bucket.setdefault(bucket, []).append(n_tokens)
|
||||||
|
|
||||||
if n_tokens > MAX_TOKENS:
|
if n_tokens > MAX_TOKENS:
|
||||||
stats["filtered_length"] += 1
|
stats["too_long"] += 1
|
||||||
total_filtered_length += 1
|
too_long.append((path.name, lineno, bucket, n_tokens))
|
||||||
print(f"[FILTERED] {path.name}:{lineno} (bucket={bucket}): {n_tokens} tokens > {MAX_TOKENS}")
|
print(f"[TOO LONG] {path.name}:{lineno} (bucket={bucket}): {n_tokens} tokens > {MAX_TOKENS}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stats["ok"] += 1
|
stats["ok"] += 1
|
||||||
total_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 = []
|
secret_problems = []
|
||||||
for path in DATASET_FILES:
|
for path in DATASET_FILES:
|
||||||
secret_problems.extend(secrets_gate(path))
|
secret_problems.extend(secrets_gate(path))
|
||||||
|
|
||||||
print("\n=== RESUMEN POR BUCKET ===")
|
print("\n=== RESUMEN POR BUCKET ===")
|
||||||
for bucket, stats in sorted(summary.items()):
|
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:
|
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:
|
for problem in secret_problems:
|
||||||
print(f" - {problem}")
|
print(f" - {problem}")
|
||||||
else:
|
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(1)
|
||||||
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ N_TRAIN = 900
|
|||||||
EVAL_FRACTION = 0.10
|
EVAL_FRACTION = 0.10
|
||||||
N_CALIBRATION = 256
|
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
|
# grupo de seed -> porcion de la mezcla
|
||||||
PORTION_OF_GROUP = {
|
PORTION_OF_GROUP = {
|
||||||
"A1": "api_forma_correcta", "A2": "api_forma_correcta", "A3": "api_forma_correcta",
|
"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 "
|
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)")
|
"(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)
|
counts = Counter(ex["meta"]["porcion"] for ex in mixed)
|
||||||
print("\n=== COMPOSICION DE LA MEZCLA ===")
|
print("\n=== COMPOSICION DE LA MEZCLA ===")
|
||||||
penpot_new = 0
|
penpot_new = 0
|
||||||
|
|||||||
+371
-110
@@ -1,12 +1,37 @@
|
|||||||
"""Fase 4 -- genera data/holdout_prompts.jsonl: ~200 prompts held-out para la Puerta 2
|
"""Fase 4 (rebalanceado en Fase 6) -- genera data/holdout_prompts.jsonl: ~200 prompts
|
||||||
(validez de tool-calls), cubriendo los 5 MCPs, sin overlap con train.jsonl/eval.jsonl.
|
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",
|
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
|
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
|
para no reusar la misma secuencia) sobre plantillas por MCP.
|
||||||
ejemplo de train/eval (se verifica al final contra el texto normalizado de ambos archivos).
|
|
||||||
|
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 json
|
||||||
import random
|
import random
|
||||||
@@ -14,6 +39,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
SCHEMAS_DIR = REPO_ROOT / "data" / "schemas"
|
SCHEMAS_DIR = REPO_ROOT / "data" / "schemas"
|
||||||
|
SEEDS_DIR = REPO_ROOT / "data" / "raw" / "seeds"
|
||||||
TRAIN_PATH = REPO_ROOT / "data" / "train.jsonl"
|
TRAIN_PATH = REPO_ROOT / "data" / "train.jsonl"
|
||||||
EVAL_PATH = REPO_ROOT / "data" / "eval.jsonl"
|
EVAL_PATH = REPO_ROOT / "data" / "eval.jsonl"
|
||||||
OUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
|
OUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
|
||||||
@@ -21,102 +47,55 @@ OUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
|
|||||||
SEED = 43
|
SEED = 43
|
||||||
TARGET_TOTAL = 200
|
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 = {
|
MCP_TARGETS = {
|
||||||
"penpot": 40,
|
"penpot": 60,
|
||||||
"gitea": 40,
|
"gitea": 35,
|
||||||
"github-personal": 40,
|
"github-personal": 35,
|
||||||
"docmost": 40,
|
"docmost": 35,
|
||||||
"atlassian": 40,
|
"atlassian": 35,
|
||||||
}
|
}
|
||||||
|
|
||||||
PENPOT_TEMPLATES = [
|
# --- deteccion de contaminacion ----------------------------------------------------
|
||||||
"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.",
|
|
||||||
]
|
|
||||||
|
|
||||||
GITEA_TEMPLATES = [
|
SHINGLE_N = 6
|
||||||
"Crea una rama llamada '{branch}' en el repo '{repo}' desde main.",
|
# Fraccion maxima de shingles de 6-gramas de un prompt held-out que puede aparecer en
|
||||||
"Abri un issue en '{repo}' titulado '{text}' con la label 'bug'.",
|
# train/eval. Un prompt genuinamente nuevo comparte a lo sumo giros sueltos ("en el repo
|
||||||
"Lista los pull requests abiertos del repo '{repo}'.",
|
# de", "de la pagina con"); una parafrasis de un ejemplo de training comparte la mayoria.
|
||||||
"Crea un release '{branch}' en '{repo}' con las notas '{text}'.",
|
MAX_SHINGLE_OVERLAP = 0.34
|
||||||
"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"))
|
|
||||||
|
|
||||||
|
|
||||||
def normalize(text):
|
def normalize(text):
|
||||||
return " ".join(text.lower().split())
|
return " ".join(text.lower().split())
|
||||||
|
|
||||||
|
|
||||||
def load_existing_texts():
|
def shingles(text, n=SHINGLE_N):
|
||||||
texts = set()
|
"""Conjunto de n-gramas de palabras del texto normalizado.
|
||||||
for path in (TRAIN_PATH, EVAL_PATH):
|
|
||||||
|
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:
|
with open(path, encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -125,18 +104,265 @@ def load_existing_texts():
|
|||||||
example = json.loads(line)
|
example = json.loads(line)
|
||||||
for msg in example.get("messages", []):
|
for msg in example.get("messages", []):
|
||||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||||
texts.add(normalize(msg["content"]))
|
yield msg["content"]
|
||||||
return texts
|
|
||||||
|
|
||||||
|
|
||||||
def build_prompts(rng, mcp_name, count):
|
def load_reference_corpus(paths=(TRAIN_PATH, EVAL_PATH)):
|
||||||
templates = MCP_TEMPLATES[mcp_name]
|
"""Devuelve (textos_normalizados, shingles) de los turnos de usuario de train/eval."""
|
||||||
prompts = []
|
texts = set()
|
||||||
for i in range(count):
|
ref_shingles = set()
|
||||||
template = templates[i % len(templates)]
|
for content in iter_user_texts(paths):
|
||||||
text = template.format(
|
texts.add(normalize(content))
|
||||||
w=rng.choice([80, 120, 200, 320, 480]),
|
ref_shingles |= shingles(content)
|
||||||
h=rng.choice([40, 60, 100, 240, 360]),
|
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),
|
board=rng.choice(BOARDS),
|
||||||
color=rng.choice(COLORS),
|
color=rng.choice(COLORS),
|
||||||
shape=f"{rng.choice(SHAPES)}-{rng.randint(1, 99)}",
|
shape=f"{rng.choice(SHAPES)}-{rng.randint(1, 99)}",
|
||||||
@@ -144,24 +370,53 @@ def build_prompts(rng, mcp_name, count):
|
|||||||
repo=rng.choice(REPOS),
|
repo=rng.choice(REPOS),
|
||||||
branch=f"{rng.choice(BRANCHES)}-{rng.randint(1, 99)}",
|
branch=f"{rng.choice(BRANCHES)}-{rng.randint(1, 99)}",
|
||||||
num=rng.randint(1, 500),
|
num=rng.randint(1, 500),
|
||||||
)
|
dominio=rng.choice(DOMINIOS),
|
||||||
prompts.append(text)
|
estilo=rng.choice(ESTILOS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
return prompts
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
rng = random.Random(SEED)
|
rng = random.Random(SEED)
|
||||||
existing_texts = load_existing_texts()
|
existing_texts, reference_shingles = load_reference_corpus()
|
||||||
print(f"[INFO] {len(existing_texts)} prompts de usuario existentes en train.jsonl/eval.jsonl")
|
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 = []
|
examples = []
|
||||||
|
max_overlap = 0.0
|
||||||
|
seen = set()
|
||||||
for mcp_name, count in MCP_TARGETS.items():
|
for mcp_name, count in MCP_TARGETS.items():
|
||||||
tools = load_tools(mcp_name)
|
tools = load_tools(mcp_name)
|
||||||
prompts = build_prompts(rng, mcp_name, count)
|
for prompt, expect in build_prompts(rng, mcp_name, count, seen):
|
||||||
for prompt in prompts:
|
overlap = assert_no_contamination(prompt, existing_texts, reference_shingles)
|
||||||
if normalize(prompt) in existing_texts:
|
max_overlap = max(max_overlap, overlap)
|
||||||
raise AssertionError(f"prompt held-out colisiona con train/eval: {prompt!r}")
|
examples.append({"prompt": prompt, "mcp": mcp_name, "expect": expect, "tools": tools})
|
||||||
examples.append({"prompt": prompt, "mcp": mcp_name, "tools": tools})
|
|
||||||
|
|
||||||
rng.shuffle(examples)
|
rng.shuffle(examples)
|
||||||
|
|
||||||
@@ -173,9 +428,15 @@ def main():
|
|||||||
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
|
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
print(f"[INFO] {len(examples)} prompts held-out escritos en {OUT_PATH}")
|
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:
|
for mcp_name in MCP_TARGETS:
|
||||||
n = sum(1 for ex in examples if ex["mcp"] == mcp_name)
|
n = sum(1 for ex in examples if ex["mcp"] == mcp_name)
|
||||||
print(f" {mcp_name}: {n}")
|
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__":
|
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):
|
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,
|
- 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
|
que los argumentos parseen como JSON valido, que las propiedades "required" del
|
||||||
schema esten presentes.
|
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
|
- 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).
|
prompts pueden resolverse sin tool-call, pero se reporta la tasa).
|
||||||
|
|
||||||
Reporta: % de prompts con tool_call sintacticamente valido (parseado sin excepcion por
|
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 argparse
|
||||||
import json
|
import json
|
||||||
@@ -75,25 +89,47 @@ def to_openai_tools(tools):
|
|||||||
return openai_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):
|
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"]
|
name = tool_call["function"]["name"]
|
||||||
raw_args = tool_call["function"]["arguments"]
|
raw_args = tool_call["function"]["arguments"]
|
||||||
try:
|
try:
|
||||||
args = json.loads(raw_args)
|
args = json.loads(raw_args)
|
||||||
except json.JSONDecodeError as e:
|
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)
|
tool_def = tool_by_name(tools, name)
|
||||||
if tool_def is None:
|
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 {}
|
schema = tool_def.get("inputSchema") or tool_def.get("parameters") or {}
|
||||||
required = schema.get("required", [])
|
required = schema.get("required", [])
|
||||||
missing = [r for r in required if r not in args]
|
missing = [r for r in required if r not in args]
|
||||||
if missing:
|
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):
|
def call_vllm(prompt, tools, timeout=240):
|
||||||
@@ -121,7 +157,15 @@ def main():
|
|||||||
print(f"[INFO] {len(examples)} prompts held-out, endpoint={BASE_URL}")
|
print(f"[INFO] {len(examples)} prompts held-out, endpoint={BASE_URL}")
|
||||||
|
|
||||||
results = []
|
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()
|
t0 = time.time()
|
||||||
for i, ex in enumerate(examples):
|
for i, ex in enumerate(examples):
|
||||||
@@ -134,6 +178,8 @@ def main():
|
|||||||
results.append({"mcp": mcp, "prompt": ex["prompt"], "error": str(e)})
|
results.append({"mcp": mcp, "prompt": ex["prompt"], "error": str(e)})
|
||||||
stats[mcp]["invalid"] += 1
|
stats[mcp]["invalid"] += 1
|
||||||
stats["__global__"]["invalid"] += 1
|
stats["__global__"]["invalid"] += 1
|
||||||
|
stats[mcp]["request_error"] += 1
|
||||||
|
stats["__global__"]["request_error"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
message = response["choices"][0]["message"]
|
message = response["choices"][0]["message"]
|
||||||
@@ -149,6 +195,7 @@ def main():
|
|||||||
results.append({
|
results.append({
|
||||||
"mcp": mcp,
|
"mcp": mcp,
|
||||||
"prompt": ex["prompt"],
|
"prompt": ex["prompt"],
|
||||||
|
"expect": ex.get("expect"),
|
||||||
"tool_calls": None,
|
"tool_calls": None,
|
||||||
"valid": None,
|
"valid": None,
|
||||||
"content": content,
|
"content": content,
|
||||||
@@ -159,11 +206,14 @@ def main():
|
|||||||
|
|
||||||
all_valid = True
|
all_valid = True
|
||||||
errors = []
|
errors = []
|
||||||
|
error_kinds = []
|
||||||
for tc in tool_calls:
|
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:
|
if not ok:
|
||||||
all_valid = False
|
all_valid = False
|
||||||
errors.append(err)
|
errors.append(err)
|
||||||
|
if kind not in error_kinds:
|
||||||
|
error_kinds.append(kind)
|
||||||
|
|
||||||
if all_valid:
|
if all_valid:
|
||||||
stats[mcp]["valid_tool_call"] += 1
|
stats[mcp]["valid_tool_call"] += 1
|
||||||
@@ -171,13 +221,20 @@ def main():
|
|||||||
else:
|
else:
|
||||||
stats[mcp]["invalid"] += 1
|
stats[mcp]["invalid"] += 1
|
||||||
stats["__global__"]["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({
|
results.append({
|
||||||
"mcp": mcp,
|
"mcp": mcp,
|
||||||
"prompt": ex["prompt"],
|
"prompt": ex["prompt"],
|
||||||
|
"expect": ex.get("expect"),
|
||||||
"tool_calls": [tc["function"]["name"] for tc in tool_calls],
|
"tool_calls": [tc["function"]["name"] for tc in tool_calls],
|
||||||
"valid": all_valid,
|
"valid": all_valid,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
|
"error_kinds": error_kinds,
|
||||||
"content": content,
|
"content": content,
|
||||||
"reasoning": reasoning,
|
"reasoning": reasoning,
|
||||||
"finish_reason": response["choices"][0].get("finish_reason"),
|
"finish_reason": response["choices"][0].get("finish_reason"),
|
||||||
@@ -198,6 +255,19 @@ def main():
|
|||||||
f"pct_valid={pct_valid:.1f}%"
|
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:
|
with open(RESULTS_PATH, "w", encoding="utf-8") as f:
|
||||||
json.dump({"stats": stats, "results": results}, f, ensure_ascii=False, indent=2)
|
json.dump({"stats": stats, "results": results}, f, ensure_ascii=False, indent=2)
|
||||||
print(f"\n[INFO] resultados detallados en {RESULTS_PATH}")
|
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
|
accion real); se verifica que la respuesta NO ejecuta ninguna tool_call de esa
|
||||||
skill/MCP.
|
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.
|
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 json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
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):
|
def call_model(base_url, model_name, prompt):
|
||||||
payload = {
|
payload = {
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
@@ -179,6 +397,31 @@ def run_checklist(base_url, model_name, label):
|
|||||||
"reasoning": response["reasoning"],
|
"reasoning": response["reasoning"],
|
||||||
})
|
})
|
||||||
print(f" {'OK ' if passed else 'FAIL'} {skill:20s} {kind:20s} tool_calls={len(response['tool_calls'])}")
|
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
|
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)
|
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}%")
|
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:
|
if baseline_rows:
|
||||||
baseline_pass_rate = sum(1 for r in baseline_rows if r.get("passed")) / len(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}%")
|
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