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.
This commit is contained in:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

61
entrena/README.md Normal file
View file

@ -0,0 +1,61 @@
# Entrenamiento de JARVIS
El fine-tuning NO sustituye al RAG (nucleo/saber/), lo complementa. El RAG ya
funciona y da el conocimiento fresco; esto hornea el estilo y los flujos en los
pesos. Por eso fue: primero RAG, esto despues.
## Los tres pasos
1. dataset.py convierte los apuntes de COFRE en pares pregunta-respuesta
(usa el indice de nucleo/saber/, ~10 h con el 4B local)
2. afina.py QLoRA sobre el dataset. --revisa dice si la maquina puede
3. exportar el adaptador a GGUF + Modelfile con ADAPTER, para ollama
## Por que falla hoy: qwen3.5 es MULTIMODAL (16 ago)
Las pruebas de `afina.py` cargan el modelo bien y se caen justo despues, al
enganchar los adaptadores, con un mensaje que no viene a cuento:
Incorrect image source. Must be a valid URL starting with `http://`
No es un fallo de red ni de rutas. **`Qwen/Qwen3.5-2B` es `image-text-to-text`**
—comprobado en la API de HuggingFace, y el propio GGUF lo delata: el
`model_info` de ollama trae `qwen35.vision.block_count = 24`—. O sea que el
modelo lleva torre de vision, y `FastLanguageModel` lo mete por el camino de
solo texto mientras transformers intenta procesar una imagen que no existe.
La pista estaba en el log y se leyo como ruido:
Unsloth: Explicit target_modules are constrained by the
finetune_(vision|language|attention|mlp) filters
Ese aviso solo lo emite el camino multimodal.
Por donde va el arreglo: `FastVisionModel` con `finetune_vision_layers=False`,
que es la via de unsloth para afinar solo la parte de lenguaje de un modelo con
vision. **Sin probar todavia.**
De paso, los nombres que no existen en HuggingFace y que las pruebas fueron
descartando: `Qwen3.5-3B`, `Qwen3.5-3B-Instruct`, `Qwen3.5-1.7B`,
`Qwen3.5-1.7B-Instruct`, `Qwen3.5-1.5B`. El unico que carga es `Qwen3.5-2B`
(1,95 GB).
## Donde entrenar
T1200 (4 GB) al limite. Turing, sin bf16. Posible pero fragil.
3060 (12 GB) holgado. El sitio natural. El dataset viaja como fichero.
## Para entrenar
pip install unsloth # trae peft, trl, bitsandbytes
./afina.py --revisa # comprueba
./afina.py # entrena
## Exportar a ollama (tras entrenar)
# con llama.cpp:
python convert_lora_to_gguf.py jarvis-lora --outfile jarvis-lora.gguf
# Modelfile:
# FROM qwen3.5:4b
# ADAPTER ./jarvis-lora.gguf
ollama create qwen3.5:4b-jarvis-cofre -f Modelfile

236
entrena/afina.py Executable file
View file

