|
|
|
|
| """
|
| 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))
|
|
|
|
|
| _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())
|
|
|
|
|
|
|
|
|
|
|
| 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."
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| MODELO_PRINCIPAL = "qwen/qwen3-coder-30b-a3b-instruct"
|
|
|
|
|
| MODELOS_FALLBACK = [
|
| "qwen/qwen3-coder-30b-a3b-instruct",
|
| "qwen/qwen-2.5-coder-32b-instruct",
|
| "qwen/qwen2.5-coder-7b-instruct",
|
| ]
|
|
|
|
|
| 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
|
|
|
| _modelo_idx = 0
|
| _costo_total = 0.0
|
|
|
|
|
| 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):
|
| 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"]
|
|
|
| usage = data.get("usage", {})
|
| _sumar_costo(modelo_actual, usage.get("prompt_tokens", 400), usage.get("completion_tokens", 600))
|
| if texto:
|
| return texto
|
|
|
| _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]
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| def limpiar_codigo(texto: str) -> str:
|
| """Elimina bloques markdown ```python ... ``` si el modelo los incluye."""
|
| texto = texto.strip()
|
| if texto.startswith("```"):
|
|
|
| lineas = texto.split("\n")
|
| if lineas[0].startswith("```"):
|
| lineas = lineas[1:]
|
|
|
| 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
|
|
|
| tiene_def = any(kw in texto for kw in ("def ", "class ", "async def "))
|
| if not tiene_def:
|
| return False
|
|
|
| try:
|
| ast.parse(texto)
|
| return True
|
| except SyntaxError:
|
|
|
| return len(texto) > 100
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
| 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()
|
|
|
|
|
| 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
|
|
|
|
|
| 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)
|
|
|
|
|
| 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}")
|
|
|
|
|
| _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("\\", "/")
|
|
|
|
|
| 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()
|
|
|