"""Fase 2: ensambla data/train.jsonl y data/eval.jsonl (v1 a volumen reducido) a partir de los seeds escritos a mano en data/raw/seeds/*.jsonl mas data/raw/replay.jsonl. Corre localmente (no requiere GPU ni el modelo). Aplica variacion deterministica (random.seed(42)) sobre los seeds -- sustitucion de valores (colores, ids, numeros) + un set chico de parafraseos por bucket -- para llegar al volumen v1 objetivo por bucket sin duplicar lineas exactas. Verifica que ningun seed mencione la skill held-out (spark-ssh). Tagea cada ejemplo con meta.bucket, mezcla, y separa 90/10 train/eval estratificado por bucket para que eval no quede dominado por un solo bucket. """ import json import random import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent SEEDS_DIR = REPO_ROOT / "data" / "raw" / "seeds" REPLAY_PATH = REPO_ROOT / "data" / "raw" / "replay.jsonl" TRAIN_PATH = REPO_ROOT / "data" / "train.jsonl" EVAL_PATH = REPO_ROOT / "data" / "eval.jsonl" SEED = 42 EVAL_FRACTION = 0.10 HELD_OUT_SKILL = "spark-ssh" # Volumen v1 objetivo por bucket (aprox. 800-1000 nuevos, ver PLAN.md seccion 3). # El bucket "replay" no se varia -- se usa tal cual viene de la Fase 1. BUCKET_TARGETS = { "penpot": 110, "otros_mcps": 290, "skills_adherencia": 160, "delegacion_subagentes": 65, "negativos": 70, "manejo_errores": 55, } HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") TRAILING_NUMBER_RE = re.compile(r"^(.*?)(\d+)$") PARAPHRASE_PREFIXES = [ "", "Por favor, ", "Necesito que ", "¿Podés ", "Che, ", "Dale, ", ] PALETTE = ["#1a73e8", "#e8710a", "#188038", "#d93025", "#9334e6", "#12b5cb", "#f9ab00", "#3c4043"] 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") def perturb_value(value, rng): if isinstance(value, str): if HEX_COLOR_RE.match(value): return rng.choice(PALETTE) m = TRAILING_NUMBER_RE.match(value) if m and len(m.group(2)) <= 3: prefix, number = m.group(1), m.group(2) new_number = rng.randint(1, 99) return f"{prefix}{new_number}" return value if isinstance(value, bool): return value if isinstance(value, int): jitter = rng.randint(-max(1, value // 4), max(1, value // 4)) return max(0, value + jitter) if isinstance(value, float): jitter = rng.uniform(-0.25, 0.25) * value return round(value + jitter, 2) if isinstance(value, dict): return {k: perturb_value(v, rng) for k, v in value.items()} if isinstance(value, list): return [perturb_value(v, rng) for v in value] return value def vary_example(example, rng, variant_idx): """Produce una variante determinista del ejemplo (variant_idx=0 devuelve el original sin tocar, para preservar los seeds tal cual como parte del volumen).""" if variant_idx == 0: return json.loads(json.dumps(example, ensure_ascii=False)) varied = json.loads(json.dumps(example, ensure_ascii=False)) for msg in varied.get("messages", []): if msg.get("role") == "user" and variant_idx > 0: prefix = rng.choice(PARAPHRASE_PREFIXES) if prefix and not msg["content"][:1].islower(): msg["content"] = prefix + msg["content"][0].lower() + msg["content"][1:] for tool_call in msg.get("tool_calls", []) or []: args = tool_call.get("function", {}).get("arguments") if isinstance(args, dict): tool_call["function"]["arguments"] = perturb_value(args, rng) return varied 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) 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)) variant_idx += 1 print(f"[INFO] bucket '{bucket}': {n_seeds} seeds -> {len(result)} ejemplos ({variant_idx} pasadas de variacion)") return result 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): 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(): 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)) 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") 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()