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.
173 lines
7.9 KiB
Python
Executable file
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())
|