JARVIS/nucleo/pruebas/prueba_manos.py
sito 8e4bc8ad94 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.
2026-08-16 15:34:37 +02:00

173 lines
7.9 KiB
Python
Executable file

#!/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())