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

169
nucleo/pruebas/banco.py Executable file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Banco fijo para medir el emparejador sin volver a grabar.
banco.py --crear [n] graba y transcribe una vez (lento)
banco.py --mide puntua el catalogo actual (instantaneo)
banco.py --fallos enseña que se sigue fallando
## Por que hace falta
La mejora nocturna medía generando audio nuevo cada ronda: nueve horas, 39
rondas, +1,45 puntos de mejora contra ±1,9 puntos de ruido de medida. El ruido
era mayor que la mejora, asi que "¿esto mejora o empeora?" se decidia a cara o
cruz. Hoy ha vuelto a pasar: los flags de Whisper parecian dar +5 puntos con 40
muestras y con 150 no dan nada.
Aqui se graba UNA vez con semilla fija y se guardan las TRANSCRIPCIONES. A
partir de ahi, probar un cambio en el emparejador es instantaneo y determinista:
la misma entrada siempre, asi que dos medidas son comparables de verdad.
## La trampa que ya costo una conclusion equivocada
El 24 % del catalogo son acciones que piden argumento. Diciendolas sueltas
"busca el texto" el emparejador NO las dispara, y hace bien: no son una orden.
Un banco que las dice sueltas mide 56 % donde la realidad es 79 %, y hace
parecer que el emparejador esta roto cuando lo que esta roto es el banco.
Por eso `--crear` les añade argumento.
"""
import argparse
import collections
import difflib
import json
import os
import random
import subprocess
import sys
import tempfile
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from manos.acciones import Catalogo, normaliza # noqa: E402
from oido.whisper import Oido, es_ruido # noqa: E402
RAIZ = os.path.dirname(AQUI)
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
BANCO = os.path.join(AQUI, "datos", "banco.json")
# Fijas a proposito: son lo que hace el banco reproducible. Cambiarlas obliga a
# rehacerlo, y esa es justamente la señal de que ya no es comparable.
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
SEMILLA = 20260815
ARGUMENTOS = ["informes", "descargas", "musica", "el proyecto", "documentos"]
def crear(n: int) -> int:
cat = Catalogo([CATALOGO])
rnd = random.Random(SEMILLA)
conf = [a for a in cat.acciones if a.frases]
casos = []
for a in rnd.sample(conf, min(n, len(conf))):
frase = rnd.choice(a.frases)
if a.captura:
frase += " " + rnd.choice(ARGUMENTOS)
casos.append({"id": a.id, "frase": frase, "voz": rnd.choice(VOCES),
"captura": a.captura})
print(f" grabando {len(casos)} frases...", flush=True)
tmp = tempfile.mkdtemp(prefix="banco")
for i, c in enumerate(casos):
wav = os.path.join(tmp, f"{i:03d}.wav")
subprocess.run([PIPER, wav, c["frase"]],
env={**os.environ, "JARVIS_PIPER_VOZ": c["voz"],
"JARVIS_PIPER_TONO": "1.0"},
capture_output=True, timeout=90)
c["wav"] = wav if os.path.exists(wav) and os.path.getsize(wav) else None
oreja = Oido(modelo="small", beam=5)
if not oreja.arranca():
print(" whisper no arranco")
return 2
print(" transcribiendo...", flush=True)
for i, c in enumerate(casos):
c["oido"] = oreja.transcribe(c["wav"]) if c["wav"] else ""
c.pop("wav", None)
if (i + 1) % 40 == 0:
print(f" {i+1}/{len(casos)}", flush=True)
oreja.para()
subprocess.run(["rm", "-rf", tmp])
casos = [c for c in casos if c.get("oido")]
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
with open(BANCO, "w", encoding="utf-8") as f:
json.dump({"semilla": SEMILLA, "modelo": "small", "casos": casos},
f, ensure_ascii=False, indent=1)
print(f" guardado: {len(casos)} transcripciones en {BANCO}")
return 0
def carga():
try:
with open(BANCO, encoding="utf-8") as f:
return json.load(f)["casos"]
except (OSError, json.JSONDecodeError, KeyError):
return []
def mide(detalle=False) -> int:
casos = carga()
if not casos:
print(" no hay banco: crealo con --crear")
return 1
cat = Catalogo([CATALOGO])
bien = 0
oye_mal, no_relaciona, otra, ruido = [], [], [], []
for c in casos:
t = c["oido"]
if es_ruido(t):
ruido.append(c)
continue
a, _ = cat.busca(t)
if a is not None and a.id == c["id"]:
bien += 1
continue
p = difflib.SequenceMatcher(None, normaliza(t), normaliza(c["frase"])).ratio()
if p < 0.75:
oye_mal.append((c, t, p))
elif a is None:
no_relaciona.append((c, t, p))
else:
otra.append((c, t, a.id))
n = len(casos)
print(f" {n} frases · acierto {100*bien/n:.1f} %\n")
print(f" {'que pasa':28s} {'n':>4s} {'%':>7s}")
print(" " + "-" * 44)
for nombre, k in [("bien", bien), ("oye bien, NO RELACIONA", len(no_relaciona)),
("OYE MAL la frase", len(oye_mal)),
("oye bien, OTRA accion", len(otra)),
("lo tira por ruido", len(ruido))]:
print(f" {nombre:28s} {k:4d} {100*k/n:6.1f} %")
if detalle:
print("\n --- oye bien pero no relaciona ---")
for c, t, p in no_relaciona:
print(f" {c['id']:24s} dijo: {c['frase']}")
print(f" {'':24s} oyo: {t} ({p:.2f})")
print("\n --- se la lleva otra accion (lo peor: ejecuta lo que no es) ---")
for c, t, o in otra:
print(f" {c['id']:24s} -> {o} oyo: {t}")
return 0
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--crear", action="store_true")
p.add_argument("--mide", action="store_true")
p.add_argument("--fallos", action="store_true")
p.add_argument("n", nargs="?", type=int, default=150)
a = p.parse_args()
if a.crear:
return crear(a.n)
return mide(detalle=a.fallos)
if __name__ == "__main__":
sys.exit(main())

133
nucleo/pruebas/prueba_candado.py Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""El candado: que tape, que rechace lo que no es, y que NO obedezca.
./venv/bin/python pruebas/prueba_candado.py
Lo que de verdad importa aqui no es que el panel se ponga negro eso es
cosmetica sino que con el candado puesto **no se ejecute nada**. El panel tapado
pero obedeciendo seria peor que no tener candado, porque da sensacion de
seguridad sin darla.
No se prueba con la contraseña de verdad: no la tenemos ni hace falta. Se
comprueba que una mala se rechaza y que mientras tanto no pasa ni una orden.
"""
import os
import sys
import threading
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from boca.voz import Boca # noqa: E402
from cara.panel import Panel # noqa: E402
from manos import Manos # noqa: E402
fallos = []
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
class CaraFalsa:
"""Se queda con lo que el panel manda, sin levantar ningun servidor."""
def __init__(self):
self.mensajes = []
self.conectados = 1
def manda(self, tipo, datos):
self.mensajes.append((tipo, datos))
def de(self, tipo):
return [d for t, d in self.mensajes if t == tipo]
class BocaMuda(Boca):
"""Como la de verdad pero sin sonar: la prueba no tiene que hablar."""
def __init__(self):
super().__init__("davefx")
self.dicho = []
def di(self, texto):
self.dicho.append(texto); return True
def di_si_libre(self, texto):
self.dicho.append(texto); return True
def main() -> int:
manos = Manos()
cara = CaraFalsa()
boca = BocaMuda()
p = Panel(cara, boca, manos, candado=True)
print("\n al abrir")
p.arranca()
time.sleep(0.2)
afirma(not p.desbloqueado, "nace bloqueado")
afirma(bool(cara.de("bloqueo")), "pide la contraseña nada mas abrir")
print("\n con el candado puesto NO obedece")
antes = len(cara.mensajes)
for orden in ("prueba", "voz:siguiente", "parar"):
p.pulsado("accion", orden)
time.sleep(1.0)
afirma(not boca.dicho, f"no ha hablado ({boca.dicho})")
# Desde que la tanda va por tareas, lo que se ve al ejecutar son mensajes
# "tarea" (una tarjeta por orden), no "respuesta".
hechas = [d for t, d in cara.mensajes[antes:] if t in ("tarea", "respuesta")]
afirma(not hechas, f"no ha ejecutado nada ({len(hechas)} tareas o respuestas)")
# Solo se miran las respuestas a lo pulsado: la telemetria sigue latiendo
# en su hilo cada segundo y se cuela entre medias.
contestaciones = [t for t, _ in cara.mensajes[antes:] if t != "telemetria"]
afirma(contestaciones and all(t == "bloqueo" for t in contestaciones),
f"a cada intento contesta pidiendo la contraseña ({contestaciones})")
print("\n una contraseña mala se rechaza")
antes = len(cara.mensajes)
p.pulsado("accion", "desbloquear:no-es-esta-contrasena-12345")
time.sleep(0.5)
afirma(not p.desbloqueado, "sigue bloqueado")
ultimos = cara.de("bloqueo")[-1:] or [{}]
afirma(ultimos[0].get("error") is True, "y lo dice como error")
afirma(not cara.de("desbloqueado"), "no manda 'desbloqueado'")
print("\n una vacia ni se intenta")
antes = len(cara.mensajes)
p.pulsado("accion", "desbloquear:")
afirma(len(cara.mensajes) == antes, "no molesta a sudo con una clave vacia")
print("\n sin candado, obedece")
cara2, boca2 = CaraFalsa(), BocaMuda()
p2 = Panel(cara2, boca2, manos, candado=False)
p2.arranca()
afirma(p2.desbloqueado, "nace desbloqueado")
afirma(not cara2.de("bloqueo"), "no pide contraseña")
p2.pulsado("accion", "prueba")
time.sleep(3.0)
tarjetas = {d["id"] for d in cara2.de("tarea")}
afirma(bool(tarjetas), f"ejecuta de verdad ({len(tarjetas)} tarjetas)")
afirma(any(d["estado"] == "hecha" for d in cara2.de("tarea")),
"y las tareas llegan a terminar")
print("\n y se puede volver a echar")
p2.bloquea()
afirma(not p2.desbloqueado, "bloquea() cierra aunque arrancara sin candado")
antes = len(cara2.mensajes)
p2.pulsado("accion", "prueba")
time.sleep(0.6)
afirma(not [d for t, d in cara2.mensajes[antes:] if t in ("tarea", "respuesta")],
"y despues de cerrar ya no obedece")
p.para(); p2.para()
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

122
nucleo/pruebas/prueba_cara.py Executable file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""El panel: que lo que manda el nucleo sea lo que el HUD sabe leer.
./venv/bin/python pruebas/prueba_cara.py
## Por que existe esta prueba
Montando la telemetria, `ps` devolvia el uso de CPU como cadena y el nucleo la
mandaba tal cual. El HUD hace `p.cpu.toFixed(0)`, y una cadena no tiene
`toFixed`: la excepcion abortaba el manejador ENTERO y se llevaba por delante
todo lo que se pinta despues. En pantalla se veia que CPU, RAM y grafica
salian en "--", y el fallo no apuntaba a los procesos por ningun lado.
Ese es el problema de un puente sin contrato: un campo con el tipo equivocado
apaga media pantalla y no dice donde. Aqui se fija el contrato que campo, de
que tipo y en que unidad comprobando ademas que `hud.js` lo sigue usando asi,
para que enterarse no dependa de mirar la pantalla.
"""
import json
import os
import re
import sys
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from cara.panel import Telemetria # noqa: E402
HUD = os.path.join(AQUI, "cara", "hud")
fallos = []
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
def numero(v):
return isinstance(v, (int, float)) and not isinstance(v, bool)
def main() -> int:
print("\n el puente esta puesto")
with open(os.path.join(HUD, "index.html"), encoding="utf-8") as f:
html = f.read()
afirma("puente.js" in html, "index.html carga puente.js")
# Se comparan las etiquetas <script>, no el texto suelto: el comentario que
# hay encima menciona hud.js y una busqueda ingenua lo encuentra antes.
scripts = re.findall(r'<script\s+src="([^"]+)"', html)
afirma("puente.js" in scripts and "hud.js" in scripts
and scripts.index("puente.js") < scripts.index("hud.js"),
"y lo carga ANTES que hud.js, que pregunta por window.webkit al arrancar")
with open(os.path.join(HUD, "puente.js"), encoding="utf-8") as f:
puente = f.read()
afirma("WebSocket" in puente, "el puente habla por WebSocket")
afirma("messageHandlers" in puente,
"suplanta window.webkit, asi hud.js no se toca")
afirma("cola" in puente, "guarda lo pulsado mientras reconecta")
print("\n la telemetria: campos, tipos y unidades")
d = Telemetria().lee()
Telemetria().lee() # el segundo tiene ya las diferencias de cpu
afirma(numero(d.get("cpu")), "cpu es un numero")
afirma(isinstance(d.get("nucleos"), list) and all(numero(x) for x in d["nucleos"]),
f"nucleos es una lista de numeros ({len(d.get('nucleos', []))})")
for clave in ("ram", "swap"):
v = d.get(clave, {})
afirma(numero(v.get("usada")) and numero(v.get("total")),
f"{clave} lleva usada y total")
afirma(0 < v.get("total", 0) < 1024,
f"{clave} viene en GB, no en MB ({v.get('total', 0):.1f})")
disco = d.get("disco", {})
afirma(0 < disco.get("total", 0) < 200,
f"disco viene en TB, no en GB ({disco.get('total', 0):.2f})")
afirma(isinstance(d.get("carga"), list),
"carga es una lista: el HUD hace carga.map()")
afirma(d.get("uptime", 0) > 60,
"uptime va en segundos: el HUD decide solo si pone minutos o dias")
if "gpu" in d:
g = d["gpu"]
afirma(all(numero(g.get(k)) for k in ("uso", "vram_usada", "vram_total")),
"la grafica manda uso, vram_usada y vram_total")
afirma(0 < g.get("vram_total", 0) < 128,
f"la vram viene en GB ({g.get('vram_total', 0):.1f})")
print("\n los procesos: el campo que apago media pantalla")
procs = d.get("procesos", [])
afirma(bool(procs), f"llegan procesos ({len(procs)})")
for p in procs[:3]:
afirma(numero(p.get("cpu")) and numero(p.get("mem")),
f"{p.get('nombre','?')}: cpu y mem son numeros, no cadenas")
afirma(all("pid" in p for p in procs), "todos traen pid, que el HUD pone en el title")
print("\n y el HUD sigue esperando eso mismo")
with open(os.path.join(HUD, "hud.js"), encoding="utf-8") as f:
js = f.read()
# Si alguien cambia hud.js para leer otro campo, esta prueba no lo sabria
# mirando solo al nucleo: se comprueba en el propio JavaScript.
for campo in ("datos.cpu", "datos.ram", "datos.swap", "datos.disco",
"datos.nucleos", "datos.procesos"):
afirma(campo in js, f"hud.js lee {campo}")
afirma("p.cpu.toFixed" in js,
"hud.js llama a toFixed sobre el cpu de cada proceso (por eso tiene que ser numero)")
# Todo lo que se manda tiene que poder viajar por el socket.
print("\n serializable")
try:
json.dumps(d)
afirma(True, "la telemetria entera cabe en JSON")
except (TypeError, ValueError) as e:
afirma(False, f"la telemetria no es JSON: {e}")
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

133
nucleo/pruebas/prueba_dialogo.py Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Pulsar una accion PREGUNTA lo que falta y la hace.
./venv/bin/python pruebas/prueba_dialogo.py
Antes, pulsar en "sabe hacer" recitaba la frase que dispara la accion. Eso es un
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
Lo que se comprueba, por orden de importancia:
1. que lo destructivo NO se dispare de un clic
2. que lo destructivo CON argumento pregunte las DOS cosas
3. que un "no" cancele de verdad
4. que la pregunta caduque, para que una frase suelta media conversacion
despues no se tome por la respuesta
"""
import os
import sys
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from cara.panel import Panel # noqa: E402
from manos import Manos # noqa: E402
from manos.preguntar import pregunta_por, es_si # noqa: E402
fallos = []
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
class CaraFalsa:
conectados = 1
def __init__(self): self.m = []
def manda(self, t, d): self.m.append((t, d))
class BocaMuda:
voz = "davefx"
def __init__(self): self.dicho = []
def di(self, t): self.dicho.append(t); return True
def di_si_libre(self, t): return self.di(t)
def disponibles(self): return ["davefx"]
def nuevo():
cara, boca = CaraFalsa(), BocaMuda()
return Panel(cara, boca, Manos(), candado=False), boca
def main() -> int:
print("\n las preguntas salen del catalogo")
manos = Manos()
conArg = [a for a in manos.catalogo.acciones if a.captura]
genericas = [a for a in conArg if pregunta_por(a) == "¿El que, señor?"]
afirma(len(conArg) >= 30, f"{len(conArg)} acciones piden argumento")
afirma(len(genericas) <= 10,
f"solo {len(genericas)} caen en la pregunta generica, de {len(conArg)}")
afirma(all(pregunta_por(a).endswith("señor?") for a in conArg),
"todas preguntan en el registro de la casa")
afirma("cierra" not in [pregunta_por(a).split()[0].strip("¿").lower()
for a in conArg],
"y ninguna empieza por el verbo de la orden")
print("\n con argumento: pregunta y ejecuta con lo que le digas")
p, boca = nuevo()
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
afirma(bool(p.esperando()), "se queda esperando respuesta")
afirma(any("fichero" in d for d in boca.dicho), f"pregunta cual: {boca.dicho}")
p.responde("informes"); time.sleep(1.5)
tareas = [t.titulo for t in p.tareas.todas()]
afirma(any("informes" in t for t in tareas), f"lo ejecuta con el argumento: {tareas}")
afirma(not p.esperando(), "y deja de esperar")
p.para()
print("\n lo destructivo NO se dispara de un clic")
p, boca = nuevo()
p.pulsado("accion", "explica:ff_firefox_cerrar"); time.sleep(0.3)
afirma(not p.tareas.todas(), "no ha lanzado nada al pulsar")
afirma(bool(p.esperando()), "esta esperando confirmacion")
p.responde("no"); time.sleep(0.4)
afirma(not p.tareas.todas(), "y con un 'no' sigue sin lanzar nada")
p.para()
print("\n destructivo CON argumento: las dos preguntas")
p, boca = nuevo()
p.pulsado("accion", "explica:proceso_matar"); time.sleep(0.2)
afirma(bool(p.esperando()), "pregunta el argumento")
p.responde("firefox"); time.sleep(0.3)
afirma(bool(p.esperando()),
"y DESPUES pide confirmacion, no ejecuta directo")
afirma(any("firefox" in d for d in boca.dicho),
"la confirmacion dice sobre que va, no un cheque en blanco")
p.responde("no"); time.sleep(0.4)
afirma(not p.tareas.todas(), "un 'no' lo cancela")
p.para()
print("\n lo que no pregunta nada, se hace")
p, boca = nuevo()
p.pulsado("accion", "explica:disco_libre"); time.sleep(1.5)
afirma(bool(p.tareas.todas()), "una accion sin argumento ni riesgo se ejecuta")
afirma(not p.esperando(), "sin preguntar nada")
p.para()
print("\n la pregunta caduca")
p, boca = nuevo()
p.ESPERA_S = 0.5
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
afirma(bool(p.esperando()), "recien preguntada, espera")
time.sleep(0.8)
afirma(not p.esperando(),
"pasado el plazo la olvida: una frase suelta media conversacion "
"despues no puede tomarse por la respuesta")
p.para()
print("\n el si y el no, como los dice la gente")
for t, esperado in [("si", True), ("Sí.", True), ("vale", True),
("claro que si", True), ("no", False), ("dejalo", False),
("no, mejor no", False), ("informes", None),
("el fichero de agosto", None)]:
afirma(es_si(t) is esperado, f"{t!r} -> {es_si(t)}")
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

