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

26
nucleo/cerebro/Modelfile Normal file
View file

@ -0,0 +1,26 @@
# El modelo de JARVIS: qwen3.5 4B con parametros de asistente CONCISO.
#
# La build de qwen3.5 viene con temperature 1 y presence_penalty 1.5. Eso
# esta pensado para escritura creativa, no para un asistente de voz: el
# presence_penalty alto empuja al modelo a meter temas nuevos que no vienen a
# cuento —de ahi que acabara las frases soltando cuanta RAM queda— y la
# temperature alta lo hace divagar en vez de contestar y callar.
#
# Reconstruir: ollama create qwen3.5:4b-jarvis -f nucleo/cerebro/Modelfile
FROM qwen3.5:4b
# Bajo y estable: para "cuanto espacio queda" no hay creatividad que aportar,
# hay una cifra que dar.
PARAMETER temperature 0.4
# El que causaba el relleno. 0 = no penaliza repetir, asi que no siente la
# necesidad de introducir temas nuevos para variar.
PARAMETER presence_penalty 0.0
PARAMETER frequency_penalty 0.0
PARAMETER top_p 0.9
PARAMETER top_k 20
# Que pare cuando acabe la respuesta, no cuando se le acabe la cuerda.
PARAMETER num_predict 300
# 24 de 32 capas en la grafica: deja sitio a whisper en los 4 GB.
PARAMETER num_gpu 24
PARAMETER num_ctx 4096

View file

316
nucleo/cerebro/ollama.py Normal file
View file

