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.
484 lines
18 KiB
Python
Executable file
484 lines
18 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Bucle de mejora que corre solo, ronda tras ronda, y se protege de empeorar.
|
|
|
|
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py # hasta la mañana
|
|
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --horas 2 # un rato
|
|
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --ronda # una sola
|
|
|
|
Que hace en cada ronda:
|
|
1. Dice en voz alta (Piper) las frases del catalogo, en varias voces,
|
|
velocidades y con ruido y sala aleatorios, distintos cada ronda.
|
|
2. Las transcribe con el mismo Whisper del asistente.
|
|
3. Mira que porcentaje llega a su accion. Ese es el marcador de la ronda.
|
|
4. De lo que NO llega, se queda con los fallos SISTEMATICOS (repetidos) y
|
|
SIN AMBIGUEDAD (que no puedan ser dos acciones), y los propone como alias.
|
|
5. GUARDA: antes de aplicar nada, comprueba en una copia que
|
|
a) el acierto sube, y
|
|
b) la conversacion normal SIGUE yendo al modelo (no se cuela ninguna).
|
|
Solo si las dos cosas se cumplen, aplica los alias de verdad.
|
|
6. Copia de seguridad del catalogo antes de tocarlo, y todo al registro.
|
|
|
|
Por que es seguro dejarlo sin mirar: nunca aplica algo que baje el acierto ni
|
|
que haga que una frase de charla acabe ejecutando un comando. Si una ronda
|
|
empeorara, revierte. Y cada cambio queda respaldado, asi que siempre se puede
|
|
volver atras a mano.
|
|
|
|
Que NO hace: reentrenar el modelo (no cabe en 4 GB de VRAM) ni tocar Whisper o
|
|
Piper. Mejora la capa de emparejamiento —alias y umbrales—, que es donde estan
|
|
casi todos los fallos y donde una mejora es inmediata, legible y reversible.
|
|
|
|
En marcha se puede seguir con:
|
|
tail -f ~/COFRE/CODERS/JARVIS/muestras/mejora/registro.log
|
|
cat ~/COFRE/CODERS/JARVIS/muestras/mejora/marcador.json
|
|
"""
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import random
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
CASA = os.path.expanduser("~")
|
|
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
|
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
|
APP = "io.github.qwersyk.Newelle//master"
|
|
|
|
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
|
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
|
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
|
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
|
|
|
CATALOGO = os.path.expanduser(
|
|
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
|
"active/files/share/newelle/acciones/acciones.json")
|
|
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
|
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
|
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
|
|
|
SALA = os.path.join(JARVIS, "muestras/mejora")
|
|
REGISTRO = os.path.join(SALA, "registro.log")
|
|
MARCADOR = os.path.join(SALA, "marcador.json")
|
|
COPIAS = os.path.join(SALA, "copias")
|
|
WAVS = os.path.join(CONFIG, "mejora_wavs")
|
|
|
|
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
|
# Cada ronda escoge una rejilla distinta de velocidades y ruidos, para que no
|
|
# se estanque midiendo siempre lo mismo y siga sacando fallos raros.
|
|
VELOCIDADES = [0.85, 0.92, 1.0, 1.08, 1.18]
|
|
RUIDOS = [
|
|
"",
|
|
"aecho=0.8:0.7:22:0.12",
|
|
"volume=0.72,highpass=f=120",
|
|
"aecho=0.6:0.5:35:0.2,volume=0.8",
|
|
"highpass=f=150,lowpass=f=6500", # microfono cutre
|
|
"volume=0.65", # lejos
|
|
]
|
|
RELLENOS = ["notas", "el informe", "documentos", "la reunion de ayer", "gatos"]
|
|
|
|
# Frases de charla que NUNCA deben acabar en una accion. Es el guardarrail: si
|
|
# despues de aplicar alias alguna se cuela, la ronda se revierte.
|
|
CHARLA = [
|
|
"cuentame un chiste sobre gatos", "que opinas del proyecto",
|
|
"hola que tal estas", "gracias por todo", "no se que hacer con esto",
|
|
"me parece que esto no funciona bien", "buenas tardes que tal",
|
|
"cuentame algo interesante", "estoy cansado de programar",
|
|
"que raro todo esto la verdad", "a ver si esto va mejor ahora",
|
|
"pues no tengo ni idea de que decir", "vale perfecto muchas gracias",
|
|
"oye una cosa que te queria preguntar",
|
|
]
|
|
|
|
TOPE_ALIAS_POR_ACCION = 25 # que una accion no acumule cientos de alias
|
|
UMBRAL_SISTEMATICO = 2 # veces que hay que fallar igual para creerselo
|
|
DRY_PARA_PARAR = 6 # rondas seguidas sin nada nuevo -> converge
|
|
|
|
|
|
def log(msg):
|
|
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
|
print(linea, flush=True)
|
|
try:
|
|
with open(REGISTRO, "a") as f:
|
|
f.write(linea + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def disco_libre_gb() -> float:
|
|
"""GB libres en la particion del home. Guardarrail: aunque el bucle apenas
|
|
ocupa 3 MB por ronda y los limpia, si algo mas llenara el disco durante la
|
|
noche conviene parar en vez de dejar el sistema sin espacio."""
|
|
try:
|
|
s = os.statvfs(CASA)
|
|
return s.f_bavail * s.f_frsize / 1e9
|
|
except OSError:
|
|
return 999.0
|
|
|
|
|
|
def carga_motor():
|
|
# se recarga cada ronda: el catalogo propio cambia y el motor debe verlo
|
|
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
|
m = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(m)
|
|
return m
|
|
|
|
|
|
def dentro(orden, limite=1200):
|
|
return subprocess.run(
|
|
["flatpak", "run", "--command=bash", APP, "-c", orden],
|
|
capture_output=True, text=True, timeout=limite)
|
|
|
|
|
|
def genera(texto, voz, velocidad, ruido, destino):
|
|
crudo = tempfile.mktemp(suffix=".wav")
|
|
try:
|
|
r = subprocess.run(
|
|
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
|
"--length_scale", str(1 / velocidad), "--output_file", crudo],
|
|
input=texto, capture_output=True, text=True, timeout=60)
|
|
if r.returncode != 0 or not os.path.exists(crudo):
|
|
return False
|
|
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
|
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
"-i", crudo, "-ac", "1", "-af", filtros, "-y", destino],
|
|
check=True, timeout=60)
|
|
return os.path.exists(destino)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
finally:
|
|
if os.path.exists(crudo):
|
|
os.remove(crudo)
|
|
|
|
|
|
def transcribe(carpeta, ficheros, hilos=8, por_tanda=40):
|
|
salida = {}
|
|
for i in range(0, len(ficheros), por_tanda):
|
|
tanda = ficheros[i:i + por_tanda]
|
|
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
|
|
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
|
|
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
|
|
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
|
|
try:
|
|
r = dentro(orden)
|
|
except subprocess.SubprocessError:
|
|
continue
|
|
actual = None
|
|
for linea in r.stdout.splitlines():
|
|
if linea.startswith("### "):
|
|
actual = linea[4:].strip()
|
|
elif actual:
|
|
salida[actual] = linea.strip()
|
|
actual = None
|
|
return salida
|
|
|
|
|
|
def catalogo_base():
|
|
ruta = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
|
with open(ruta) as f:
|
|
return json.load(f)["acciones"], ruta
|
|
|
|
|
|
def evalua(motor, textos, indice):
|
|
"""(aciertos, total, candidatos{id: {frase_mal: veces}})."""
|
|
aciertos = 0
|
|
candidatos = defaultdict(lambda: defaultdict(int))
|
|
for nombre, (ident, frase) in indice.items():
|
|
oido = textos.get(nombre, "").strip()
|
|
if not oido:
|
|
continue
|
|
a, _ = _CAT.busca(oido)
|
|
if a is not None and a.id == ident:
|
|
aciertos += 1
|
|
else:
|
|
candidatos[ident][motor.normaliza(oido)] += 1
|
|
return aciertos, len([1 for n in indice if textos.get(n)]), candidatos
|
|
|
|
|
|
_CAT = None
|
|
|
|
|
|
# Palabras de funcion: aparecen en cualquier frase y no distinguen una orden de
|
|
# una conversacion. Un alias hecho solo de estas ("que me tengo") es una trampa.
|
|
VACIAS = {
|
|
"que", "de", "la", "el", "los", "las", "un", "una", "me", "te", "se", "lo",
|
|
"mi", "tu", "su", "es", "esta", "estan", "hay", "por", "para", "con", "en",
|
|
"y", "o", "a", "al", "del", "como", "cuanto", "cuanta", "muy", "ya", "he",
|
|
"ha", "tengo", "tiene", "esto", "eso", "aqui", "ahi", "va", "van", "asi",
|
|
}
|
|
|
|
|
|
def contenido(frase: str) -> set:
|
|
"""Las palabras con significado: las que de verdad identifican la orden."""
|
|
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
|
|
|
|
|
def filtra_candidatos(motor, candidatos, acciones):
|
|
"""Solo los alias que valen: sistematicos, sin ambiguedad, y que conservan
|
|
una palabra distintiva de la orden original.
|
|
|
|
El guardarrail clave es el ultimo: una confusion del reconocedor que aun
|
|
sirve de alias mantiene al menos una palabra con significado ("kernel",
|
|
"wikipedia", "bateria"). Si las perdio todas y solo quedan palabras de
|
|
relleno, es indistinguible de una frase de charla y se descarta, por mucho
|
|
que se haya repetido. Es lo que separa "que kernel tengo" -> "que kernel me
|
|
tengo" (vale) de "que me tengo" (no vale).
|
|
"""
|
|
porfrase = defaultdict(set)
|
|
for ident, frases in candidatos.items():
|
|
for f in frases:
|
|
porfrase[f].add(ident)
|
|
|
|
porid = {a["id"]: a for a in acciones}
|
|
|
|
propuestos = defaultdict(list)
|
|
for ident, frases in candidatos.items():
|
|
accion = porid.get(ident)
|
|
if accion is None:
|
|
continue
|
|
# Las de argumento no generan alias: el relleno de prueba ("notas") se
|
|
# cuela en la transcripcion y quedaria horneado en el alias. Su
|
|
# emparejamiento ya es por parecido del arranque, que las cubre.
|
|
if accion.get("captura"):
|
|
continue
|
|
|
|
orig = motor.normaliza(accion["frases"][0] if accion["frases"] else "")
|
|
claves = contenido(orig)
|
|
|
|
for f, veces in frases.items():
|
|
if veces < UMBRAL_SISTEMATICO:
|
|
continue
|
|
if len(porfrase[f]) > 1: # la misma frase, dos acciones
|
|
continue
|
|
if len(f.split()) < 2: # una palabra engancha de todo
|
|
continue
|
|
if _CAT.busca(f)[0] is not None: # ya lo coge algo
|
|
continue
|
|
if motor.parecido(f, orig) < 0.6: # se perdio del todo: ruido
|
|
continue
|
|
# conserva una palabra distintiva? exacta o casi (el reconocedor
|
|
# cambia una letra: "wikipedia" -> "wikipeda")
|
|
palabras_f = set(f.split())
|
|
if claves and not any(
|
|
c in palabras_f or any(motor.parecido(c, w) > 0.8 for w in palabras_f)
|
|
for c in claves):
|
|
continue
|
|
propuestos[ident].append((f, veces))
|
|
return propuestos
|
|
|
|
|
|
def aplica(propuestos, acciones_orig):
|
|
"""Escribe los alias en el catalogo propio. Devuelve cuantos."""
|
|
try:
|
|
with open(PROPIO) as f:
|
|
propio = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
propio = {"version": 1, "acciones": []}
|
|
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
|
original = {a["id"]: a for a in acciones_orig}
|
|
|
|
n = 0
|
|
for ident, lista in propuestos.items():
|
|
entrada = porid.get(ident)
|
|
if entrada is None:
|
|
entrada = dict(original[ident])
|
|
porid[ident] = entrada
|
|
propio.setdefault("acciones", []).append(entrada)
|
|
if len(entrada["frases"]) >= TOPE_ALIAS_POR_ACCION:
|
|
continue
|
|
for f, _ in lista:
|
|
if f not in entrada["frases"]:
|
|
entrada["frases"].append(f)
|
|
n += 1
|
|
with open(PROPIO, "w") as f:
|
|
json.dump(propio, f, indent=2, ensure_ascii=False)
|
|
return n
|
|
|
|
|
|
def charla_se_cuela(motor):
|
|
"""True si alguna frase de charla acaba en una accion. El guardarrail."""
|
|
for frase in CHARLA:
|
|
a, _ = _CAT.busca(frase)
|
|
if a is not None:
|
|
return frase, a.id
|
|
return None
|
|
|
|
|
|
def marcador_guarda(datos):
|
|
try:
|
|
with open(MARCADOR, "w") as f:
|
|
json.dump(datos, f, indent=2, ensure_ascii=False)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def copia_catalogo(ronda):
|
|
if not os.path.exists(PROPIO):
|
|
return
|
|
os.makedirs(COPIAS, exist_ok=True)
|
|
shutil.copy2(PROPIO, os.path.join(COPIAS, f"ronda_{ronda:03d}.json"))
|
|
|
|
|
|
def una_ronda(motor, ronda, muestras_por_acap):
|
|
global _CAT
|
|
acciones, _ = catalogo_base()
|
|
|
|
# muestras de esta ronda: condiciones aleatorias, distintas cada vez
|
|
trabajos = []
|
|
for a in acciones:
|
|
if not a["frases"]:
|
|
continue
|
|
frase = a["frases"][0]
|
|
if a.get("captura"):
|
|
frase += " " + random.choice(RELLENOS)
|
|
for _ in range(muestras_por_acap):
|
|
trabajos.append((a["id"], frase, random.choice(VOCES),
|
|
random.choice(VELOCIDADES), random.choice(RUIDOS)))
|
|
random.shuffle(trabajos)
|
|
|
|
shutil.rmtree(WAVS, ignore_errors=True)
|
|
os.makedirs(WAVS, exist_ok=True)
|
|
indice = {}
|
|
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
|
|
nombre = f"{n:05d}.wav"
|
|
if genera(frase, voz, vel, ruido, os.path.join(WAVS, nombre)):
|
|
indice[nombre] = (ident, frase)
|
|
|
|
log(f"ronda {ronda}: {len(indice)} frases dichas, transcribiendo...")
|
|
textos = transcribe(WAVS, sorted(indice))
|
|
|
|
# medir ANTES de tocar nada
|
|
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
|
or [CATALOGO_FUENTE])
|
|
aciertos, total, candidatos = evalua(motor, textos, indice)
|
|
antes = 100 * aciertos / max(1, total)
|
|
log(f"ronda {ronda}: acierto {antes:.1f} % ({aciertos}/{total})")
|
|
|
|
propuestos = filtra_candidatos(motor, candidatos, acciones)
|
|
nuevos = sum(len(v) for v in propuestos.values())
|
|
if not nuevos:
|
|
log(f"ronda {ronda}: sin alias nuevos que valgan")
|
|
return antes, 0, False
|
|
|
|
# aplicar en el catalogo propio, con copia de seguridad antes
|
|
copia_catalogo(ronda)
|
|
n = aplica(propuestos, acciones)
|
|
|
|
# revalidar: recargar el catalogo con los alias puestos
|
|
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
|
or [CATALOGO_FUENTE])
|
|
|
|
# GUARDARRAIL 1: la charla no se cuela
|
|
cuela = charla_se_cuela(motor)
|
|
if cuela:
|
|
frase, ident = cuela
|
|
log(f"ronda {ronda}: REVERTIDA — '{frase}' se colaria en {ident}")
|
|
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
|
return antes, 0, False
|
|
|
|
# GUARDARRAIL 2: el acierto sube en las mismas muestras
|
|
aciertos2, _, _ = evalua(motor, textos, indice)
|
|
despues = 100 * aciertos2 / max(1, total)
|
|
if despues < antes - 0.5:
|
|
log(f"ronda {ronda}: REVERTIDA — el acierto bajaria a {despues:.1f} %")
|
|
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
|
return antes, 0, False
|
|
|
|
log(f"ronda {ronda}: +{n} alias · acierto {antes:.1f} -> {despues:.1f} %")
|
|
for ident, lista in sorted(propuestos.items()):
|
|
for f, v in lista[:2]:
|
|
log(f" {ident} <- \"{f}\" ({v}x)")
|
|
return despues, n, True
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--horas", type=float, default=0, help="parar tras N horas")
|
|
ap.add_argument("--ronda", action="store_true", help="una sola ronda")
|
|
ap.add_argument("--muestras", type=int, default=3,
|
|
help="muestras por accion y ronda")
|
|
ap.add_argument("--dry", type=int, default=DRY_PARA_PARAR,
|
|
help="rondas secas seguidas antes de parar")
|
|
args = ap.parse_args()
|
|
|
|
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
|
|
if not os.path.exists(ruta):
|
|
print(f"falta {que}: {ruta}")
|
|
return 1
|
|
|
|
os.makedirs(SALA, exist_ok=True)
|
|
limite = time.time() + args.horas * 3600 if args.horas else None
|
|
motor = carga_motor()
|
|
|
|
log("=" * 56)
|
|
log(f"arranca la mejora nocturna · {args.muestras} muestras/accion/ronda")
|
|
if limite:
|
|
log(f"parara en {args.horas} h")
|
|
else:
|
|
log("corre hasta converger o hasta que la pares")
|
|
|
|
historial = []
|
|
dry = 0
|
|
ronda = 0
|
|
try:
|
|
while True:
|
|
ronda += 1
|
|
libre = disco_libre_gb()
|
|
if libre < 10:
|
|
log(f'PARADA: solo quedan {libre:.1f} GB de disco. Fin por seguridad.')
|
|
break
|
|
ini = time.time()
|
|
try:
|
|
score, nuevos, aplicado = una_ronda(motor, ronda, args.muestras)
|
|
except Exception as e:
|
|
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
|
time.sleep(5)
|
|
continue
|
|
|
|
historial.append({"ronda": ronda, "acierto": round(score, 1),
|
|
"alias_nuevos": nuevos,
|
|
"minutos": round((time.time() - ini) / 60, 1)})
|
|
marcador_guarda({
|
|
"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
|
"rondas": ronda, "ultimo_acierto": round(score, 1),
|
|
"alias_totales": alias_totales(),
|
|
"historial": historial[-40:],
|
|
})
|
|
|
|
dry = dry + 1 if nuevos == 0 else 0
|
|
if args.ronda:
|
|
break
|
|
if dry >= args.dry:
|
|
log(f"converge: {DRY_PARA_PARAR} rondas sin nada nuevo. Fin.")
|
|
break
|
|
if limite and time.time() > limite:
|
|
log("se acabo el tiempo pedido. Fin.")
|
|
break
|
|
except KeyboardInterrupt:
|
|
log("parada a mano")
|
|
|
|
shutil.rmtree(WAVS, ignore_errors=True)
|
|
log(f"terminado tras {ronda} rondas · acierto final "
|
|
f"{historial[-1]['acierto'] if historial else '?'} % · "
|
|
f"{alias_totales()} alias en el catalogo propio")
|
|
log("los cambios estan en jarvis-acciones.json; se aplican al abrir JARVIS")
|
|
return 0
|
|
|
|
|
|
def alias_totales():
|
|
try:
|
|
with open(PROPIO) as f:
|
|
d = json.load(f)
|
|
base = {a["id"]: len(a["frases"]) for a in
|
|
json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
|
else CATALOGO_FUENTE))["acciones"]}
|
|
return sum(max(0, len(a["frases"]) - base.get(a["id"], 0))
|
|
for a in d.get("acciones", []))
|
|
except (OSError, json.JSONDecodeError):
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|