159
nucleo/pruebas/prueba_eco.py Executable file
View file

@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""La prueba que Newelle no pasa: que JARVIS no se oiga a si mismo.
En Newelle, mas del 21 % de lo que oye y no entiende es su propia voz. Esta
prueba lo comprueba de verdad, con el microfono y los altavoces, en dos partes:
A. Suena una frase POR FUERA de JARVIS (un reproductor a pelo, como si
hablara una persona). Tiene que captarla. Si no, el microfono no llega a
los altavoces y la parte B no demuestra nada.
B. La misma frase, pero dicha POR JARVIS. NO tiene que captarla.
Las dos partes juntas son la prueba: A sola no dice nada, B sola podria pasar
simplemente porque el microfono no oye nada.
./venv/bin/python pruebas/prueba_eco.py
Necesita altavoces encendidos y a un volumen normal. Si estan en silencio, la
parte A falla y avisa.
"""
import os
import subprocess
import sys
import threading
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from boca.voz import Boca # noqa: E402
from oido.captura import Captura # noqa: E402
from oido.whisper import Oido, es_ruido # noqa: E402
FRASE = "El sistema esta operativo y a su disposicion, señor."
ESPERA = 14 # cuanto se escucha en cada parte
def _se_parece(oido: str, dicho: str) -> bool:
"""Si lo transcrito es la frase que se dijo, aunque venga maltratada.
Se compara por palabras y no por parecido de cadena: un eco recortado
("operativo y a su disposicion") no se parece a la frase entera como
cadena, pero comparte casi todas sus palabras, y eso es inequivoco.
"""
import unicodedata
def palabras(t):
t = unicodedata.normalize("NFD", t.lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return {p.strip(".,¡!¿?") for p in t.split() if len(p) > 3}
a, b = palabras(oido), palabras(dicho)
return bool(b) and len(a & b) >= 2
def escucha_un_rato(captura, oreja, segundos):
"""Devuelve lo que se haya oido en ese rato."""
oido = []
fin = time.monotonic() + segundos
hilo_paro = threading.Event()
def bucle():
for wav in captura.frases(para_cuando=lambda: hilo_paro.is_set()):
try:
t = oreja.transcribe(wav)
finally:
try:
os.unlink(wav)
except OSError:
pass
if t and not es_ruido(t):
oido.append(t)
h = threading.Thread(target=bucle, daemon=True)
h.start()
while time.monotonic() < fin:
time.sleep(0.2)
hilo_paro.set()
captura.para()
h.join(timeout=5)
return oido
def main() -> int:
boca = Boca("davefx")
oreja = Oido(modelo="small")
if not oreja.disponible():
print(" no esta whisper o el modelo; no se puede probar")
return 2
print(" arrancando whisper...")
if not oreja.arranca():
print(" whisper no arranco")
return 2
# Se genera el WAV una vez para poder reproducirlo por los dos caminos con
# exactamente el mismo audio.
wav = boca._genera(FRASE)
if not wav:
print(" no se pudo generar la voz")
return 2
fallos = 0
# --- A: suena por fuera, tiene que oirse -----------------------------
print("\n A · suena por fuera (como si hablara alguien)")
captura = Captura(boca=boca)
def suelta_fuera():
time.sleep(2.5)
subprocess.run(["paplay", wav], capture_output=True)
threading.Thread(target=suelta_fuera, daemon=True).start()
oido_a = escucha_un_rato(captura, oreja, ESPERA)
for t in oido_a:
print(f" oido: {t!r}")
if oido_a:
print(" OK el microfono llega a los altavoces")
else:
print(" MAL no se oyo nada: ¿altavoces en silencio? sin esto la")
print(" parte B no demuestra nada")
fallos += 1
time.sleep(1.5)
# --- B: lo dice JARVIS, NO tiene que oirse ---------------------------
print("\n B · lo dice JARVIS (la guarda tiene que taparlo)")
captura = Captura(boca=boca)
def suelta_jarvis():
time.sleep(2.5)
boca.di(FRASE)
threading.Thread(target=suelta_jarvis, daemon=True).start()
oido_b = escucha_un_rato(captura, oreja, ESPERA)
# Lo que se juzga es si se colo LA FRASE, no si se transcribio algo.
#
# Esto costo una conclusion equivocada: probando con colas de 350, 700 y
# 1100 ms el resultado no era monotono —1100 fallaba y 700 no—, lo cual es
# imposible si el unico factor fuera el eco. Mirando lo que se colaba, eran
# "¡Adios!" y un parrafo inventado: Whisper ALUCINANDO sobre el silencio,
# que es otro problema distinto y da igual lo larga que sea la guarda.
eco = [t for t in oido_b if _se_parece(t, FRASE)]
alucinado = [t for t in oido_b if t not in eco]
for t in eco:
print(f" ECO: {t!r}")
for t in alucinado:
print(f" (whisper alucinando sobre el silencio: {t[:52]!r})")
if not eco:
print(" OK JARVIS no se oye a si mismo")
else:
print(" MAL se ha oido hablar: la guarda no tapa")
fallos += 1
oreja.para()
print(f"\n {'todo en orden' if not fallos else str(fallos) + ' fallan'}")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

173
nucleo/pruebas/prueba_manos.py Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""El emparejador: que siga acertando y, sobre todo, que no se invente ordenes.
./venv/bin/python pruebas/prueba_manos.py
Porta las comprobaciones de `pruebas/08_acciones.sh` de la epoca de Newelle y
añade las que hacen falta ahora que el emparejador compara tambien por sonido.
La regla que ordena este fichero: **equivocarse cuesta mas que no reaccionar.**
Esto lanza comandos en la maquina; que no entienda una orden es molesto, que
ejecute la que no es puede costar trabajo perdido. Por eso las comprobaciones de
falsos positivos son las que mandan, y por eso el suelo de acierto va aparte y
mas bajo de lo medido: para que una mejora legitima no obligue a tocar el test,
pero una regresion de verdad lo tire.
"""
import json
import os
import shlex
import sys
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from manos.acciones import Catalogo # noqa: E402
from manos.fonetica import fonetica # noqa: E402
from oido.whisper import es_ruido # noqa: E402
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
BANCO = os.path.join(AQUI, "datos", "banco.json")
# Medido el 15 de agosto de 2026: 84,7 %. El suelo se pone tres puntos por
# debajo para dejar sitio al ruido de un banco de 150, donde una frase es 0,67.
SUELO = 81.0
# Lo que NO puede disparar una accion jamas. Las cinco primeras vienen del test
# 08; el resto son conversacion que empieza igual que una orden, que es
# exactamente el riesgo de comparar por sonido.
CHARLA = [
"necesito un abrazo", "quiero contarte una cosa", "necesito pensar",
"puedes explicarme como funciona el kernel", "me gustaria que fueramos amigos",
"que tal estas", "como te encuentras hoy", "cuentame un chiste",
"que opinas de la musica electronica", "me apetece un cafe",
"hoy hace un dia estupendo", "estoy pensando en cambiar de trabajo",
"quien fue alan turing", "explicame la teoria de la relatividad",
"me gusta mucho como suena tu voz", "cuanto tiempo llevas funcionando",
"no se que hacer este fin de semana", "que peliculas me recomiendas",
"he tenido un dia muy largo", "sabes cantar",
"no quiero que abras nada", "no busques nada por ahora",
"antes buscabas mejor las cosas", "el disco duro de mi hermano se rompio",
"la memoria me falla ultimamente", "que temperatura hace en la calle",
"me han abierto una cuenta en el banco", "cierro los ojos y pienso",
"mi ventana da al patio", "la pantalla del movil se ha roto",
"ese proceso judicial fue largo", "tengo un fichero de esos antiguos",
"el kernel de la cuestion es otro", "hablame de la red de metro",
"pon atencion a lo que te digo", "sube el animo que no pasa nada",
"baja la voz que estan durmiendo", "abre tu corazon",
"escanea el documento con la impresora", "mata el tiempo como puedas",
"el volumen de trabajo es enorme", "que hora era cuando llegaste",
"las descargas de agua fueron fuertes", "mi carpeta de fotos favorita",
"el brillo de sus ojos", "conecta conmigo emocionalmente",
"quiero que sepas que me importa", "necesito que me escuches",
"puedes decirme si estoy equivocado", "me gustaria saber tu opinion",
]
# Como habla la gente de verdad. Sin quitar la cortesia no encajaba ninguna:
# era el fallo de casi todas las ordenes reales que se perdian.
CON_CORTESIA = [
("Necesito que pongas la película de Iron Man.", "pelicula_abrir"),
("¿Puedes poner la película de Iron Man?", "pelicula_abrir"),
("Necesito que me abras una terminal.", "terminal_abrir"),
("¿Podrías abrir una terminal?", "terminal_abrir"),
("Me puedes decir cuánto espacio queda.", "disco_libre"),
]
fallos = []
def afirma(condicion, queja):
if condicion:
print(f" OK {queja}")
else:
print(f" MAL {queja}")
fallos.append(queja)
def main() -> int:
cat = Catalogo([CATALOGO])
print("\n el catalogo")
afirma(len(cat.acciones) >= 150, f"{len(cat.acciones)} acciones cargadas")
afirma(not cat.errores, f"sin errores de carga ({len(cat.errores)})")
ids = [a.id for a in cat.acciones]
afirma(len(ids) == len(set(ids)), "no hay ids repetidos")
afirma(all(a.acuse for a in cat.acciones), "todas tienen acuse")
afirma(all("{argumento" in a.comando for a in cat.acciones if a.captura),
"las que capturan argumento lo usan en el comando")
afirma(all(a.confirmar for a in cat.acciones if "rm -rf" in a.comando),
"nada que borre se ejecuta sin confirmar")
print("\n entiende como habla la gente")
for frase, esperado in CON_CORTESIA:
a, _ = cat.busca(frase)
afirma(a is not None and a.id == esperado,
f"'{frase[:38]}' -> {esperado}"
+ ("" if a is None or a.id == esperado else f" (fue a {a.id})"))
print("\n y NO se inventa ordenes")
coladas = [(c, cat.busca(c)[0]) for c in CHARLA]
coladas = [(c, a.id) for c, a in coladas if a is not None]
afirma(not coladas,
f"ninguna de las {len(CHARLA)} frases de charla dispara nada"
+ ("" if not coladas else f" — se colo {coladas[0][0]!r} -> {coladas[0][1]}"))
print("\n el argumento viene de un microfono: no puede ser una orden")
for veneno in ["gatos; rm -rf /", 'algo " ; touch /tmp/jarvis_pwned ; "']:
a, arg = cat.busca(f"busca en internet {veneno}")
if a is None:
fallos.append(f"no encaja la busqueda con {veneno!r}")
print(f" MAL no encaja la busqueda con {veneno!r}")
continue
comando = a.comando.replace("{argumento_url}", shlex.quote(arg))
comando = comando.replace("{argumento}", shlex.quote(arg))
afirma("; rm" not in comando and "; touch" not in comando,
f"entrecomillado: {veneno[:24]!r}")
print("\n el filtro de alucinaciones de Whisper")
for t in ["[Música]", "(aplausos)", "[motor]", "¡Suscríbete!", ""]:
afirma(es_ruido(t), f"descarta {t!r}")
for t in ["cuanto espacio queda", "abre la terminal"]:
afirma(not es_ruido(t), f"deja pasar {t!r}")
print("\n la comparacion por sonido")
# Lo que se afirma es que ACERCA, no que iguale: `parecido()` se queda con
# el mayor de los dos parecidos, asi que basta con que el del sonido suba.
# Exigir igualdad seria pedirle a esto que fuera un Metaphone, y no lo es
# a proposito: comprimir mas junta ordenes que no se parecen.
import difflib
def sube(a, b):
letra = difflib.SequenceMatcher(None, a, b).ratio()
sonido = difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio()
return sonido > letra
afirma(sube("abre van kamp", "abre bandcamp"), "'van kamp' se acerca a 'bandcamp'")
afirma(fonetica("haz") == fonetica("has"), "'haz' suena igual que 'has'")
afirma(fonetica("llave") == fonetica("yave"), "'yave' suena igual que 'llave'")
afirma(fonetica("bino") == fonetica("vino"), "la b y la v son el mismo sonido")
# Y lo que importa de verdad: que no acerque lo que no se parece.
afirma(not sube("abre la terminal", "apaga la pantalla"),
"y NO acerca ordenes distintas")
print("\n el banco fijo")
if not os.path.exists(BANCO):
print(" ·· no hay banco; crealo con pruebas/banco.py --crear")
else:
casos = json.load(open(BANCO, encoding="utf-8"))["casos"]
bien = otra = 0
for c in casos:
a, _ = cat.busca(c["oido"])
if a is None:
continue
if a.id == c["id"]:
bien += 1
else:
otra += 1
pct = 100 * bien / len(casos)
afirma(pct >= SUELO, f"acierto {pct:.1f} % sobre {len(casos)} (suelo {SUELO})")
afirma(otra == 0, f"ninguna orden acaba en la accion equivocada ({otra})")
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

243
nucleo/pruebas/prueba_voz.py Executable file
View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""La cadena de voz entera, con altavoces y microfono de verdad.
./venv/bin/python pruebas/prueba_voz.py
Las otras pruebas miran piezas: que el emparejador acierte, que el candado
cierre, que el puente mande los campos que el HUD lee. Ninguna contesta a la
pregunta que de verdad importa **si le hablo, ¿me contesta?** y por eso el
fallo de que oia bien pero descartaba en silencio llego hasta el usuario.
Esta habla por los altavoces y escucha por el microfono, como una persona. Es
lenta y necesita el volumen puesto, pero es la unica que prueba lo que se usa.
## Como funciona
Se genera la frase con Piper, se reproduce POR FUERA de la boca de JARVIS con
un reproductor a pelo, para que la guarda del eco no la tape y se mira que la
cadena entera reaccione. Que el microfono llega a los altavoces ya lo demuestra
prueba_eco.py; aqui se da por hecho y se avisa si falla.
## Lo que se comprueba, en orden
1. sin llamarle se oye pero NO se atiende, y se dice por que
2. llamandole se atiende y contesta
3. encadenando la siguiente sin repetir el nombre
4. como lo oye Whisper "Yarvis" y "Charles" tambien valen
"""
import os
import subprocess
import sys
import threading
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from boca.voz import Boca # noqa: E402
from manos import Manos # noqa: E402
from oido.captura import Captura # noqa: E402
from oido.despierta import Centinela, NOMBRE # noqa: E402
from oido.whisper import Oido, es_ruido # noqa: E402
from jarvis import atiende # noqa: E402
ESPERA = 16 # cuanto se escucha por cada frase dicha
fallos = []
ajenas = [] # lo que se oyo y no era de la prueba: ruido de la sala
def _se_parece(oido: str, dicho: str) -> bool:
"""Si lo transcrito es la frase que se reprodujo, aunque venga maltratada.
Hace falta porque la prueba usa el microfono de la habitacion: si hay una
tele, una conversacion o un video sonando, el bucle oye ESO y una prueba
ingenua lo da por bueno. Ha pasado una pasada dio por valida "¡No te
olvides de todo ese chico!"— y una prueba que se cree cualquier ruido no
prueba nada.
"""
import unicodedata
def palabras(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return {p.strip(".,¡!¿?«»") for p in t.split() if len(p) > 3}
a, b = palabras(oido), palabras(dicho)
if not b:
return False
return len(a & b) >= max(1, min(2, len(b) - 1))
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
class Escucha:
"""El bucle de voz de jarvis.py, reducido a lo que hace falta probar."""
def __init__(self, boca, oreja, centinela):
self.boca, self.oreja, self.centinela = boca, oreja, centinela
self.manos = Manos()
self.oido = [] # (texto, atendido, motivo)
self.dicho = [] # lo que ha contestado
self._para = threading.Event()
self._hilo = None
def arranca(self):
self._hilo = threading.Thread(target=self._bucle, daemon=True)
self._hilo.start()
def _bucle(self):
cap = Captura(boca=self.boca)
for wav in cap.frases(para_cuando=lambda: self._para.is_set()):
try:
texto = self.oreja.transcribe(wav)
finally:
try:
os.unlink(wav)
except OSError:
pass
if es_ruido(texto):
continue
atender, orden, _ = self.centinela.filtra(texto)
if not atender:
self.oido.append((texto, False, "no le han llamado"))
continue
self.oido.append((texto, True, ""))
respuesta = atiende(orden, self.manos, None, traza=lambda t: None)
if respuesta:
self.dicho.append(respuesta)
cap.para()
def para(self):
self._para.set()
if self._hilo:
self._hilo.join(timeout=6)
def suelta(self, frase, espera=ESPERA):
"""Dice la frase POR FUERA de JARVIS y espera a que reaccione.
Solo cuenta lo que se PARECE a lo reproducido: si hay ruido en la
habitacion, el bucle lo oye tambien y darlo por bueno haria pasar la
prueba sin haber probado nada.
"""
antes_oido, antes_dicho = len(self.oido), len(self.dicho)
wav = self.boca._genera(frase)
if not wav:
return None, None
time.sleep(0.6)
subprocess.run(["paplay", wav], capture_output=True)
fin = time.monotonic() + espera
mio = None
while time.monotonic() < fin:
for entrada in self.oido[antes_oido:]:
if _se_parece(entrada[0], frase):
mio = entrada
break
if mio and (not mio[1] or len(self.dicho) > antes_dicho):
break
time.sleep(0.2)
nuevo_dicho = self.dicho[antes_dicho:]
ajeno = [o[0] for o in self.oido[antes_oido:] if o is not mio]
if ajeno:
ajenas.extend(ajeno)
print(f" (ruido de la habitacion, ignorado: {ajeno[0][:44]!r})")
return mio, (nuevo_dicho[-1] if nuevo_dicho else None)
def main() -> int:
boca = Boca("davefx")
oreja = Oido(modelo="small")
if not oreja.disponible():
print(" no esta whisper o el modelo; no se puede probar")
return 2
print(" arrancando whisper...")
if not oreja.arranca():
print(" whisper no arranco")
return 2
centinela = Centinela(activo=True)
esc = Escucha(boca, oreja, centinela)
esc.arranca()
time.sleep(1.5)
print("\n 0 · con la palabra APAGADA (el defecto): atiende directo")
centinela.activo = False
oido, dicho = esc.suelta("Cuanta memoria queda.")
if oido:
print(f" oyo: {oido[0]!r}")
if dicho:
print(f" dijo: {dicho}")
afirma(oido is not None and oido[1],
"sin exigir el nombre, atiende lo que oiga")
afirma(dicho is not None, "y contesta")
centinela.activo = True
centinela.duerme()
print("\n 1 · con la palabra puesta y sin llamarle: oye pero no atiende")
oido, dicho = esc.suelta("Cuanto espacio queda en el disco.")
if oido is None:
print(" ·· no se oyo nada: ¿altavoces en silencio? sin esto el resto")
print(" de la prueba no demuestra nada")
fallos.append("el microfono no capta los altavoces")
else:
print(f" oyo: {oido[0]!r}")
afirma(not oido[1], "lo oye pero no lo atiende, porque no le han llamado")
afirma(dicho is None, "y no contesta")
centinela.duerme()
print(f"\n 2 · llamandole por su nombre")
oido, dicho = esc.suelta(f"{NOMBRE.capitalize()}, cuanto espacio queda.")
if oido:
print(f" oyo: {oido[0]!r}")
if dicho:
print(f" dijo: {dicho}")
afirma(oido is not None and oido[1], "lo atiende")
afirma(dicho is not None, "y contesta algo")
afirma(dicho is not None and any(c.isdigit() for c in dicho),
"con una cifra de verdad, no una excusa")
print("\n 3 · encadenando, sin repetir el nombre")
oido, dicho = esc.suelta("Cuanta memoria queda.")
if oido:
print(f" oyo: {oido[0]!r}")
afirma(oido is not None and oido[1],
"sigue despierto y atiende sin que le vuelvan a llamar")
print("\n 4 · como lo oye Whisper de verdad")
centinela.duerme()
oido, dicho = esc.suelta("Yarvis, que hora es.")
if oido:
print(f" oyo: {oido[0]!r}")
afirma(oido is not None and oido[1],
"'Yarvis' tambien le despierta (Whisper escribe el nombre de mil formas)")
esc.para()
oreja.para()
# Una prueba que usa el microfono de la sala no puede fallar en silencio por
# culpa de la sala. Si ha entrado mucho ruido, el resultado no dice nada del
# codigo y hay que decirlo, no soltar una pared de MAL.
if fallos and len(ajenas) >= 2:
print(f"\n ┌─ SALA RUIDOSA ─────────────────────────────────────────")
print(f" │ Han entrado {len(ajenas)} frases que no eran de la prueba.")
print(f" │ Con audio sonando cerca del microfono, este resultado NO")
print(f" │ dice nada del codigo. Ejemplos de lo que se colo:")
for a in ajenas[:3]:
print(f"{a[:52]!r}")
print(f" │ Repitelo en silencio.")
print(f" └────────────────────────────────────────────────────────")
print(f"\n {len(fallos)} fallan, pero no son concluyentes\n")
return 2
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

175
nucleo/pruebas/repasa_acciones.py Executable file
View 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())