@ -0,0 +1,316 @@
"""Pensar: hablar con ollama y dejarle usar las manos.
Newelle dedica 578 lineas a esto porque soporta quince motores distintos y
tiene que traducir el formato de herramientas de cada uno. Nosotros hablamos
solo con ollama, que trae tool calling nativo en `/api/chat`.
## Dos herramientas, no una
La primera version tenia una sola "ejecuta una orden" y salio mal de una
forma instructiva. Medido con tres modelos:
4B "abre una terminal" -> ejecuta_orden("xterm -e bash")
9B "cuanto espacio queda" -> ejecuta_orden("df -h / | grep ...")
9B "abre una terminal" -> ejecuta_orden("open -a Terminal") (¡macOS!)
Los modelos son serviciales: si les dejas un hueco donde cabe un comando, lo
escriben. Y el 9B, que es mas listo, lo hacia MAS escribia pipelines enteras y
hasta ordenes de otro sistema operativo.
La solucion no es rogarle al modelo en el prompt, es que el hueco no quepa. Se
parten en dos herramientas con fronteras claras:
`orden_del_catalogo` recibe castellano, se lo come el emparejador. La
descripcion lleva ejemplos REALES sacados del catalogo,
no inventados, y dice explicitamente que no acepta
comandos.
`comando_de_shell` para lo que no esta en el catalogo. Aqui SI se espera
un comando, asi que el modelo tiene donde poner lo que
queria poner, y deja de meterlo donde no toca.
## Por que el catalogo va antes que esto
Las 150 acciones se emparejan ANTES de llamar a ningun modelo. Esto solo se
usa para lo que no encaja alrededor del 15 % y para conversar. Por eso un
cerebro lento duele menos de lo que parece: la mayoria de las ordenes ni pasan
por aqui.
"""
import json
import os
import urllib.error
import urllib.request
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis")
# Cuantas vueltas de "llama a una herramienta -> toma el resultado" se permiten
# antes de cortar. Sin tope, un modelo que se atasca pidiendo lo mismo deja al
# usuario esperando para siempre.
VUELTAS = 4
PERSONA = """Eres JARVIS, el asistente de voz de esta maquina.
REGLA QUE NO SE ROMPE NUNCA: no des ni un solo dato sobre este ordenador sin
haberlo comprobado antes con una herramienta. Ni el espacio del disco, ni la
memoria, ni cuantos ficheros hay, ni si algo esta abierto, ni la hora. Aunque
creas saberlo. Aunque parezca obvio. Si no lo has comprobado en esta misma
conversacion, lo compruebas.
Inventarse una cifra que suena razonable es el peor fallo que puedes cometer,
porque el usuario no tiene forma de saber que te la has inventado.
Y AL REVES: contesta SOLO lo que te preguntan. No añadas al final de tus
respuestas datos del sistema que nadie ha pedido cuanta RAM queda, el espacio
del disco, la hora. Si te preguntan como estas, "bien, señor" y punto; no lleva
un informe de memoria detras.
Para hacer algo, el orden es:
1. orden_del_catalogo, siempre que lo que te piden se parezca a algo de la
lista. Es lo que JARVIS sabe hacer bien y esta probado.
2. comando_de_shell solo si de verdad no esta en el catalogo.
Si te preguntan COMO se hace algo tecnico una tecnica de pentesting, un flag,
una herramienta y no lo tienes claro o no aparece en la documentacion de abajo,
usa buscar_en_apuntes con palabras clave ANTES de contestar. Ahi estan los
comandos que el usuario ya ha probado; es mejor buscarlos que inventarlos. Si la
primera busqueda no da, reformula y prueba otra vez.
Tu respuesta se convierte en AUDIO y se escucha en voz alta:
- Castellano siempre. Trata al usuario de usted y llamale "señor" en la primera
frase.
- Una o dos frases. Nada mas, salvo que te pidan detalle.
- Texto plano: ni asteriscos, ni vinetas, ni bloques de codigo, ni rutas largas.
- Los numeros redondeados y en palabras: "quedan setecientos gigas".
- Si algo falla, di en una frase QUE fallo. Sin disculpas de tres lineas.
Tienes acceso real a esta maquina y no estas en ningun contenedor. No digas
nunca que no puedes. En concreto, con comando_de_shell puedes:
- Ejecutar cualquier herramienta instalada, incluidas las de COFRE. Si no
recuerdas como se usa una, en la documentacion del usuario de abajo suele
estar el comando exacto.
- Conectarte a sus servidores: "ssh <host> '<comando>'" corre algo en el
servidor. Sus hosts estan en su configuracion; abajo veras cual encaja.
- Tareas de administrador, SOLO si el candado esta abierto. Si algo lo necesita
y no lo esta, dilo: que desbloquee y lo repites.
Antes de nada que borre datos o cambie el sistema, di lo que vas a hacer."""
def _ejemplos(catalogo, cuantos=8) -> str:
"""Ordenes de verdad del catalogo, repartidas entre grupos.
Van en la descripcion de la herramienta porque es lo unico que consigue que
el modelo pase castellano en vez de inventarse un comando. Salen del
catalogo y no escritas a mano para que no se queden viejas.
"""
por_grupo = {}
for a in catalogo.acciones:
if a.frases and not a.captura:
por_grupo.setdefault(a.grupo or "otras", []).append(a.frases[0])
muestra = []
grupos = list(por_grupo.values())
for i in range(cuantos):
for g in grupos:
if i < len(g) and len(muestra) < cuantos:
muestra.append(g[i])
return ", ".join(f'"{m}"' for m in muestra)
def herramientas(catalogo) -> list:
return [
{
"type": "function",
"function": {
"name": "orden_del_catalogo",
"description": (
"Ejecuta una de las ordenes que JARVIS ya sabe hacer. "
"Recibe la orden EN CASTELLANO, tal y como la diria una "
"persona. NO acepta comandos de shell: si escribes 'df -h' "
"o 'xterm' falla. Ejemplos validos: "
+ _ejemplos(catalogo) + ". "
"Usala siempre que puedas antes que el shell."
),
"parameters": {
"type": "object",
"properties": {
"orden": {
"type": "string",
"description": ("la orden en castellano, en palabras, "
"nunca un comando"),
}
},
"required": ["orden"],
},
},
},
{
"type": "function",
"function": {
"name": "buscar_en_apuntes",
"description": (
"Busca en los apuntes de pentesting y Linux del usuario (su "
"COFRE) el comando o la explicacion que necesites. Usala "
"cuando te pregunten COMO se hace algo tecnico —una tecnica, "
"un flag, una herramienta— y no lo tengas ya delante en la "
"documentacion. Puedes reformular la consulta con tus propias "
"palabras clave y buscar varias veces hasta dar con ello. "
"Devuelve los fragmentos mas parecidos, con su fuente, para "
"que respondas desde ahi y no de memoria."
),
"parameters": {
"type": "object",
"properties": {
"consulta": {
"type": "string",
"description": ("que buscar, en pocas palabras clave; "
"el nombre de la herramienta ayuda"),
}
},
"required": ["consulta"],
},
},
},
{
"type": "function",
"function": {
"name": "comando_de_shell",
"description": (
"Ejecuta un comando en la terminal de esta maquina, que es "
"Debian con GNOME. Solo para lo que NO esta en el catalogo. "
"Es de solo lectura: no ejecutes nada que borre o modifique "
"sin que te lo pidan expresamente."
),
"parameters": {
"type": "object",
"properties": {
"comando": {"type": "string",
"description": "el comando, para bash"},
},
"required": ["comando"],
},
},
},
]
class Cerebro:
def __init__(self, catalogo, modelo=MODELO, endpoint=ENDPOINT, en_ram=False,
manos=None):
self.catalogo = catalogo
self.modelo = modelo
self.endpoint = endpoint
# num_gpu 0 fuerza CPU: el modelo entero en RAM. Util para probar uno
# mas grande del que cabe en la grafica, a costa de velocidad.
self.capas = 0 if en_ram else None
self.manos = manos # a quien pedirle que ejecute
self.historia = []
def _saber(self, texto: str) -> str:
"""Los apuntes de COFRE que vengan a cuento, para meterlos al prompt.
Es la mitad "aumentar" de RAG: antes de contestar, se le pone delante al
modelo el trozo de los apuntes del usuario que mas se parece a lo que
pregunta. Asi construye la respuesta desde SU documentacion flags
reales, comandos que ha probado en vez de desde lo que recuerde, que
para 668 herramientas seria inventarselo.
Si no hay indice o nada encaja lo bastante, devuelve cadena vacia y el
prompt se queda como estaba: la funcion no puede empeorar una respuesta,
solo mejorarla.
"""
try:
from saber.busca import contexto, disponible
except Exception:
return ""
if not disponible():
return ""
trozos = contexto(texto, cuantos=4, minimo=0.38)
if not trozos:
return ""
return ("\n\n## Documentacion del usuario relevante a esto\n"
"Usa ESTO para responder, son sus apuntes probados. Si el "
"comando exacto esta aqui, dalo tal cual; no te inventes flags "
"que no aparezcan.\n\n" + trozos)
def _pide(self, mensajes, tools=None) -> dict:
cuerpo = {"model": self.modelo, "messages": mensajes, "stream": False,
"think": False, "options": {"temperature": 0.3}}
if tools:
cuerpo["tools"] = tools
if self.capas is not None:
cuerpo["options"]["num_gpu"] = self.capas
req = urllib.request.Request(
self.endpoint + "/api/chat", data=json.dumps(cuerpo).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
return json.loads(r.read())
def responde(self, texto: str, al_ejecutar=None) -> str:
"""Contesta, usando las manos si hace falta. Devuelve lo que hay que decir."""
self.historia.append({"role": "user", "content": texto})
sistema = PERSONA + self._saber(texto)
mensajes = [{"role": "system", "content": sistema}] + self.historia[-8:]
tools = herramientas(self.catalogo)
for _ in range(VUELTAS):
try:
d = self._pide(mensajes, tools)
except (urllib.error.URLError, OSError) as e:
return f"No he podido pensar, señor: {str(e)[:50]}"
m = d.get("message", {})
llamadas = m.get("tool_calls") or []
if not llamadas:
dicho = (m.get("content") or "").strip()
self.historia.append({"role": "assistant", "content": dicho})
return dicho
mensajes.append(m)
for lc in llamadas:
f = lc.get("function", {})
args = f.get("arguments") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
resultado = self._usa(f.get("name", ""), args, al_ejecutar)
mensajes.append({"role": "tool", "content": resultado[:2000]})
return "Me he liado dando vueltas, señor. Pruebe a pedirmelo de otra forma."
def _usa(self, nombre: str, args: dict, al_ejecutar=None) -> str:
# buscar_en_apuntes NO pasa por las manos: es lectura del indice, no
# toca la maquina, asi que funciona aunque el candado este echado.
if nombre == "buscar_en_apuntes":
return self._busca_apuntes(args.get("consulta", ""))
if self.manos is None:
return "no hay manos conectadas"
if nombre == "orden_del_catalogo":
return self.manos.por_orden(args.get("orden", ""), al_ejecutar)
if nombre == "comando_de_shell":
return self.manos.por_shell(args.get("comando", ""), al_ejecutar)
return f"no existe la herramienta {nombre}"
def _busca_apuntes(self, consulta: str) -> str:
"""El RAG como herramienta: el modelo busca cuando lo decide.
La inyeccion pasiva de _saber() usa la frase LITERAL del usuario y
dispara siempre. Esto es lo otro: el modelo reformula la consulta con
sus palabras y busca a proposito, que es lo que salva los casos donde el
usuario dice una cosa y el apunte esta escrito de otra."""
consulta = (consulta or "").strip()
if not consulta:
return "dime que buscar"
try:
from saber.busca import busca
except Exception:
return "no tengo los apuntes indexados"
trozos = [t for t in busca(consulta, cuantos=5) if t.get("sim", 0) >= 0.3]
if not trozos:
return ("no encontre nada en los apuntes sobre eso; prueba con otras "
"palabras o el nombre de la herramienta")
lineas = []
for t in trozos:
fuente = t.get("fichero") or f"{t.get('perfil')}/{t.get('herramienta')}"
lineas.append(f"[{fuente}]\n{t['texto'][:500]}")
return "\n\n".join(lineas)