"""Fase 2 (revisado en Fase 6): ensambla un train/eval jsonl a partir de los seeds escritos a mano en /*.jsonl mas data/raw/replay.jsonl. Corre localmente (no requiere GPU ni el modelo). Aplica variacion deterministica (random.seed(42)) sobre los seeds para llegar al volumen objetivo por bucket sin duplicar lineas exactas. Verifica que ningun seed mencione la skill held-out (spark-ssh). Tagea cada ejemplo con meta.bucket, mezcla, y separa 90/10 train/eval estratificado por bucket. PARAMETROS POR ENV (los defaults reproducen la invocacion historica de Fase 2) ----------------------------------------------------------------------------- SEEDS_DIR directorio de seeds (def: data/raw/seeds) REPLAY_FILE jsonl de replay de Fase 1 (def: data/raw/replay.jsonl) TRAIN_OUT destino del split de train (def: data/train.jsonl) EVAL_OUT destino del split de eval (def: data/eval.jsonl) ALLOW_OVERWRITE '1' para permitir pisar un destino que ya existe (def: off) Las rutas relativas se resuelven contra la raiz del repo. REGLA CRITICA: NO REGENERAR data/train.jsonl NI data/eval.jsonl --------------------------------------------------------------- Esos dos archivos son la procedencia exacta del modelo que hoy esta en produccion y el baseline de la puerta 1 (perdida ponderada 0.2750). Por eso hay un guard que ABORTA si el destino ya existe, salvo que se pase ALLOW_OVERWRITE=1. Los datasets nuevos se construyen a archivos nuevos: TRAIN_OUT=data/train_v2.jsonl EVAL_OUT=data/eval_v2.jsonl python3 scripts/05_build_dataset.py Y ademas: regenerar NO es idempotente. `stratified_split()` mezcla con un unico RNG (random.Random(42)) sobre la lista concatenada de todos los buckets, asi que cambiar el tamano de UN bucket (p.ej. BUCKET_TARGETS['penpot'] de 110 a 320) corre la secuencia de numeros aleatorios y reshufflea el split de TODOS los buckets. Volver a correr este script con los targets de hoy NO reconstruye el train.jsonl de Fase 2 aunque los seeds no hayan cambiado. AUGMENTACION (reescrita en Fase 6) ---------------------------------- La version anterior tenia dos defectos medidos sobre el train.jsonl de Fase 2: 1. `perturb_value()` reescribia SOLO `tool_calls.function.arguments`, nunca los tool results ni el `content` final del assistant -> 30 ejemplos auto-contradictorios (la llamada decia `issue_number: 82` y la respuesta final decia "issue #77"). El modelo se entrenaba a ignorar sus propios argumentos. 2. La inyeccion de prefijos de parafraseo se pegaba delante de cualquier turno de usuario, sin mirar su forma gramatical -> 68 prompts rotos del tipo "Necesito que ¿Podes usar el tool de import_image...". Reemplazos: - La sustitucion de valores es ATOMICA y se declara en el seed (`meta.variation`): se aplica sobre TODOS los campos de texto del ejemplo a la vez (arguments, tool results, contents, reasoning_content), de modo que un valor que cambia, cambia en todas partes. Sin `meta.variation` no se sustituye ningun valor -- adivinar que numero de un string es "seguro" de perturbar es exactamente lo que producia las contradicciones. - `NEVER_PERTURB_KEYS` protege los valores que jamas pueden variar (`code`, `shapeId`, `tool_call_id`, `id`). Un payload de Penpot perturbado es codigo roto. Si un valor de `meta.variation` aparece dentro de un campo protegido, la build FALLA: seria una variante internamente inconsistente, justo lo que veniamos a eliminar. - El bucket `penpot` esta exento de sustitucion de valores por completo (NO_PERTURB_BUCKETS): sus payloads son codigo, y la variacion tiene que venir de seeds distintos. - `meta.paraphrases` (2-3 reescrituras a mano del turno del usuario) es la via PREFERIDA de variacion del prompt. La inyeccion de prefijos queda solo como fallback y solo cuando el turno arranca con un imperativo que sabemos conjugar; cualquier otra forma se deja intacta. `meta.variation` y `meta.paraphrases` son entradas de build: no se escriben al dataset final. FORMATO DE meta.variation ------------------------- Dos formas equivalentes, las dos deterministas por indice de variante: "meta": {"variation": {"82": ["77", "91"], "billing/pricing.py": ["core/rates.py"]}} "meta": {"variation": [{"82": "77"}, {"82": "91"}]} La primera es un mapa token -> lista de reemplazos (la variante i usa el elemento (i-1) % len). La segunda es una lista de mapas completos, uno por variante. """ import importlib.util import json import os import random import re import sys from collections import Counter from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent def _path_env(name, default_rel): """Resuelve una ruta por env; las relativas cuelgan de la raiz del repo.""" raw = os.environ.get(name) if not raw: return REPO_ROOT / default_rel p = Path(raw) return p if p.is_absolute() else REPO_ROOT / p SEEDS_DIR = _path_env("SEEDS_DIR", "data/raw/seeds") REPLAY_PATH = _path_env("REPLAY_FILE", "data/raw/replay.jsonl") TRAIN_PATH = _path_env("TRAIN_OUT", "data/train.jsonl") EVAL_PATH = _path_env("EVAL_OUT", "data/eval.jsonl") ALLOW_OVERWRITE = os.environ.get("ALLOW_OVERWRITE", "").lower() in ("1", "true", "yes") SEED = 42 EVAL_FRACTION = 0.10 HELD_OUT_SKILL = "spark-ssh" # Volumen objetivo por bucket. El bucket "replay" no se varia -- se usa tal cual viene de la # Fase 1. `penpot` sube de 110 a 320 en Fase 6 (el corpus de diseno reescrito). BUCKET_TARGETS = { "penpot": 320, "otros_mcps": 290, "skills_adherencia": 160, "delegacion_subagentes": 65, "negativos": 70, "manejo_errores": 55, } # Claves cuyo valor NUNCA se sustituye, en ningun bucket. `code` es un payload de JavaScript # (perturbarlo produce codigo roto); los ids son referencias que tienen que seguir cerrando # entre la llamada y su resultado. NEVER_PERTURB_KEYS = {"code", "shapeId", "tool_call_id", "id"} # Buckets exentos de sustitucion de valores por completo. Su variacion viene de seeds # distintos, no de reescribir strings. NO_PERTURB_BUCKETS = {"penpot"} # Anti-colapso del bucket penpot: como esta exento de sustitucion, la unica fuente de # variacion son `meta.paraphrases` y seeds distintos. Si un corpus llega a 320 filas # repitiendo 96 trayectorias identicas, la augmentacion no augmenta nada (es lo que pasaba en # Fase 2: 99 filas -> 40 trayectorias unicas sobre 41 seeds). Se exige >= 2.5 trayectorias # unicas por seed, que es justo lo que dan 2-3 parafrasis a mano por seed mas el original. PENPOT_UNIQUE_RATIO = float(os.environ.get("PENPOT_UNIQUE_RATIO", "2.5")) # El assert duro que mata los 68 prompts rotos de Fase 2: ningun turno de usuario del dataset # final puede tener un prefijo de parafraseo pegado delante de una pregunta ya formada. BROKEN_PREFIX_RE = re.compile(r"(?:Necesito que|Necesito|Por favor,|Che,|Dale,|¿Pod[eé]s)\s*¿") # ------------------------------------------------------------------------------------------ # Parafraseo por prefijo (FALLBACK -- la via preferida es meta.paraphrases) # ------------------------------------------------------------------------------------------ # Cada prefijo declara que forma verbal exige. Un prefijo solo se aplica si podemos convertir # el primer verbo del turno a esa forma; si no, el turno se deja intacto. Preferimos perder # variacion antes que emitir un prompt agramatical: el modelo aprende la forma del prompt. # imperativo -> el turno ya esta en imperativo, solo baja la mayuscula ("Por favor, crea ...") # infinitivo -> "Necesito crear ..." (declarativa, funciona con multiples oraciones) # pregunta -> "¿Podes crear ...?" (solo si el turno es UNA oracion afirmativa) PARAPHRASE_PREFIXES = [ ("", None), ("Por favor, ", "imperativo"), ("Che, ", "imperativo"), ("Dale, ", "imperativo"), ("Necesito ", "infinitivo"), ("¿Podés ", "pregunta"), ] # 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", "import_image / importImage / createImage / filePath", "textAlign", "typography.setFont", ] LINT_PATH = Path(__file__).resolve().parent / "07_lint_penpot_code.py" def load_forbidden_patterns(): spec = importlib.util.spec_from_file_location("penpot_lint", LINT_PATH) if spec is None or spec.loader is None: print(f"[ERROR] no se pudo importar {LINT_PATH}") sys.exit(1) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) by_name = {name: rx for name, rx, _why in module.FORBIDDEN} missing = [n for n in FORBIDDEN_PATTERN_NAMES if n not in by_name] if missing: print( f"[ERROR] {LINT_PATH.name} ya no define estos patrones: {missing}. " f"Actualizar FORBIDDEN_PATTERN_NAMES en este script (la fuente de verdad de los " f"patrones es el lint, no esta copia de nombres)." ) sys.exit(1) return [(n, by_name[n]) for n in FORBIDDEN_PATTERN_NAMES] # ------------------------------------------------------------------------------------------ # Carga # ------------------------------------------------------------------------------------------ def load_jsonl(path): lines = [] with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if line: lines.append(json.loads(line)) return lines def load_seeds(): by_bucket = {} if not SEEDS_DIR.exists(): print(f"[ERROR] {SEEDS_DIR} no existe") sys.exit(1) for path in sorted(SEEDS_DIR.glob("*.jsonl")): examples = load_jsonl(path) for ex in examples: bucket = ex.get("meta", {}).get("bucket") if bucket is None: print(f"[ERROR] {path}: ejemplo sin meta.bucket") sys.exit(1) by_bucket.setdefault(bucket, []).append(ex) print(f"[INFO] {path.name}: {len(examples)} ejemplos cargados") return by_bucket def assert_no_held_out_skill(by_bucket): for bucket, examples in by_bucket.items(): for ex in examples: blob = json.dumps(ex, ensure_ascii=False) assert HELD_OUT_SKILL not in blob, ( f"[ASSERT FAIL] bucket '{bucket}' menciona la skill held-out " f"'{HELD_OUT_SKILL}' -- esta prohibido en todos los buckets" ) print(f"[OK] ninguna mencion de la skill held-out '{HELD_OUT_SKILL}' en los seeds") # ------------------------------------------------------------------------------------------ # Sustitucion atomica de valores # ------------------------------------------------------------------------------------------ def resolve_variation(meta, variant_idx): """Devuelve el mapa token -> reemplazo para esta variante, o {} si el seed no declara variacion. variant_idx >= 1 (la variante 0 es el seed sin tocar).""" variation = meta.get("variation") if not variation: return {} if isinstance(variation, list): chosen = variation[(variant_idx - 1) % len(variation)] if not isinstance(chosen, dict): raise AssertionError( "[ASSERT FAIL] meta.variation como lista tiene que contener dicts " "token -> reemplazo" ) return {str(k): str(v) for k, v in chosen.items()} if isinstance(variation, dict): mapping = {} for token, options in variation.items(): if not isinstance(options, list) or not options: raise AssertionError( f"[ASSERT FAIL] meta.variation['{token}'] tiene que ser una lista no vacia " f"de reemplazos" ) mapping[str(token)] = str(options[(variant_idx - 1) % len(options)]) return mapping raise AssertionError("[ASSERT FAIL] meta.variation tiene que ser un dict o una lista") def substitute_atomic(example, mapping): """Aplica el mapa de sustitucion a TODOS los campos de texto del ejemplo a la vez. Un valor que cambia, cambia en todas partes: arguments (incluidos los escalares JSON no string: `issue_number: 82` es tan parte del ejemplo como el "#82" del texto), tool results, content y reasoning_content. Los valores bajo NEVER_PERTURB_KEYS quedan intactos, y si el token original sobrevive en alguno de ellos se lanza -- seria una variante auto-contradictoria, exactamente el defecto que esta reescritura vino a eliminar. """ if not mapping: return json.loads(json.dumps(example, ensure_ascii=False)) pattern = re.compile("|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True))) survivors = [] hits = Counter() def walk(node, protected): if isinstance(node, dict): return { key: walk(value, protected or key in NEVER_PERTURB_KEYS) for key, value in node.items() } if isinstance(node, list): return [walk(item, protected) for item in node] if isinstance(node, str): if protected: for token in mapping: if token in node: survivors.append(token) return node def _sub(m): hits[m.group(0)] += 1 return mapping[m.group(0)] return pattern.sub(_sub, node) if isinstance(node, (int, float)) and not isinstance(node, bool): # Un escalar JSON (issue_number: 82) se sustituye por igualdad exacta de su # representacion, y se devuelve con el mismo tipo. Si no se hiciera, la llamada # diria 82 y el texto 77: el defecto de Fase 2. key = repr(node) if isinstance(node, float) else str(node) if key in mapping: if protected: survivors.append(key) return node hits[key] += 1 replacement = mapping[key] try: return type(node)(replacement) except ValueError: return replacement return node return node varied = walk(example, False) if survivors: raise AssertionError( f"[ASSERT FAIL] los valores {sorted(set(survivors))} de meta.variation aparecen " f"dentro de un campo protegido ({sorted(NEVER_PERTURB_KEYS)}) y no se pueden " f"sustituir ahi. La variante quedaria auto-contradictoria: sacar esos valores de " f"meta.variation o reescribir el seed." ) unused = [token for token in mapping if hits[token] == 0] if unused: raise AssertionError( f"[ASSERT FAIL] los tokens {unused} de meta.variation no aparecen en el ejemplo " f"(typo en el seed): la sustitucion no haria nada" ) return varied def strip_build_only_meta(example): """meta.variation y meta.paraphrases son entradas de build, no datos de entrenamiento.""" meta = example.get("meta") if isinstance(meta, dict): meta.pop("variation", None) meta.pop("paraphrases", None) return example def vary_example(example, rng, variant_idx, bucket): """Produce una variante determinista del ejemplo. variant_idx == 0 devuelve el seed tal cual (los seeds originales son parte del volumen). Para variant_idx > 0: 1. reescritura del turno del usuario: meta.paraphrases si el seed las trae, y si no, inyeccion de un prefijo gramaticalmente compatible como fallback, 2. sustitucion atomica de valores segun meta.variation (salvo buckets exentos). El orden importa: la sustitucion va DESPUES de la parafrasis para que un valor citado en la parafrasis a mano ("Pasame el issue 82") tambien se sustituya. Al reves, la parafrasis reintroduciria el valor viejo y la variante volveria a ser auto-contradictoria. """ varied = json.loads(json.dumps(example, ensure_ascii=False)) if variant_idx == 0: return strip_build_only_meta(varied) meta = varied.get("meta", {}) or {} paraphrases = meta.get("paraphrases") or [] first_user = next((m for m in varied.get("messages", []) if m.get("role") == "user"), None) if first_user is not None: if paraphrases: # Via preferida: reescrituras a mano del turno del usuario. first_user["content"] = paraphrases[(variant_idx - 1) % len(paraphrases)] else: prefix, mode = rng.choice(PARAPHRASE_PREFIXES) rewritten = apply_prefix(first_user["content"], prefix, mode) if rewritten is not None: first_user["content"] = rewritten mapping = {} if bucket in NO_PERTURB_BUCKETS else resolve_variation(meta, variant_idx) # Se limpia meta ANTES de sustituir: si no, un token que solo aparece en meta.paraphrases # contaria como "usado" y el chequeo de tokens muertos dejaria de servir. strip_build_only_meta(varied) return substitute_atomic(varied, mapping) def build_bucket(bucket, seeds, target_count): rng = random.Random(f"{SEED}-{bucket}") n_seeds = len(seeds) if n_seeds == 0: print(f"[WARN] bucket '{bucket}' no tiene seeds, se omite") return [] if target_count <= n_seeds: print(f"[WARN] bucket '{bucket}': target ({target_count}) <= seeds ({n_seeds}), se usan solo los seeds originales") return [vary_example(ex, rng, 0, bucket) for ex in seeds] result = [] variant_idx = 0 seed_order = list(range(n_seeds)) while len(result) < target_count: if variant_idx > 0: rng.shuffle(seed_order) for i in seed_order: if len(result) >= target_count: break result.append(vary_example(seeds[i], rng, variant_idx, bucket)) variant_idx += 1 print(f"[INFO] bucket '{bucket}': {n_seeds} seeds -> {len(result)} ejemplos ({variant_idx} pasadas de variacion)") return result # ------------------------------------------------------------------------------------------ # Asserts sobre el corpus ensamblado # ------------------------------------------------------------------------------------------ def trajectory_key(example): """Identidad de la trayectoria: messages + tools, sin meta.""" return json.dumps( {"messages": example.get("messages"), "tools": example.get("tools")}, ensure_ascii=False, sort_keys=True, ) def iter_code_calls(example): """(tool_call_id, code) de cada llamada a execute_code, en orden de aparicion.""" for msg in example.get("messages", []) or []: for tool_call in msg.get("tool_calls") or []: args = tool_call.get("function", {}).get("arguments") if isinstance(args, dict) and isinstance(args.get("code"), str): yield tool_call.get("id"), args["code"] def iter_code_payloads(example): for _call_id, code in iter_code_calls(example): yield code def assert_penpot_not_collapsed(examples, n_seeds): """El bucket penpot esta exento de sustitucion de valores, asi que si la augmentacion no produce trayectorias realmente distintas, 320 filas son 96 ejemplos repetidos. En Fase 2 esto era 99 filas -> 40 trayectorias unicas sobre 41 seeds.""" rows = [ex for ex in examples if ex.get("meta", {}).get("bucket") == "penpot"] if not rows: return unique_trajectories = len({trajectory_key(ex) for ex in rows}) unique_code = len({code for ex in rows for code in iter_code_payloads(ex)}) minimum = PENPOT_UNIQUE_RATIO * n_seeds print( f"[INFO] penpot: {len(rows)} filas, {unique_trajectories} trayectorias unicas, " f"{unique_code} payloads de code unicos, {n_seeds} seeds " f"(minimo exigido: {minimum:.0f} trayectorias unicas)" ) assert unique_trajectories >= minimum, ( f"[ASSERT FAIL] el bucket penpot colapso: {unique_trajectories} trayectorias unicas " f"sobre {n_seeds} seeds (minimo {minimum:.0f} = {PENPOT_UNIQUE_RATIO} x seeds). La " f"augmentacion no esta augmentando nada: agregar meta.paraphrases a los seeds o " f"escribir seeds nuevos. Perturbar los payloads NO es una opcion (son codigo)." ) # El bucket esta exento de sustitucion: los payloads de code del corpus generado tienen # que ser exactamente los de los seeds, ni uno mas ni uno menos. assert unique_code > 0, "[ASSERT FAIL] el bucket penpot no tiene ningun payload de code" AUTHORED_PREFIX_LEN = 40 def authored_user_openings(by_bucket, replay_examples): """Los primeros caracteres de cada turno de usuario escrito a mano (seeds, parafrasis a mano y replay). 'Che, ¿que significa X?' es castellano perfectamente valido cuando lo escribio una persona; lo que esta prohibido es que lo produzca nuestra inyeccion de prefijos. Se comparan solo las primeras letras para que una sustitucion de valores mas adelante en la frase no rompa la comparacion.""" openings = set() def add(text): if isinstance(text, str) and text: openings.add(text[:AUTHORED_PREFIX_LEN]) sources = list(replay_examples) for examples in by_bucket.values(): sources.extend(examples) for ex in sources: for paraphrase in (ex.get("meta", {}) or {}).get("paraphrases") or []: add(paraphrase) for msg in ex.get("messages", []) or []: if msg.get("role") == "user": add(msg.get("content") or "") return openings def assert_no_broken_prefixes(examples, authored_openings): """El assert que mata los 68 prompts rotos: cero turnos de usuario con un prefijo de parafraseo pegado delante de una pregunta ya formada.""" offenders = [] for ex in examples: for msg in ex.get("messages", []) or []: if msg.get("role") != "user": continue content = msg.get("content") or "" if BROKEN_PREFIX_RE.search(content): if content[:AUTHORED_PREFIX_LEN] in authored_openings: continue # escrito a mano, no inyectado offenders.append(content[:120]) assert not offenders, ( f"[ASSERT FAIL] {len(offenders)} turno(s) de usuario con un prefijo de parafraseo " f"pegado delante de una pregunta ya formada. Primero: {offenders[0]!r}" ) print("[OK] 0 turnos de usuario con prefijo de parafraseo agramatical") def error_result_call_ids(example): """tool_call_id cuyo resultado NO es JSON, es decir, un string de error real.""" ids = set() for msg in example.get("messages", []) or []: if msg.get("role") != "tool": continue content = (msg.get("content") or "").strip() if content and not content.startswith(("{", "[")): ids.add(msg.get("tool_call_id")) return ids def is_corrected_mistake(example, call_id, rx): """Mismo criterio que 07_lint_penpot_code.py: un payload puede traer un patron prohibido si y solo si (a) su tool result fue un error real y (b) una llamada POSTERIOR del mismo ejemplo hace lo mismo SIN el patron. O sea: el error ocurrio de verdad y fue corregido. Es el material de los grupos A1 y D (auto-correccion desde errores reales).""" if call_id not in error_result_call_ids(example): return False seen = False for cid, code in iter_code_calls(example): if seen and not rx.search(code): return True if cid == call_id: seen = True return False def assert_no_forbidden_patterns(examples, patterns): """Cero ocurrencias de la API prohibida de Penpot en el corpus ensamblado. Alcance: los payloads de `code` (donde la API se INVOCA de verdad), de todos los buckets. La prosa (reasoning_content, content, tool results) queda deliberadamente fuera: un seed tiene que poder nombrar la forma equivocada para advertir contra ella, y la puerta que mide la prosa es otra. La fuente de verdad de los patrones es 07_lint_penpot_code.py. """ problems = [] for idx, ex in enumerate(examples): bucket = ex.get("meta", {}).get("bucket", "sin_bucket") for call_id, code in iter_code_calls(ex): for name, rx in patterns: if not rx.search(code): continue if is_corrected_mistake(ex, call_id, rx): continue for m in rx.finditer(code): line_start = code.rfind("\n", 0, m.start()) + 1 line_end = code.find("\n", m.end()) line = code[line_start: line_end if line_end != -1 else len(code)] # La clave `layout` SI existe en la salida de shapeStructure(): esas # lineas no son la propiedad inexistente del shape. if "shapeStructure" in line: continue problems.append( f"ejemplo #{idx} (bucket={bucket}): [{name}] ...{line.strip()[:120]}..." ) assert not problems, ( f"[ASSERT FAIL] {len(problems)} ocurrencia(s) de API prohibida de Penpot en los " f"payloads de code del corpus ensamblado (fuente de verdad de los patrones: " f"{LINT_PATH.name}):\n - " + "\n - ".join(problems[:20]) ) print(f"[OK] 0 ocurrencias de los {len(patterns)} patrones prohibidos de Penpot en los payloads de code") def guard_output(path): if path.exists() and not ALLOW_OVERWRITE: print( f"[ABORT] {path} ya existe.\n" f" data/train.jsonl y data/eval.jsonl son la procedencia exacta del modelo " f"en produccion y el baseline de la puerta 1: regenerarlos los destruye, y ademas " f"NO es idempotente (ver el docstring).\n" f" Escribi a archivos nuevos con TRAIN_OUT=... EVAL_OUT=..., o corre con " f"ALLOW_OVERWRITE=1 si de verdad queres pisarlo." ) sys.exit(1) def stratified_split(all_examples, rng): by_bucket = {} for ex in all_examples: by_bucket.setdefault(ex["meta"]["bucket"], []).append(ex) train, eval_ = [], [] for bucket, examples in by_bucket.items(): shuffled = examples[:] rng.shuffle(shuffled) n_eval = max(1, round(len(shuffled) * EVAL_FRACTION)) eval_.extend(shuffled[:n_eval]) train.extend(shuffled[n_eval:]) print(f"[INFO] split '{bucket}': {len(shuffled)} total -> train={len(shuffled) - n_eval} eval={n_eval}") rng.shuffle(train) rng.shuffle(eval_) return train, eval_ def write_jsonl(path, examples): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: for ex in examples: f.write(json.dumps(ex, ensure_ascii=False)) f.write("\n") f.flush() def main(): print("=" * 78) print("CONFIGURACION DE ESTA BUILD") print("=" * 78) for label, value in [ ("SEEDS_DIR", SEEDS_DIR), ("REPLAY_FILE", REPLAY_PATH), ("TRAIN_OUT", TRAIN_PATH), ("EVAL_OUT", EVAL_PATH), ("ALLOW_OVERWRITE", ALLOW_OVERWRITE), ("BUCKET_TARGETS", BUCKET_TARGETS), ]: print(f" {label:18} {value}") print("=" * 78) guard_output(TRAIN_PATH) guard_output(EVAL_PATH) forbidden_patterns = load_forbidden_patterns() by_bucket = load_seeds() assert_no_held_out_skill(by_bucket) all_examples = [] for bucket, target_count in BUCKET_TARGETS.items(): seeds = by_bucket.get(bucket, []) all_examples.extend(build_bucket(bucket, seeds, target_count)) assert_penpot_not_collapsed(all_examples, len(by_bucket.get("penpot", []))) replay_examples = load_jsonl(REPLAY_PATH) for ex in replay_examples: ex.setdefault("meta", {})["bucket"] = "replay" print(f"[INFO] bucket 'replay': {len(replay_examples)} ejemplos (sin variacion, tal cual Fase 1)") all_examples.extend(replay_examples) print(f"[INFO] total combinado: {len(all_examples)} ejemplos") assert_no_broken_prefixes(all_examples, authored_user_openings(by_bucket, replay_examples)) assert_no_forbidden_patterns(all_examples, forbidden_patterns) split_rng = random.Random(SEED) train, eval_ = stratified_split(all_examples, split_rng) write_jsonl(TRAIN_PATH, train) write_jsonl(EVAL_PATH, eval_) print(f"[OK] {TRAIN_PATH} escrito: {len(train)} ejemplos") print(f"[OK] {EVAL_PATH} escrito: {len(eval_)} ejemplos") if __name__ == "__main__": main()