JARVIS/config/aprender.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

148 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Enseña acciones nuevas a partir de lo que no entendió.
python3 ~/COFRE/CODERS/JARVIS/config/aprender.py
Cada vez que dices algo que no encaja con ninguna accion, el panel lo anota en
config/jarvis-sin-accion.jsonl. Esto lo lee, agrupa lo repetido y pregunta a
que accion pertenece cada frase; lo que decidas se escribe como alias en
config/jarvis-acciones.json, que el catalogo carga ANTES que el del paquete.
Por que asi y no entrenando un modelo: los fallos del reconocedor son
sistematicos, no aleatorios. "abre el correo" se oye "a ver el correo" siempre,
no una de cada diez veces. Un alias lo arregla del todo, se lee, se borra si
molesta y no cuesta un milisegundo. Reentrenar costaria horas para acertar
"casi siempre".
"""
import json
import os
import sys
from collections import Counter
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
REGISTRO = os.path.join(CONFIG, "jarvis-sin-accion.jsonl")
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
PAQUETE = os.path.expanduser(
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
"active/files/share/newelle/acciones/acciones.json")
def carga_catalogo():
acciones = {}
for ruta in (PAQUETE, PROPIO):
try:
with open(ruta) as f:
for a in json.load(f).get("acciones", []):
acciones.setdefault(a["id"], a)
except (OSError, json.JSONDecodeError):
pass
return acciones
def carga_fallos():
"""Las frases sin accion, agrupadas por veces que se han dicho."""
cuenta = Counter()
try:
with open(REGISTRO) as f:
for linea in f:
try:
texto = json.loads(linea).get("texto", "").strip()
except json.JSONDecodeError:
continue
if texto:
cuenta[texto] += 1
except OSError:
pass
return cuenta
def guarda_alias(nuevos):
"""Añade los alias al catalogo propio, respetando lo que ya hubiera."""
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", [])}
catalogo = carga_catalogo()
for ident, frases in nuevos.items():
if ident in porid:
entrada = porid[ident]
else:
# se copia la del paquete entera: el catalogo propio se carga antes
# y sustituye a la original, asi que tiene que traer su comando
entrada = dict(catalogo[ident])
porid[ident] = entrada
propio.setdefault("acciones", []).append(entrada)
for f in frases:
if f not in entrada["frases"]:
entrada["frases"].append(f)
with open(PROPIO, "w") as f:
json.dump(propio, f, indent=2, ensure_ascii=False)
return PROPIO
def main():
fallos = carga_fallos()
if not fallos:
print("No hay nada sin entender todavia.")
print(f"Se anota solo en {REGISTRO}")
return 0
catalogo = carga_catalogo()
ids = sorted(catalogo)
print(f"{len(fallos)} frases distintas no encajaron con ninguna accion.\n")
nuevos = {}
for texto, veces in fallos.most_common():
print(f' "{texto}" ({veces} {"vez" if veces == 1 else "veces"})')
print(" escribe el id de la accion, 'l' para listarlos, "
"Enter para saltar, 'q' para terminar")
try:
resp = input(" > ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if resp == "q":
break
if resp == "l":
for i, ident in enumerate(ids):
print(f" {ident:<24}", end="\n" if i % 3 == 2 else "")
print()
try:
resp = input(" > ").strip()
except (EOFError, KeyboardInterrupt):
break
if not resp:
continue
if resp not in catalogo:
print(f" '{resp}' no existe, saltada")
continue
nuevos.setdefault(resp, []).append(texto)
print(f" anotada como alias de {resp}")
if not nuevos:
print("\nNada que guardar.")
return 0
ruta = guarda_alias(nuevos)
total = sum(len(v) for v in nuevos.values())
print(f"\n{total} alias guardados en {ruta}")
print("Se aplican al abrir la pestaña JARVIS otra vez.")
if input("¿Vaciar el registro de frases no entendidas? [s/N] ").strip().lower() == "s":
try:
os.remove(REGISTRO)
print("registro vaciado")
except OSError as e:
print(f"no se pudo vaciar: {e}")
return 0
if __name__ == "__main__":
sys.exit(main())