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
175
nucleo/pruebas/repasa_acciones.py
Executable file
175
nucleo/pruebas/repasa_acciones.py
Executable file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad.
|
||||
|
||||
Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del
|
||||
sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a
|
||||
false), pero las 134 acciones del catalogo nunca se habian ejecutado todas
|
||||
seguidas contra la maquina real: la suite prueba que las FRASES caen en la
|
||||
accion correcta, no que el comando de cada accion haga algo util.
|
||||
|
||||
Que se ejecuta y que no:
|
||||
|
||||
- Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan.
|
||||
En uso normal pasan por un dialogo que aprueba una persona; aqui no hay
|
||||
persona, y aprobarlas solas seria justo lo que el diseño evita.
|
||||
- Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir,
|
||||
porque llenan la pantalla. Sin esa opcion se comprueba que el programa
|
||||
existe, que es lo que puede fallar.
|
||||
- El resto se ejecuta entero y se mira que conteste algo con sentido.
|
||||
|
||||
python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RAIZ = os.path.dirname(AQUI) # la capa JARVIS, para los argumentos de prueba
|
||||
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||
|
||||
# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos:
|
||||
# nada que borre, mueva o mande nada fuera.
|
||||
ARGUMENTOS = {
|
||||
"buscar_fichero": "README.md",
|
||||
"buscar_carpeta": "config",
|
||||
"grep_recursivo": "JARVIS",
|
||||
"leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz
|
||||
"abrir_carpeta": RAIZ,
|
||||
"tamano_carpeta": os.path.join(RAIZ, "config"),
|
||||
"buscar_youtube": "musica",
|
||||
"buscar_wikipedia": "madrid",
|
||||
"buscar_github": "newelle",
|
||||
"traducir": "hola",
|
||||
"buscar_web": "que hora es",
|
||||
"matar_proceso": "proceso_que_no_existe_jarvis",
|
||||
"ping": "127.0.0.1",
|
||||
}
|
||||
POR_DEFECTO = "prueba"
|
||||
|
||||
# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario:
|
||||
# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar.
|
||||
VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|"
|
||||
r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b")
|
||||
|
||||
|
||||
def carga():
|
||||
with open(CATALOGO, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
return d.get("acciones", d if isinstance(d, list) else [])
|
||||
|
||||
|
||||
def prefijo_host():
|
||||
"""Las acciones llevan host:true y en uso salen del sandbox. Aqui ya
|
||||
estamos fuera, asi que no hace falta prefijo."""
|
||||
return []
|
||||
|
||||
|
||||
def ejecuta(accion, argumento, tope=25):
|
||||
import shlex
|
||||
import urllib.parse
|
||||
|
||||
comando = accion.get("comando") or ""
|
||||
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
comando = comando.replace("{argumento_url}",
|
||||
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
r = subprocess.run(["bash", "-lc", comando], capture_output=True,
|
||||
text=True, errors="replace", timeout=tope)
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
return r.returncode, salida, time.monotonic() - t0
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, f"(no termino en {tope}s)", time.monotonic() - t0
|
||||
except OSError as e:
|
||||
return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0
|
||||
|
||||
|
||||
def programa_existe(comando):
|
||||
"""El primer ejecutable de la orden, para las que abren o mueven ventanas.
|
||||
|
||||
Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con
|
||||
ruta completa (config/ventanas.sh): `command -v` solo sirve para los
|
||||
primeros, y con los segundos hay que mirar el fichero.
|
||||
"""
|
||||
m = VENTANA.search(comando)
|
||||
if not m:
|
||||
return None
|
||||
nombre = m.group(1)
|
||||
if nombre.endswith(".sh"):
|
||||
# sacar la ruta entera tal como aparece en el comando
|
||||
for trozo in comando.split():
|
||||
if trozo.endswith(nombre):
|
||||
ruta = os.path.expandvars(os.path.expanduser(trozo))
|
||||
return os.access(ruta, os.X_OK)
|
||||
return False
|
||||
return subprocess.run(["bash", "-lc", f"command -v {nombre}"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--abrir", action="store_true",
|
||||
help="ejecutar tambien las que abren ventanas")
|
||||
p.add_argument("--grupo")
|
||||
p.add_argument("--json")
|
||||
p.add_argument("--tope", type=int, default=25)
|
||||
args = p.parse_args()
|
||||
|
||||
acciones = carga()
|
||||
if args.grupo:
|
||||
acciones = [a for a in acciones if a.get("grupo") == args.grupo]
|
||||
|
||||
resultados = []
|
||||
print(f" repasando {len(acciones)} acciones"
|
||||
f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True)
|
||||
|
||||
for a in acciones:
|
||||
ident = a.get("id", "?")
|
||||
comando = a.get("comando") or ""
|
||||
arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "")
|
||||
|
||||
if not comando:
|
||||
estado, detalle = "solo habla", a.get("acuse", "")[:50]
|
||||
elif a.get("confirmar") or "sudo" in comando:
|
||||
# no se aprueban solas: en uso las aprueba una persona
|
||||
estado, detalle = "saltada", "destructiva o con root"
|
||||
elif VENTANA.search(comando) and not args.abrir:
|
||||
hay = programa_existe(comando)
|
||||
estado = "programa ok" if hay else "FALTA PROGRAMA"
|
||||
detalle = VENTANA.search(comando).group(1)
|
||||
else:
|
||||
codigo, salida, tardo = ejecuta(a, arg, args.tope)
|
||||
una_linea = " ".join(salida.split())[:70]
|
||||
if codigo == 0:
|
||||
estado = "ok" if salida else "ok (sin salida)"
|
||||
else:
|
||||
estado = f"FALLO ({codigo})"
|
||||
detalle = f"{tardo:4.1f}s {una_linea}"
|
||||
|
||||
resultados.append({"id": ident, "grupo": a.get("grupo"),
|
||||
"estado": estado, "detalle": detalle})
|
||||
marca = "!" if "FALLO" in estado or "FALTA" in estado else " "
|
||||
print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True)
|
||||
|
||||
print()
|
||||
from collections import Counter
|
||||
c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados)
|
||||
for k, v in c.most_common():
|
||||
print(f" {v:3d} {k}")
|
||||
malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]]
|
||||
print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien")
|
||||
if malas:
|
||||
print(" fallan: " + ", ".join(r["id"] for r in malas))
|
||||
|
||||
if args.json:
|
||||
with open(args.json, "w", encoding="utf-8") as f:
|
||||
json.dump(resultados, f, ensure_ascii=False, indent=2)
|
||||
return 1 if malas else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue