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.
464 lines
18 KiB
Python
Executable file
464 lines
18 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Dos JARVIS hablando: uno examina, el otro ejecuta, el primero corrige.
|
|
|
|
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py # una tanda
|
|
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --horas 6 # toda la noche
|
|
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --aplicar # guardar mejoras
|
|
|
|
La idea. Los bucles que teniamos probaban trozos: mejora_nocturna comprobaba
|
|
solo si la frase llega a la accion; probar_acciones, solo si la accion corre.
|
|
Esto los junta en una conversacion de punta a punta y por AUDIO:
|
|
|
|
Examinador -> dice una tarea en voz alta (Piper), con ruido y sala
|
|
Jarvis -> la oye (Whisper), decide que accion es, la ejecuta,
|
|
y contesta en voz alta
|
|
Examinador -> comprueba tres cosas:
|
|
1. ¿oyo la tarea correcta? (reconocimiento)
|
|
2. ¿hizo la accion que tocaba? (emparejamiento)
|
|
3. ¿el resultado tiene sentido? (ejecucion y respuesta)
|
|
y pone nota
|
|
|
|
Lo que se ejecuta de verdad: solo lo de solo lectura. Lo que abre cosas, apaga
|
|
o necesita root se comprueba hasta el emparejamiento, sin lanzarlo —una
|
|
autoescuela no deberia apagarte la pantalla ni pedir tu contraseña de noche.
|
|
|
|
Lo que aprende: los fallos se separan por tipo. Un fallo de reconocimiento
|
|
propone un alias (con el mismo filtro seguro de mejora_nocturna: sistematico,
|
|
sin ambiguedad, conserva palabra clave). Un fallo de ejecucion o de respuesta
|
|
se ANOTA para arreglarlo a mano —eso no se toca solo, que es codigo—.
|
|
|
|
Escenarios: casi todos son de un turno (una tarea, una respuesta), sacados del
|
|
catalogo. Unos pocos son de varios turnos para probar lo que tiene estado:
|
|
buscar -> "abre el segundo" -> "busca dentro X". Y hay charla que NO debe
|
|
disparar ninguna accion, como control negativo.
|
|
"""
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import shlex
|
|
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")
|
|
SELECCION = os.path.join(JARVIS, "newelle/src/utility/seleccion.py")
|
|
|
|
SALA = os.path.join(JARVIS, "muestras/autoescuela")
|
|
DIALOGO = os.path.join(SALA, "dialogo.log")
|
|
INFORME = os.path.join(SALA, "informe.json")
|
|
WAVS = os.path.join(CONFIG, "autoescuela_wavs")
|
|
|
|
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
|
VELOCIDADES = [0.9, 1.0, 1.12]
|
|
RUIDOS = ["", "aecho=0.8:0.7:22:0.12", "volume=0.72,highpass=f=120",
|
|
"highpass=f=150,lowpass=f=6500"]
|
|
|
|
# Lo que tiene efecto o pide permiso: se comprueba el emparejamiento pero no se
|
|
# ejecuta. Igual que en probar_acciones.
|
|
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
|
|
"brightnessctl", "systemctl start", "systemctl stop", "systemctl restart",
|
|
"import -window", "gnome-screenshot", "sudo", "rm ", "kill", "dpms"]
|
|
|
|
RELLENOS = ["notas", "el informe", "documentos", "reactor"]
|
|
|
|
|
|
def carga(ruta, nombre):
|
|
spec = importlib.util.spec_from_file_location(nombre, ruta)
|
|
m = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(m)
|
|
return m
|
|
|
|
|
|
def log(msg, a_dialogo=False):
|
|
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
|
print(linea, flush=True)
|
|
fichero = DIALOGO if a_dialogo else DIALOGO
|
|
try:
|
|
with open(fichero, "a") as f:
|
|
f.write((msg if a_dialogo else linea) + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def dice(texto, voz, vel, ruido, destino):
|
|
"""El examinador dice una tarea: la sintetiza a un wav de 16 kHz."""
|
|
crudo = tempfile.mktemp(suffix=".wav")
|
|
try:
|
|
r = subprocess.run(
|
|
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
|
"--length_scale", str(1 / vel), "--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 True
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
finally:
|
|
if os.path.exists(crudo):
|
|
os.remove(crudo)
|
|
|
|
|
|
def oye(wavs, hilos=8, por_tanda=40):
|
|
"""Jarvis oye: transcribe los wav. {fichero: texto}."""
|
|
salida = {}
|
|
ficheros = sorted(wavs)
|
|
for i in range(0, len(ficheros), por_tanda):
|
|
tanda = ficheros[i:i + por_tanda]
|
|
lista = " ".join(f'"{WAVS}/{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 = subprocess.run(["flatpak", "run", "--command=bash", APP, "-c", orden],
|
|
capture_output=True, text=True, errors="replace", timeout=1200)
|
|
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 tiene_efecto(comando):
|
|
return any(m in comando for m in EFECTOS)
|
|
|
|
|
|
def ejecuta_lectura(comando, argumento=""):
|
|
"""Ejecuta un comando de solo lectura y devuelve su salida."""
|
|
c = comando.replace("{argumento}", shlex.quote(argumento))
|
|
c = c.replace("{argumento_url}", shlex.quote(argumento))
|
|
try:
|
|
r = subprocess.run(["bash", "-lc", c], capture_output=True, text=True,
|
|
errors="replace", timeout=15)
|
|
return (r.stdout or r.stderr or "").strip(), r.returncode
|
|
except subprocess.SubprocessError:
|
|
return "", 1
|
|
|
|
|
|
class Ejecutor:
|
|
"""La tuberia de Jarvis: oye una frase, decide y hace. Replica el enrutado
|
|
del panel (seleccion antes que catalogo) para poder probarlo sin GUI."""
|
|
|
|
def __init__(self, motor, seleccion):
|
|
self.motor = motor
|
|
self.sel = seleccion
|
|
self.cat = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
|
or [CATALOGO_FUENTE])
|
|
self.lista = []
|
|
self.fichero = None
|
|
|
|
def recarga(self):
|
|
self.cat = self.motor.Catalogo(
|
|
[p for p in (PROPIO, CATALOGO) if os.path.exists(p)] or [CATALOGO_FUENTE])
|
|
|
|
def procesa(self, oido):
|
|
"""Devuelve (tipo, id_o_dato, salida). tipo:
|
|
'seleccion' | 'dentro' | 'accion' | 'modelo'."""
|
|
norm = self.motor.normaliza(oido)
|
|
|
|
s = self.sel.orden_de_seleccion(norm)
|
|
if s is not None and self.lista:
|
|
que, pos = s
|
|
idx = len(self.lista) - 1 if pos == -1 else pos - 1
|
|
if 0 <= idx < len(self.lista):
|
|
self.fichero = self.lista[idx]
|
|
return "seleccion", f"{que}:{pos}", self.fichero
|
|
return "seleccion", "fuera_de_rango", ""
|
|
|
|
termino = self.sel.orden_buscar_dentro(norm)
|
|
if termino and self.fichero:
|
|
r, _ = ejecuta_lectura(f"grep -in {shlex.quote(termino)} "
|
|
f"{shlex.quote(self.fichero)} | head", "")
|
|
return "dentro", termino, r
|
|
|
|
accion, arg = self.cat.busca(oido)
|
|
if accion is None:
|
|
return "modelo", "", ""
|
|
|
|
# las de lista alimentan self.lista
|
|
if accion.id in ("buscar_fichero", "buscar_carpeta", "grep_recursivo"):
|
|
salida, _ = ejecuta_lectura(accion.comando, arg)
|
|
rutas = [l.split(":", 1)[0] if accion.id == "grep_recursivo" else l
|
|
for l in salida.splitlines() if l.strip()]
|
|
self.lista = rutas
|
|
if rutas:
|
|
self.fichero = rutas[0]
|
|
return "accion", accion.id, salida
|
|
|
|
if accion.confirmar or tiene_efecto(accion.comando):
|
|
return "accion", accion.id, "(no se ejecuta; solo emparejamiento)"
|
|
|
|
salida, _ = ejecuta_lectura(accion.comando, arg)
|
|
return "accion", accion.id, salida
|
|
|
|
|
|
def escenarios(acciones):
|
|
"""Construye las tareas de examen. Cada una: (texto, comprobacion)."""
|
|
tareas = []
|
|
|
|
# un turno por accion: la tarea es su frase, se espera su id
|
|
for a in acciones:
|
|
if not a["frases"]:
|
|
continue
|
|
texto = a["frases"][0]
|
|
if a.get("captura"):
|
|
texto += " " + random.choice(RELLENOS)
|
|
tareas.append({
|
|
"tipo": "accion", "texto": texto, "espera_id": a["id"],
|
|
"turnos": [texto],
|
|
})
|
|
|
|
# multi-turno: probar el estado (lista -> elegir -> buscar dentro)
|
|
tareas.append({
|
|
"tipo": "multi", "texto": "buscar y elegir",
|
|
"turnos": ["busca el fichero reactor", "abre el primero", "busca dentro def"],
|
|
"espera": ["accion:grep_recursivo|accion:buscar_fichero", "seleccion", "dentro"],
|
|
})
|
|
|
|
# control negativo: charla que NO debe disparar accion
|
|
for frase in ["cuentame un chiste sobre gatos", "que opinas de todo esto",
|
|
"estoy cansado hoy la verdad", "no se muy bien que hacer ahora"]:
|
|
tareas.append({"tipo": "charla", "texto": frase, "turnos": [frase]})
|
|
|
|
return tareas
|
|
|
|
|
|
def una_tanda(ejecutor, motor, ronda, muestras):
|
|
acciones = ejecutor.cat.acciones
|
|
base_acciones = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
|
else CATALOGO_FUENTE))["acciones"]
|
|
tareas = escenarios(base_acciones)
|
|
|
|
# sintetizar todos los turnos
|
|
shutil.rmtree(WAVS, ignore_errors=True)
|
|
os.makedirs(WAVS, exist_ok=True)
|
|
plan = [] # (fichero, tarea_idx, turno_idx, texto)
|
|
n = 0
|
|
for ti, tarea in enumerate(tareas):
|
|
for tj, texto in enumerate(tarea["turnos"]):
|
|
n += 1
|
|
nombre = f"{n:05d}.wav"
|
|
if dice(texto, random.choice(VOCES), random.choice(VELOCIDADES),
|
|
random.choice(RUIDOS), os.path.join(WAVS, nombre)):
|
|
plan.append((nombre, ti, tj, texto))
|
|
|
|
log(f"ronda {ronda}: examinador dice {len(plan)} tareas, jarvis las oye...")
|
|
oidos = oye([p[0] for p in plan])
|
|
|
|
# correr los escenarios en orden (el estado importa en los multi-turno)
|
|
resultados = {"bien": 0, "reconoce_mal": 0, "accion_mal": 0,
|
|
"ejecuta_mal": 0, "charla_colada": 0}
|
|
candidatos = defaultdict(lambda: defaultdict(int))
|
|
por_tarea = defaultdict(list)
|
|
for nombre, ti, tj, texto in plan:
|
|
por_tarea[ti].append((tj, nombre, texto))
|
|
|
|
for ti in sorted(por_tarea):
|
|
tarea = tareas[ti]
|
|
ejecutor.lista = [] # cada escenario empieza limpio
|
|
ejecutor.fichero = None
|
|
for tj, nombre, texto in sorted(por_tarea[ti]):
|
|
oido = oidos.get(nombre, "").strip()
|
|
if not oido:
|
|
continue
|
|
tipo, dato, salida = ejecutor.procesa(oido)
|
|
|
|
# dejar la conversacion en el log, legible
|
|
log(f" examinador: «{texto}»", a_dialogo=True)
|
|
log(f" jarvis oye: «{oido}» -> {tipo}:{dato}", a_dialogo=True)
|
|
|
|
veredicto = verifica(tarea, tj, texto, oido, tipo, dato, salida,
|
|
motor, candidatos)
|
|
log(f" veredicto: {veredicto}\n", a_dialogo=True)
|
|
resultados[veredicto] = resultados.get(veredicto, 0) + 1
|
|
|
|
total = sum(resultados.values())
|
|
bien = resultados["bien"]
|
|
log(f"ronda {ronda}: {bien}/{total} bien · " +
|
|
" · ".join(f"{k} {v}" for k, v in resultados.items() if k != "bien" and v))
|
|
return resultados, candidatos
|
|
|
|
|
|
def verifica(tarea, tj, texto, oido, tipo, dato, salida, motor, candidatos):
|
|
"""Pone nota a un turno y, si es fallo de reconocimiento, anota el candidato."""
|
|
if tarea["tipo"] == "charla":
|
|
return "bien" if tipo == "modelo" else "charla_colada"
|
|
|
|
if tarea["tipo"] == "multi":
|
|
esperado = tarea["espera"][tj]
|
|
actual = f"{tipo}:{dato}"
|
|
if any(actual.startswith(e.split("|")[0].split(":")[0] + ":") and
|
|
(e == actual or e.split(":")[0] == tipo)
|
|
for e in esperado.split("|")):
|
|
# para multi basta con que el TIPO sea el correcto
|
|
if tipo in [e.split(":")[0] for e in esperado.split("|")]:
|
|
return "bien"
|
|
return "accion_mal"
|
|
|
|
# tarea de un turno: se espera un id concreto
|
|
espera_id = tarea["espera_id"]
|
|
if tipo != "accion":
|
|
# no reconocio la orden: candidato a alias si conserva sentido
|
|
candidatos[espera_id][motor.normaliza(oido)] += 1
|
|
return "reconoce_mal"
|
|
if dato != espera_id:
|
|
return "accion_mal"
|
|
# emparejo bien; ¿la ejecucion dio algo con sentido?
|
|
if salida and ("command not found" in salida or salida == "TIMEOUT"):
|
|
return "ejecuta_mal"
|
|
return "bien"
|
|
|
|
|
|
# --- aprender: mismos filtros seguros que mejora_nocturna --------------------
|
|
VACIAS = {"que", "de", "la", "el", "me", "se", "es", "esta", "estan", "hay",
|
|
"tengo", "un", "una", "por", "con", "como", "cuanto", "cuanta"}
|
|
|
|
|
|
def contenido(frase):
|
|
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
|
|
|
|
|
def alias_seguros(candidatos, acciones, motor, cat):
|
|
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():
|
|
a = porid.get(ident)
|
|
if a is None or a.get("captura"):
|
|
continue
|
|
orig = motor.normaliza(a["frases"][0] if a["frases"] else "")
|
|
claves = contenido(orig)
|
|
for f, veces in frases.items():
|
|
if veces < 2 or len(porfrase[f]) > 1 or len(f.split()) < 2:
|
|
continue
|
|
if cat.busca(f)[0] is not None or motor.parecido(f, orig) < 0.6:
|
|
continue
|
|
pal = set(f.split())
|
|
if claves and not any(
|
|
c in pal or any(motor.parecido(c, w) > 0.8 for w in pal)
|
|
for c in claves):
|
|
continue
|
|
propuestos[ident].append((f, veces))
|
|
return propuestos
|
|
|
|
|
|
def guarda_alias(propuestos, acciones):
|
|
try:
|
|
propio = json.load(open(PROPIO))
|
|
except (OSError, json.JSONDecodeError):
|
|
propio = {"version": 1, "acciones": []}
|
|
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
|
orig = {a["id"]: a for a in acciones}
|
|
n = 0
|
|
for ident, lista in propuestos.items():
|
|
e = porid.get(ident)
|
|
if e is None:
|
|
e = dict(orig[ident]); porid[ident] = e
|
|
propio.setdefault("acciones", []).append(e)
|
|
for f, _ in lista:
|
|
if f not in e["frases"]:
|
|
e["frases"].append(f); n += 1
|
|
json.dump(propio, open(PROPIO, "w"), indent=2, ensure_ascii=False)
|
|
return n
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--horas", type=float, default=0)
|
|
ap.add_argument("--aplicar", action="store_true", help="guardar los alias que encuentre")
|
|
ap.add_argument("--muestras", type=int, default=1)
|
|
args = ap.parse_args()
|
|
|
|
for ruta, q in ((PIPER, "piper"), (WHISPER, "whisper")):
|
|
if not os.path.exists(ruta):
|
|
print(f"falta {q}"); return 1
|
|
|
|
os.makedirs(SALA, exist_ok=True)
|
|
motor = carga(MOTOR, "acciones")
|
|
sel = carga(SELECCION, "seleccion")
|
|
ejecutor = Ejecutor(motor, sel)
|
|
|
|
limite = time.time() + args.horas * 3600 if args.horas else None
|
|
log("=" * 56)
|
|
log("arranca la autoescuela (examinador + jarvis)")
|
|
|
|
historial = []
|
|
ronda = 0
|
|
total_alias = 0
|
|
while True:
|
|
ronda += 1
|
|
ini = time.time()
|
|
try:
|
|
resultados, candidatos = una_tanda(ejecutor, motor, ronda, args.muestras)
|
|
except Exception as e:
|
|
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
|
time.sleep(5); continue
|
|
|
|
# aprender de los fallos de reconocimiento
|
|
base = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
|
else CATALOGO_FUENTE))["acciones"]
|
|
propuestos = alias_seguros(candidatos, base, motor, ejecutor.cat)
|
|
nuevos = sum(len(v) for v in propuestos.values())
|
|
if nuevos and args.aplicar:
|
|
total_alias += guarda_alias(propuestos, base)
|
|
ejecutor.recarga()
|
|
log(f"ronda {ronda}: +{nuevos} alias aplicados")
|
|
elif nuevos:
|
|
log(f"ronda {ronda}: {nuevos} alias propuestos (usa --aplicar para guardarlos)")
|
|
|
|
total = sum(resultados.values())
|
|
historial.append({
|
|
"ronda": ronda,
|
|
"acierto": round(100 * resultados["bien"] / max(1, total), 1),
|
|
"fallos": {k: v for k, v in resultados.items() if k != "bien" and v},
|
|
"minutos": round((time.time() - ini) / 60, 1),
|
|
})
|
|
json.dump({"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
|
"rondas": ronda, "alias_aplicados": total_alias,
|
|
"historial": historial[-30:]},
|
|
open(INFORME, "w"), indent=2, ensure_ascii=False)
|
|
|
|
if not args.horas:
|
|
break
|
|
if time.time() > limite:
|
|
log("se acabo el tiempo. Fin."); break
|
|
|
|
shutil.rmtree(WAVS, ignore_errors=True)
|
|
log(f"terminado tras {ronda} rondas · ultimo acierto "
|
|
f"{historial[-1]['acierto'] if historial else '?'} %")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|