#!/usr/bin/env python3 """Genera el dataset de fine-tuning a partir de los apuntes de COFRE. ./dataset.py --muestra 5 enseña 5 ejemplos y para (para mirar la calidad) ./dataset.py genera el dataset entero (horas, en segundo plano) ## Por que esto es el paso que faltaba Fine-tuning no es magia: es enseñarle al modelo con EJEMPLOS de como quieres que conteste. Para eso hacen falta pares pregunta-respuesta, y no los teniamos. Los apuntes de COFRE son conocimiento, pero en prosa; hay que convertirlos en la forma "alguien pregunta X, JARVIS responde Y". Se generan con el modelo local a partir de cada fragmento de los apuntes: se le da el trozo y se le pide que invente las preguntas que ese trozo contesta, y la respuesta al estilo de JARVIS. El fragmento es la VERDAD —lo escribio el usuario— asi que las respuestas salen de ahi y no de lo que el modelo recuerde. ## Por que el dataset vale aunque no se entrene aqui La T1200 de 4 GB va justa para entrenar un 4B. El sitio natural es el servidor con la 3060 de 12 GB. Pero el DATASET es lo caro de conseguir y no depende de la maquina: se genera una vez, es un fichero, y se entrena donde haga falta —aqui si cabe, o en el servidor cuando haya acceso—. ## El formato JSONL en el formato de mensajes, que es lo que comen trl/axolotl/unsloth: {"messages": [{"role":"user","content":"..."}, {"role":"assistant","content":"..."}]} """ import argparse import json import os import re import sys import urllib.request AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) INDICE = os.path.join(AQUI, "nucleo", "datos", "saber.jsonl") SALIDA = os.path.join(AQUI, "entrena", "dataset-cofre.jsonl") ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434") MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis") # Al generador se le da MAS libertad que a JARVIS en produccion: aqui quereis # variedad de preguntas, no una respuesta conservadora. GENERADOR = """Eres un generador de datos de entrenamiento para un asistente de pentesting llamado JARVIS. Te doy un fragmento de los apuntes tecnicos del usuario. Devuelve de UNA a TRES preguntas que una persona haria y que ese fragmento contesta, cada una con su respuesta. Reglas: - Las preguntas, naturales y variadas: como las diria alguien por voz. - Las respuestas SALEN DEL FRAGMENTO. Si el fragmento tiene un comando exacto, la respuesta lo lleva tal cual. No te inventes flags que no aparezcan. - Respuestas al estilo de JARVIS: directas, en castellano, tratando de "señor". - Si el fragmento no da para una pregunta util (es un titulo, una lista de enlaces, ruido), devuelve una lista vacia. Responde SOLO con JSON, un objeto con la clave "pares": {"pares": [{"pregunta": "...", "respuesta": "..."}]}""" def _pide(fragmento: str) -> list: cuerpo = json.dumps({ "model": MODELO, "messages": [{"role": "system", "content": GENERADOR}, {"role": "user", "content": fragmento[:1500]}], "stream": False, "think": False, "options": {"temperature": 0.7, "num_predict": 500}, "format": "json", }).encode() req = urllib.request.Request(ENDPOINT + "/api/chat", data=cuerpo, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=120) as r: contenido = json.loads(r.read())["message"]["content"] except Exception: return [] # el modelo a veces envuelve el array en un objeto {"pares": [...]} try: d = json.loads(contenido) except json.JSONDecodeError: m = re.search(r"\[.*\]", contenido, re.S) if not m: return [] try: d = json.loads(m.group(0)) except json.JSONDecodeError: return [] if isinstance(d, dict): d = next((v for v in d.values() if isinstance(v, list)), []) salida = [] for par in d if isinstance(d, list) else []: if isinstance(par, dict) and par.get("pregunta") and par.get("respuesta"): salida.append((par["pregunta"].strip(), par["respuesta"].strip())) return salida def apuntes(): """Los fragmentos de apuntes del indice, los mas sustanciosos primero.""" fuera = [] with open(INDICE, encoding="utf-8") as f: for l in f: try: d = json.loads(l) except json.JSONDecodeError: continue if d.get("tipo") == "apunte" and len(d["texto"].split()) >= 20: d.pop("v", None) fuera.append(d) return fuera def main() -> int: p = argparse.ArgumentParser() p.add_argument("--muestra", type=int, default=0, help="genera solo N y los enseña, sin guardar") a = p.parse_args() if not os.path.exists(INDICE): print(" no hay indice: nucleo/saber/indexa.py primero") return 1 fragmentos = apuntes() print(f" {len(fragmentos)} fragmentos con sustancia", flush=True) if a.muestra: for frag in fragmentos[:a.muestra]: pares = _pide(frag["texto"]) print(f"\n ── {frag.get('fichero', frag['perfil'])} ──") for q, r in pares: print(f" P: {q}") print(f" R: {r[:120]}") return 0 # Reanudable: un trabajo de 10 h no puede perder todo si se corta. Se apunta # en un .hecho por que fragmento se iba, y al arrancar se salta hasta ahi y # se AÑADE al dataset en vez de reescribirlo. marca = SALIDA + ".hecho" desde = 0 if os.path.exists(marca) and os.path.exists(SALIDA): try: desde = int(open(marca).read().strip()) + 1 except (ValueError, OSError): desde = 0 modo = "a" if desde else "w" hechos = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0 if desde: print(f" reanudando desde el fragmento {desde} ({hechos} ejemplos hechos)", flush=True) with open(SALIDA, modo, encoding="utf-8", buffering=1) as out: for i in range(desde, len(fragmentos)): for q, r in _pide(fragmentos[i]["texto"]): out.write(json.dumps({"messages": [ {"role": "user", "content": q}, {"role": "assistant", "content": r}, ]}, ensure_ascii=False) + "\n") hechos += 1 with open(marca, "w") as m: m.write(str(i)) if (i + 1) % 50 == 0: print(f" {i+1}/{len(fragmentos)} fragmentos, {hechos} ejemplos", flush=True) print(f"\n dataset guardado: {SALIDA} ({hechos} ejemplos)") return 0 if __name__ == "__main__": sys.exit(main())