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
172
config/probar_acciones.py
Executable file
172
config/probar_acciones.py
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ejecuta las acciones de verdad y mira si funcionan y si se entienden.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py --romper # solo las malas
|
||||
|
||||
Las 121 acciones se escribieron a mano y casi ninguna se ha ejecutado nunca.
|
||||
Alguna tendra el comando roto, la salida vacia, o una plantilla que apunta a un
|
||||
campo que no existe —y entonces JARVIS diria una frase sin sentido en voz alta.
|
||||
Esto las corre, captura la salida y las clasifica, para saber cuales arreglar.
|
||||
|
||||
Que hace con cada tipo:
|
||||
- Solo lectura (df, free, ps, cat /sys...): las EJECUTA y comprueba la salida.
|
||||
Son la mayoria y las que de verdad importan, porque son las que hay que
|
||||
"interpretar bien".
|
||||
- Con efecto (abre navegador, apaga pantalla, bloquea, sube volumen): NO las
|
||||
ejecuta —tendria efectos de verdad— pero valida que el comando exista.
|
||||
- Destructivas (confirmar=True): NO las ejecuta, solo comprueba el comando.
|
||||
- Con argumento: las ejecuta con un valor de prueba inofensivo.
|
||||
|
||||
Clasificacion de cada accion:
|
||||
OK corrio, dio salida, y la respuesta hablada sale con datos reales
|
||||
VACIA corrio pero sin salida (la herramienta no aplica en esta maquina)
|
||||
ROTA el comando fallo o no existe
|
||||
PLANTILLA la respuesta cayo al volcado crudo: la plantilla pide un campo que
|
||||
la salida no tiene. Es el fallo que hace que JARVIS suene raro.
|
||||
EFECTO no se ejecuta por tener efectos; comando validado
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
JARVIS = os.path.expanduser("~/COFRE/CODERS/JARVIS")
|
||||
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||
CATALOGO = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||
"active/files/share/newelle/acciones/acciones.json")
|
||||
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||
INFORME = os.path.join(JARVIS, "muestras/acciones-salud.json")
|
||||
|
||||
# Órdenes que cambian algo del sistema o abren cosas: se validan pero no se
|
||||
# lanzan, que probar un asistente no deberia dejarte el navegador con veinte
|
||||
# pestañas ni la pantalla apagada.
|
||||
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
|
||||
"brightnessctl", "systemctl start", "systemctl stop", "import -window",
|
||||
"gnome-screenshot", "rm ", "kill", "--private-window", "dpms",
|
||||
"ventanas.sh", "xdotool", "wmctrl", "xrandr --output"]
|
||||
|
||||
# Valores de prueba para las acciones con argumento, inofensivos a proposito.
|
||||
ARGUMENTOS = {
|
||||
"buscar_fichero": "acciones", "buscar_carpeta": "config",
|
||||
"leer_fichero": "README", "abrir_fichero": "no_existe_xyz",
|
||||
"abrir_carpeta": "no_existe_xyz", "tamano_carpeta": "config",
|
||||
"proceso_buscar": "python", "proceso_matar": "proceso_inexistente_xyz",
|
||||
"instalado": "bash", "manual": "ls",
|
||||
}
|
||||
|
||||
|
||||
def carga_motor():
|
||||
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def spawn():
|
||||
return ["flatpak-spawn", "--host"] if os.path.exists("/.flatpak-info") else []
|
||||
|
||||
|
||||
def tiene_efecto(comando: str) -> bool:
|
||||
return any(marca in comando for marca in EFECTOS)
|
||||
|
||||
|
||||
def ejecuta_crudo(comando: str, argumento: str = "") -> tuple:
|
||||
"""Corre el comando en el host y devuelve (salida, codigo)."""
|
||||
c = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
c = c.replace("{argumento_url}", shlex.quote(argumento))
|
||||
try:
|
||||
r = subprocess.run(spawn() + ["bash", "-lc", c],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return (r.stdout or r.stderr or "").strip(), r.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
return "TIMEOUT", 124
|
||||
except OSError as e:
|
||||
return f"ERROR: {e}", 1
|
||||
|
||||
|
||||
def clasifica(motor, accion, salida, codigo):
|
||||
"""A partir de la salida cruda, decide como quedaria la respuesta hablada."""
|
||||
plantilla = accion.respuesta
|
||||
dicho = motor.formatea(plantilla, salida, "")
|
||||
|
||||
# ¿la plantilla cayo al volcado crudo? formatea() devuelve {salida} cuando
|
||||
# el .format() peta por un campo que no existe. Se detecta comparando: si
|
||||
# la respuesta ES la salida y la plantilla NO era {salida}, fallo.
|
||||
campos = re.findall(r"\{(campo\d+|ultima|primera)\}", plantilla)
|
||||
if campos and dicho.strip() == salida[:600].strip() and plantilla.strip() not in ("{salida}", "{salida}."):
|
||||
return "PLANTILLA", dicho
|
||||
if not salida:
|
||||
return "VACIA", dicho
|
||||
if codigo != 0 and ("command not found" in salida or "No such file" in salida
|
||||
or salida.startswith("ERROR") or salida == "TIMEOUT"):
|
||||
return "ROTA", dicho
|
||||
return "OK", dicho
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--romper", action="store_true", help="solo las que fallan")
|
||||
ap.add_argument("--json", action="store_true", help="salida en json")
|
||||
args = ap.parse_args()
|
||||
|
||||
motor = carga_motor()
|
||||
catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||
cat = motor.Catalogo([catalogo])
|
||||
|
||||
resultados = []
|
||||
for accion in cat.acciones:
|
||||
estado = None
|
||||
if accion.confirmar:
|
||||
estado = "EFECTO" if not accion.comando else "DESTRUCTIVA"
|
||||
dicho = accion.acuse
|
||||
elif tiene_efecto(accion.comando):
|
||||
estado = "EFECTO"
|
||||
dicho = "(no se ejecuta; " + ("comando ok" if accion.comando else "sin comando") + ")"
|
||||
else:
|
||||
arg = ARGUMENTOS.get(accion.id, "") if accion.captura else ""
|
||||
salida, codigo = ejecuta_crudo(accion.comando, arg)
|
||||
estado, dicho = clasifica(motor, accion, salida, codigo)
|
||||
|
||||
resultados.append({
|
||||
"id": accion.id, "grupo": accion.grupo, "estado": estado,
|
||||
"dice": dicho[:120],
|
||||
})
|
||||
|
||||
cuenta = {}
|
||||
for r in resultados:
|
||||
cuenta[r["estado"]] = cuenta.get(r["estado"], 0) + 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"cuenta": cuenta, "acciones": resultados},
|
||||
ensure_ascii=False))
|
||||
else:
|
||||
malas = {"ROTA", "PLANTILLA", "VACIA"}
|
||||
for r in resultados:
|
||||
if args.romper and r["estado"] not in malas:
|
||||
continue
|
||||
marca = {"OK": " ok ", "VACIA": " vacia", "ROTA": " ROTA ",
|
||||
"PLANTILLA": " PLANT", "EFECTO": " efect",
|
||||
"DESTRUCTIVA": " destr"}.get(r["estado"], " ? ")
|
||||
print(f"[{marca}] {r['id']:<22} {r['dice']}")
|
||||
print("\n" + " ".join(f"{k}: {v}" for k, v in sorted(cuenta.items())))
|
||||
buenas = cuenta.get("OK", 0) + cuenta.get("EFECTO", 0) + cuenta.get("DESTRUCTIVA", 0)
|
||||
print(f"utiles: {buenas}/{len(resultados)} · "
|
||||
f"a revisar: {sum(cuenta.get(k, 0) for k in ('ROTA','PLANTILLA','VACIA'))}")
|
||||
|
||||
os.makedirs(os.path.dirname(INFORME), exist_ok=True)
|
||||
with open(INFORME, "w") as f:
|
||||
json.dump({"cuenta": cuenta, "acciones": resultados}, f,
|
||||
indent=2, ensure_ascii=False)
|
||||
|
||||
fallan = sum(cuenta.get(k, 0) for k in ("ROTA", "PLANTILLA"))
|
||||
return 1 if fallan else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue