JARVIS/nucleo/pruebas/banco.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

169 lines
6 KiB
Python
Executable file

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