JARVIS/entrena/dataset.py
sito 8e4bc8ad94 JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
2026-08-16 15:34:37 +02:00

171 lines
6.7 KiB
Python
Executable file

#!/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())