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.
171 lines
6.7 KiB
Python
Executable file
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())
|