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.
174 lines
6.6 KiB
Python
Executable file
174 lines
6.6 KiB
Python
Executable file
#!/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
|
|
|
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "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())
|