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:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
26
nucleo/cerebro/Modelfile
Normal file
26
nucleo/cerebro/Modelfile
Normal 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
|
||||
0
nucleo/cerebro/__init__.py
Normal file
0
nucleo/cerebro/__init__.py
Normal file
316
nucleo/cerebro/ollama.py
Normal file
316
nucleo/cerebro/ollama.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue