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