@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Fine-tuning de JARVIS con QLoRA sobre el dataset de COFRE.
./afina.py --revisa comprueba que se puede entrenar aqui, sin entrenar
./afina.py entrena (largo; mejor en el servidor con la 3060)
## Que hace y que NO hace el fine-tuning
Le enseña al modelo el ESTILO y las respuestas de tus apuntes con ejemplos. NO
sustituye al RAG: el RAG le da los datos frescos en el momento y se actualiza
soltando un fichero, mientras que esto los hornea en los pesos. Se complementan:
el fine-tuning hace que "suene" a JARVIS y conozca tus flujos sin buscar; el RAG
cubre lo que cambia y lo que es muy especifico.
Por eso el orden fue: primero RAG (hecho, funciona hoy), y esto despues.
## QLoRA en cristiano
Entrenar un 4B entero pide mas memoria de la que hay. QLoRA hace dos trucos:
- carga el modelo base en 4 bits (cabe en poca VRAM, y se queda CONGELADO)
- entrena solo unas matrices pequeñas nuevas (los "adaptadores LoRA"), que son
una fraccion del tamaño
Asi se entrena un 4B en pocos GB. El resultado es un adaptador de ~50 MB que se
le pone encima al modelo base.
## Donde entrenar
T1200 (4 GB, esta maquina) al limite. Turing (capacidad 7.5): sin bf16 ni
flash-attention. Posible con lote 1 y secuencia
corta, lento y fragil. --revisa lo dice.
3060 (12 GB, el servidor) holgado. Ampere: bf16 y flash-attention. Es el
sitio natural, por eso el dataset se genera aparte
y viaja como fichero.
## Dependencias
pip install unsloth (trae peft, trl, bitsandbytes, accelerate)
unsloth porque es lo que mejor exprime una GPU pequeña: mismo resultado con la
mitad de VRAM y el doble de velocidad que peft+trl a pelo.
"""
import argparse
import json
import os
import sys
AQUI = os.path.dirname(os.path.abspath(__file__))
DATASET = os.path.join(AQUI, "dataset-cofre.jsonl")
SALIDA = os.path.join(AQUI, "jarvis-lora")
# El modelo base, en formato HuggingFace. ollama corre GGUF; para entrenar hace
# falta el modelo de verdad. Se prueban candidatos en orden hasta que uno
# cargue: la version pre-cuantizada de unsloth va primero porque baja menos y
# ocupa menos VRAM, que en 4 GB es lo que decide si entra.
#
# El de ollama es qwen3.5:4b (arquitectura qwen35, 4,7B, embedding 2560). Si
# ninguno carga, la fase 0 lo dice y no se pierden horas.
CANDIDATOS = [
os.environ.get("JARVIS_BASE_HF", ""), # lo que ponga el usuario, si pone
"unsloth/Qwen3.5-4B-Instruct-bnb-4bit",
"Qwen/Qwen3.5-4B-Instruct",
"unsloth/Qwen3.5-4B-bnb-4bit",
"Qwen/Qwen3.5-4B",
]
# Apretado para 4 GB (ver el plan). Con el 4,7B en 4 bits ocupando ~2,8 GB,
# cada megabyte de mas en secuencia o rango es el que provoca el OOM.
EPOCAS = 2
LOTE = 1
ACUMULA = 16 # lote efectivo 16 sin gastar VRAM
LR = 2e-4
MAX_SEQ = 512 # los pares Q&A son cortos; 512 no pierde casi nada
RANGO = 8 # adaptadores pequeños
def _base_que_carga():
"""El primer candidato que exista y cargue. None si ninguno."""
from unsloth import FastLanguageModel
for repo in CANDIDATOS:
if not repo:
continue
try:
print(f" probando {repo} ...", flush=True)
modelo, tok = FastLanguageModel.from_pretrained(
model_name=repo, max_seq_length=MAX_SEQ, dtype=None,
load_in_4bit=True)
print(f" ✓ carga: {repo}")
return repo, modelo, tok
except Exception as e:
print(f" no: {str(e).splitlines()[0][:80]}")
return None, None, None
def revisa() -> int:
"""El ensayo de la fase 0: carga el 4,7B y hace UN paso de entrenamiento.
Es el momento de la verdad. Si ese paso pasa sin quedarse sin memoria, el
entrenamiento entero es cuestion de tiempo. Si peta, se sabe en diez minutos
y no en la sexta hora.
"""
print("\n ── se puede entrenar aqui? ──\n")
n = 0
if os.path.exists(DATASET):
with open(DATASET) as f:
n = sum(1 for _ in f)
print(f" dataset: {n} ejemplos"
+ (" (suficiente para el test; poco para entrenar)" if n < 500 else ""))
try:
import torch
except ImportError:
print(" torch: NO — el venv de entrenamiento no esta montado")
return 1
if not torch.cuda.is_available():
print(" cuda: NO")
return 1
p = torch.cuda.get_device_properties(0)
libre = torch.cuda.mem_get_info()[0] / 1e9
print(f" gpu: {p.name} {p.total_memory/1e9:.1f} GB, {libre:.1f} libres, "
f"capacidad {p.major}.{p.minor}"
+ (" (Turing: fp16, sin flash-attn)" if p.major < 8 else ""))
if libre < 3.3:
print(" AVISO: hay menos de 3,3 GB libres. Cierra JARVIS y ollama antes.")
for m in ("unsloth", "peft", "trl", "bitsandbytes"):
try:
__import__(m)
except ImportError:
print(f" {m}: NO -> el venv de entrenamiento no acabo de instalar")
return 1
# El momento de la verdad: cargar + un paso real.
print("\n ── cargando el modelo y haciendo un paso de prueba ──")
from unsloth import FastLanguageModel
repo, modelo, tok = _base_que_carga()
if repo is None:
print("\n NINGUN candidato cargo. O no existe el repo HF del qwen3.5:4b,")
print(" o esta arquitectura (qwen35) no la soporta este transformers.")
print(" Pon el repo correcto en JARVIS_BASE_HF y reintenta.")
return 1
tras_modelo = torch.cuda.memory_allocated() / 1e9
print(f" VRAM tras cargar el modelo: {tras_modelo:.2f} GB")
modelo = FastLanguageModel.get_peft_model(
modelo, r=RANGO, lora_alpha=RANGO, lora_dropout=0,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
use_gradient_checkpointing="unsloth", random_state=42)
# un lote de una frase, forward + backward, que es donde revienta si revienta
try:
import torch as T
texto = tok.apply_chat_template(
[{"role": "user", "content": "que es secretsdump"},
{"role": "assistant", "content": "Saca los hashes NTLM, señor."}],
tokenize=False)
ids = tok(texto, return_tensors="pt", max_length=MAX_SEQ,
truncation=True).input_ids.to("cuda")
salida = modelo(ids, labels=ids)
salida.loss.backward()
pico = T.cuda.max_memory_allocated() / 1e9
print(f" ✓ un paso de entrenamiento paso. Pico de VRAM: {pico:.2f} GB")
print(f"\n CABE. base a usar: {repo}")
print(" Pon ese repo en JARVIS_BASE_HF si no es el primero, y ./afina.py\n")
return 0
except T.cuda.OutOfMemoryError:
print("\n OOM: el 4,7B no entra en esta grafica ni apretando.")
print(" Plan B: bajar MAX_SEQ a 256, o el 3B, o el servidor (3060).")
return 2
def entrena() -> int:
if not os.path.exists(DATASET):
print(" no hay dataset: ./dataset.py primero")
return 1
import torch
from unsloth import FastLanguageModel
from datasets import load_dataset
from trl import SFTTrainer, SFTConfig
ampere = torch.cuda.get_device_properties(0).major >= 8
repo, modelo, tokenizer = _base_que_carga()
if repo is None:
print(" ningun base cargo; corre --revisa para el diagnostico")
return 1
modelo = FastLanguageModel.get_peft_model(
modelo, r=RANGO, lora_alpha=RANGO, lora_dropout=0,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
use_gradient_checkpointing="unsloth", random_state=42)
datos = load_dataset("json", data_files=DATASET, split="train")
def formatea(ej):
return {"text": tokenizer.apply_chat_template(
ej["messages"], tokenize=False, add_generation_prompt=False)}
datos = datos.map(formatea)
entrenador = SFTTrainer(
model=modelo, tokenizer=tokenizer, train_dataset=datos,
dataset_text_field="text", max_seq_length=MAX_SEQ,
args=SFTConfig(
per_device_train_batch_size=LOTE,
gradient_accumulation_steps=ACUMULA,
num_train_epochs=EPOCAS, learning_rate=LR,
fp16=not ampere, bf16=ampere,
logging_steps=10, optim="adamw_8bit",
warmup_ratio=0.05, lr_scheduler_type="cosine",
output_dir=os.path.join(AQUI, "checkpoints"), seed=42))
print(" entrenando...")
entrenador.train()
modelo.save_pretrained(SALIDA)
tokenizer.save_pretrained(SALIDA)
print(f"\n adaptador guardado: {SALIDA}")
print(" para usarlo con ollama hay que exportarlo a GGUF y crear un")
print(" Modelfile con ADAPTER. Ver el README de entrena/.")
return 0
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--revisa", action="store_true")
a = p.parse_args()
return revisa() if a.revisa else entrena()
if __name__ == "__main__":
sys.exit(main())

171
entrena/dataset.py Executable file
View file

@ -0,0 +1,171 @@
#!/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())