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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue