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
169
nucleo/pruebas/banco.py
Executable file
169
nucleo/pruebas/banco.py
Executable 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
133
nucleo/pruebas/prueba_candado.py
Executable 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
122
nucleo/pruebas/prueba_cara.py
Executable 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
133
nucleo/pruebas/prueba_dialogo.py
Executable 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
159
nucleo/pruebas/prueba_eco.py
Executable 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
173
nucleo/pruebas/prueba_manos.py
Executable 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
243
nucleo/pruebas/prueba_voz.py
Executable 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
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