#!/usr/bin/env python3 # SPDX-License-Identifier: BUSL-1.1 """ destilar_v3.py — Destilación de Qwen2.5-Coder-32B → PamparV3 Usa OpenRouter (tier GRATIS) para generar código Python de calidad profesional y lo guarda como JSONL para entrenar PamparV3. Modelos gratuitos disponibles en OpenRouter: - qwen/qwen-2.5-coder-32b-instruct:free ← recomendado (mejor en código) - deepseek/deepseek-r1:free ← reasoning, más lento - google/gemini-2.0-flash-thinking-exp:free Uso: # Obtener API key gratis en https://openrouter.ai/keys python scripts/destilar_v3.py --api-key sk-or-... python scripts/destilar_v3.py --api-key sk-or-... --n 2000 python scripts/destilar_v3.py --api-key sk-or-... --modelo deepseek/deepseek-r1:free # Con variable de entorno (recomendado): $env:OPENROUTER_API_KEY = "sk-or-..." python scripts/destilar_v3.py --n 3000 """ import argparse import json import os import random import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) # Leer .env si existe (para no exponer la key en el terminal) _env_file = Path(__file__).parent.parent / ".env" if _env_file.exists(): for _line in _env_file.read_text(encoding="utf-8").splitlines(): _line = _line.strip() if _line and not _line.startswith("#") and "=" in _line: _k, _v = _line.split("=", 1) os.environ.setdefault(_k.strip(), _v.strip()) # ============================================================================= # Prompts para el profesor — ejercicios de código Python de distinto nivel # ============================================================================= TEMAS_NIVEL_1 = [ "funciones de strings: reverse, count, replace, strip, split", "operaciones con listas: append, sort, filter, map, zip", "números: primos, factoriales, fibonacci, divisores", "diccionarios: frecuencias, invertir, merge, filtrar por valor", "condicionales y loops: FizzBuzz, triangulos, patrones", "manejo de fechas con datetime", "operaciones de sets: union, interseccion, diferencia", ] TEMAS_NIVEL_2 = [ "recursion: torres de hanoi, permutaciones, árbol de decisión", "algoritmos de sorting: bubble, merge, quick, heap sort", "búsqueda binaria y variantes", "programación funcional: reduce, partial, currying, closures", "generadores e iteradores: yield, send, StopIteration", "decoradores: memoize, timer, retry, rate_limit", "context managers: __enter__, __exit__, contextlib", "comprensiones anidadas y expresiones generadoras complejas", ] TEMAS_NIVEL_3 = [ "clases y OOP: herencia, polimorfismo, dunder methods", "dataclasses y attrs: frozen, validators, converters", "patrones de diseño: singleton, factory, observer, strategy", "metaclases y descriptores", "async/await: asyncio, tasks, gather, queues", "threading y multiprocessing: locks, events, pools", "estructuras de datos: linked list, árbol BST, heap, graph", "algoritmos de grafos: BFS, DFS, Dijkstra, A*", "type hints avanzados: Generic, Protocol, TypeVar, overload", ] TEMAS_NIVEL_4 = [ "parsers y tokenizers simples desde cero", "implementar un mini ORM estilo Django desde cero", "web scraping con requests y BeautifulSoup", "API REST simple con FastAPI y Pydantic", "testing con pytest: fixtures, parametrize, mocking, coverage", "profiling y optimización: cProfile, memory_profiler, line_profiler", "implementar un sistema de caché LRU y TTL", "compresión y serialización: pickle, json, msgpack, protocol buffers", ] TODOS_LOS_TEMAS = [ (1, t) for t in TEMAS_NIVEL_1 ] + [ (2, t) for t in TEMAS_NIVEL_2 ] + [ (3, t) for t in TEMAS_NIVEL_3 ] + [ (4, t) for t in TEMAS_NIVEL_4 ] def prompt_para_tema(nivel: int, tema: str) -> str: """Genera un prompt que hace que el profesor produzca código Python real.""" instrucciones = { 1: "Escribe 3 funciones Python simples pero útiles relacionadas con", 2: "Escribe 2 soluciones Python con explicación de complejidad sobre", 3: "Implementa desde cero en Python (sin librerías externas) un ejemplo completo de", 4: "Escribe código Python de producción, bien documentado y con tests, sobre", } base = instrucciones.get(nivel, "Escribe código Python sobre") return ( f"{base} {tema}. " "Incluye docstrings, type hints, manejo de edge cases y ejemplos de uso. " "El código debe ser correcto, idiomático y ejecutable tal cual. " "No incluyas explicaciones en prosa, solo código Python con comentarios inline." ) # ============================================================================= # Cliente OpenRouter # ============================================================================= # Modelo principal (pago, con créditos OpenRouter) MODELO_PRINCIPAL = "qwen/qwen3-coder-30b-a3b-instruct" # Fallback si el principal tiene rate limit MODELOS_FALLBACK = [ "qwen/qwen3-coder-30b-a3b-instruct", "qwen/qwen-2.5-coder-32b-instruct", "qwen/qwen2.5-coder-7b-instruct", ] # Precio aproximado por 1M tokens (input, output) — para tracking de costo PRECIO_POR_MILLON = { "qwen/qwen3-coder-30b-a3b-instruct": (0.070, 0.270), "qwen/qwen-2.5-coder-32b-instruct": (0.200, 0.200), "qwen/qwen2.5-coder-7b-instruct": (0.030, 0.090), } MODELOS_FREE = MODELOS_FALLBACK # alias para retrocompatibilidad _modelo_idx = 0 # rotación global _costo_total = 0.0 # tracking de gasto def _sumar_costo(modelo: str, tokens_in: int, tokens_out: int) -> float: """Calcula y acumula costo de la llamada.""" global _costo_total pin, pout = PRECIO_POR_MILLON.get(modelo, (0.2, 0.2)) costo = (tokens_in * pin + tokens_out * pout) / 1_000_000 _costo_total += costo return costo def llamar_api( prompt: str, api_key: str, modelo: str, max_tokens: int = 1500, temperature: float = 0.3, timeout: int = 90, ) -> str | None: """ Llama a OpenRouter API con backoff exponencial global. Cuando todos los modelos devuelven 429, espera 60s antes de reintentar en vez de rotar rápidamente y agotar más cuota. """ import urllib.request import urllib.error global _modelo_idx n_modelos = len(MODELOS_FREE) for ronda in range(n_modelos * 2): # hasta 2 vueltas completas modelo_actual = MODELOS_FREE[_modelo_idx % n_modelos] payload = json.dumps({ "model": modelo_actual, "messages": [ { "role": "system", "content": ( "Eres un experto en Python. Respondes ÚNICAMENTE con código Python " "limpio, funcional y bien documentado. Sin markdown, sin bloques ```python. " "Solo código Python puro que se pueda ejecutar directamente." ), }, {"role": "user", "content": prompt}, ], "max_tokens": max_tokens, "temperature": temperature, }).encode("utf-8") req = urllib.request.Request( "https://openrouter.ai/api/v1/chat/completions", data=payload, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://github.com/lucasmella-stack/PAMPAr-Coder", "X-Title": "PAMPAr-Coder Distillation", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) if "error" in data and "choices" not in data: print(f"\n [API error body] {str(data)[:150]}") _modelo_idx += 1 time.sleep(3) continue texto = data["choices"][0]["message"]["content"] # Tracking de costo usage = data.get("usage", {}) _sumar_costo(modelo_actual, usage.get("prompt_tokens", 400), usage.get("completion_tokens", 600)) if texto: return texto # Respuesta vacía → rotar modelo _modelo_idx += 1 continue except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="ignore") if e.code == 429: _modelo_idx += 1 siguiente = MODELOS_FREE[_modelo_idx % n_modelos] # Si completamos una vuelta completa, backoff global de 60s if ronda > 0 and ronda % n_modelos == 0: print(f"\n [rate limit global] todos los modelos saturados — esperando 60s...", end="", flush=True) time.sleep(60) else: print(f"\n [429] → {siguiente.split('/')[1]} ", end="", flush=True) time.sleep(5) continue print(f"\n [API error {e.code}] {body[:150]}") return None except Exception as e: print(f"\n [error] {type(e).__name__}: {e}") time.sleep(5) return None print(f"\n [!, agotados reintentos]") return None # ============================================================================= # Validación básica del código generado # ============================================================================= def limpiar_codigo(texto: str) -> str: """Elimina bloques markdown ```python ... ``` si el modelo los incluye.""" texto = texto.strip() if texto.startswith("```"): # Quitar primera línea (```python o ```) lineas = texto.split("\n") if lineas[0].startswith("```"): lineas = lineas[1:] # Quitar cierre ``` if lineas and lineas[-1].strip() == "```": lineas = lineas[:-1] texto = "\n".join(lineas).strip() return texto def es_codigo_valido(texto: str) -> bool: """Rechaza respuestas que no son código Python real.""" import ast if len(texto.strip()) < 30: return False # Debe tener al menos una definición tiene_def = any(kw in texto for kw in ("def ", "class ", "async def ")) if not tiene_def: return False # Intentar parsear (no falla si hay clases parciales, etc.) try: ast.parse(texto) return True except SyntaxError: # Aceptar igual — el modelo puede generar fragmentos válidos con pequeños errores return len(texto) > 100 # ============================================================================= # Main # ============================================================================= def main() -> None: parser = argparse.ArgumentParser(description="Destilación Qwen→PamparV3 vía OpenRouter") parser.add_argument("--api-key", default=os.getenv("OPENROUTER_API_KEY"), help="OpenRouter API key") parser.add_argument("--modelo", default=MODELO_PRINCIPAL, help="Modelo de OpenRouter a usar como profesor") parser.add_argument("--n", type=int, default=5000, help="Ejemplos a generar") parser.add_argument("--output", default="biblioteca/python_real/destilado_qwen.jsonl") parser.add_argument("--temp", type=float, default=0.35) parser.add_argument("--max-tokens", type=int, default=1400) parser.add_argument("--delay", type=float, default=0.3, help="Segundos entre llamadas (0.3s con créditos — hasta 200 rpm)") parser.add_argument("--si", action="store_true", help="No pedir confirmación") args = parser.parse_args() if not args.api_key: print("\n ❌ Necesitás una API key de OpenRouter (gratis en https://openrouter.ai/keys)") print(" Pasala con --api-key sk-or-... o con:") print(" $env:OPENROUTER_API_KEY = 'sk-or-...'") sys.exit(1) output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) # Ejemplos ya existentes (para no regenerar si se interrumpe) ya_generados = 0 if output.exists(): with open(output, encoding="utf-8") as f: ya_generados = sum(1 for _ in f) print(f"\n{'═'*65}") print(f" DESTILACIÓN — {args.modelo.split('/')[-1]}") print(f"{'═'*65}") print(f" Output : {output}") print(f" Objetivo : {args.n} ejemplos") print(f" Ya existen : {ya_generados} ejemplos") print(f" Por generar: {max(0, args.n - ya_generados)} ejemplos") print(f" Delay : {args.delay}s (tier free)") pin, pout = PRECIO_POR_MILLON.get(args.modelo, (0.2, 0.2)) costo_estimado = (args.n * 600 * pin + args.n * 900 * pout) / 1_000_000 print(f" Modelo : {args.modelo}") print(f" Costo est. : ~${costo_estimado:.2f} de $10 disponibles") print(f"{'═'*65}\n") faltan = args.n - ya_generados if faltan <= 0: print(f" ✅ Ya tenés {ya_generados} ejemplos. Usá --n {ya_generados + 500} para más.") return if not args.si: resp = input(f" ¿Generar {faltan} ejemplos? (s/n): ").strip().lower() if resp not in ("s", "si", "sí", "y", "yes"): print(" Cancelado.") return n_ok = 0 n_error = 0 n_invalido = 0 t_start = time.time() # Ciclar por temas infinitamente hasta llegar al objetivo temas_pool = TODOS_LOS_TEMAS * ((faltan // len(TODOS_LOS_TEMAS)) + 2) random.shuffle(temas_pool) with open(output, "a", encoding="utf-8") as f_out: for i, (nivel, tema) in enumerate(temas_pool): if n_ok >= faltan: break prompt = prompt_para_tema(nivel, tema) elapsed = time.time() - t_start eta = (elapsed / max(n_ok, 1)) * (faltan - n_ok) if n_ok > 0 else "?" print( f" [{ya_generados + n_ok + 1:04d}/{args.n}] " f"nivel={nivel} {tema[:45]:<45} ", end="", flush=True, ) respuesta = llamar_api( prompt, args.api_key, args.modelo, max_tokens=args.max_tokens, temperature=args.temp, ) if respuesta is None: n_error += 1 print(f"ERROR (total errores: {n_error})") if n_error % 5 == 0: print(f" [!] {n_error} errores — esperando 30s...") time.sleep(30) continue respuesta = limpiar_codigo(respuesta) if not es_codigo_valido(respuesta): n_invalido += 1 print(f"INVÁLIDO ({len(respuesta)} chars)") continue # Guardar en formato compatible con LectorBiblioteca entrada = json.dumps({ "instruction": prompt, "output": respuesta, "nivel": nivel, "tema": tema, "profesor": args.modelo, }, ensure_ascii=False) f_out.write(entrada + "\n") f_out.flush() n_ok += 1 tok_aprox = len(respuesta.split()) eta_str = f"{int(eta)}s" if isinstance(eta, float) else eta print(f"OK ~{tok_aprox}tok ${_costo_total:.3f} ETA:{eta_str}") time.sleep(args.delay) # ── Resumen ────────────────────────────────────────────────────────────── total_archivo = ya_generados + n_ok elapsed = time.time() - t_start print(f"\n{'═'*65}") print(f" COMPLETADO en {elapsed/60:.1f} min") print(f" Nuevos ejemplos : {n_ok}") print(f" Total en archivo : {total_archivo}") print(f" Errores API : {n_error}") print(f" Inválidos : {n_invalido}") print(f" Costo real : ${_costo_total:.4f}") print(f" Output : {output}") print(f"{'═'*65}") # Actualizar indice.json automáticamente _registrar_en_indice(output, total_archivo) print(f"\n ✅ Listo. Para entrenar con estos datos:") print(f" & 'C:\\Users\\lucas\\AppData\\Local\\Programs\\Python\\Python313\\python.exe'") print(f" scripts/train_v3.py --checkpoint checkpoints/v3_train.pt --lr 2e-5") def _registrar_en_indice(output: Path, n_ejemplos: int) -> None: """Añade o actualiza la entrada en biblioteca/indice.json.""" indice_path = Path("biblioteca/indice.json") if not indice_path.exists(): return with open(indice_path, encoding="utf-8") as f: indice = json.load(f) nombre = "destilado_qwen" ruta_relativa = str(output.relative_to(Path("biblioteca"))).replace("\\", "/") # Buscar si ya existe existe = False for entrada in indice: if entrada.get("nombre") == nombre: entrada["n_ejemplos"] = n_ejemplos existe = True break if not existe: indice.append({ "nombre": nombre, "categoria": "destilacion", "nivel": 3, "archivo": ruta_relativa, "n_ejemplos": n_ejemplos, "fuente": "qwen2.5-coder-32b via openrouter", }) print(f"\n 📝 Añadido '{nombre}' a biblioteca/indice.json") with open(indice_path, "w", encoding="utf-8") as f: json.dump(indice, f, ensure_ascii=False, indent=2) if __name__ == "__main__": main()