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

227 lines
8.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Saca alias de lo que el usuario dijo y JARVIS no entendio.
Es la fuente mas valiosa de mejora porque son fallos REALES: el panel apunta en
jarvis-sin-accion.jsonl toda frase que no encajo en ninguna accion. Con 488
lineas acumuladas y nadie leyendolas, ahi habia meses de mejoras sin recoger.
Lo dificil no es proponer, es NO proponer de mas. Un alias malo desvia una
orden buena, y eso es peor que no entender: el usuario dice una cosa y pasa
otra. Los filtros de abajo son casi todo el fichero por esa razon.
proponer_alias.py enseña lo que propondria
proponer_alias.py --aplicar lo escribe en el generador
"""
import argparse
import collections
import difflib
import importlib.util
import json
import os
import re
import sys
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REGISTRO = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl")
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
APLICADOS = os.path.join(RAIZ, "config", "alias-aplicados.json")
# Lo que dice JARVIS: si aparece en el registro es ECO —se oye a si mismo por
# el altavoz— y no es una orden de nadie. Era el 21% del registro.
def es_eco(frase, acuses):
l = frase.lower()
if "señor" in l:
return True
return any(a and a in l for a in acuses)
def parece_orden(frase):
"""Un filtro barato antes de gastar el modelo: ¿esto pide algo?
La charla no lleva verbo de mando. Sin este filtro entran cosas como
"Me estas escuchando" o "y que te voy a hacer", que por parecido de letras
puntuan alto con cualquier frase corta del catalogo.
"""
l = " " + frase.lower() + " "
VERBOS = (" pon ", " ponme ", " pongas ", " poner ", " abre ", " abreme ",
" abrir ", " abras ", " busca ", " buscame ", " buscar ", " busques ",
" dime ", " dame ", " muestra ", " muestrame ", " ensename ",
" ejecuta ", " lanza ", " haz ", " hazme ", " mira ", " comprueba ",
" cierra ", " manda ", " pasa ", " mueve ", " maximiza ", " minimiza ",
" centra ", " reproduce ", " cuanto ", " cuanta ", " cuantos ",
" cuantas ", " que hora ", " donde ", " cual ")
return any(v in l for v in VERBOS)
def el_modelo_lo_confirma(frase, accion, modelo="qwen3.5:4b-jarvis"):
"""Que un modelo diga si la frase pide de verdad esa accion.
El parecido de letras no distingue significado: "me estas escuchando" saca
0.57 con las frases de la accion de puertos, y con eso se colaria un alias
que desvia ordenes buenas. Un alias malo es peor que no entender, porque el
usuario pide una cosa y pasa otra.
Ante la duda, NO. Si el modelo no contesta o contesta cualquier cosa, se
descarta: dejar pasar un alias dudoso cuesta mas que perderlo.
"""
import urllib.request
ejemplo = accion.frases[0] if accion.frases else accion.id
prompt = (
"Decide si dos frases piden LO MISMO. Responde solo SI o NO.\n\n"
"Ejemplos:\n"
"A: \"cuanto espacio queda\" B: \"dime cuanto disco me queda\" -> SI\n"
"A: \"cuanto espacio queda\" B: \"que tal estas\" -> NO\n"
"A: \"abre una terminal\" B: \"sacame una consola\" -> SI\n"
"A: \"abre una terminal\" B: \"me estas escuchando\" -> NO\n"
"A: \"que temperatura hay\" B: \"cuentame un chiste\" -> NO\n\n"
f"A: \"{ejemplo}\" B: \"{frase}\" -> "
)
try:
carga = json.dumps({"model": modelo, "prompt": prompt, "stream": False,
"think": False, "options": {"temperature": 0}}).encode()
req = urllib.request.Request("http://127.0.0.1:11434/api/generate",
data=carga,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
respuesta = json.load(r).get("response", "").strip().upper()
except Exception:
return False
return respuesta.startswith("SI") or respuesta.startswith("")
def cargar_motor():
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def frases_del_registro():
if not os.path.exists(REGISTRO):
return []
fuera = []
for linea in open(REGISTRO, encoding="utf-8"):
try:
t = json.loads(linea).get("texto", "")
except Exception:
t = linea.strip()
t = " ".join(t.split())
if t:
fuera.append(t)
return fuera
def main():
p = argparse.ArgumentParser()
p.add_argument("--aplicar", action="store_true")
p.add_argument("--tope", type=int, default=3,
help="cuantos alias como mucho por vuelta")
args = p.parse_args()
m = cargar_motor()
cat = m.Catalogo([CATALOGO])
acciones = {a.id: a for a in cat.acciones}
acuses = {a.acuse.lower().strip(". ") for a in cat.acciones if a.acuse}
frases = frases_del_registro()
if not frases:
print("no hay registro de frases sin entender")
return 1
try:
ya = set(json.load(open(APLICADOS)))
except Exception:
ya = set()
# 1. fuera el eco y lo que ya se entiende hoy (el catalogo cambia)
candidatas = []
for f in frases:
if f in ya or es_eco(f, acuses):
continue
if cat.busca(f)[0] is not None:
continue
candidatas.append(f)
# 2. lo que se repite es lo que importa: una frase dicha una vez puede ser
# un error de transcripcion; dicha tres veces es como habla el usuario
cuenta = collections.Counter(candidatas)
propuestas = []
for frase, veces in cuenta.most_common():
norm = m.normaliza(frase)
palabras = norm.split()
# 3. filtros duros
if not (3 <= len(palabras) <= 12):
continue
if re.search(r"[<>{}\[\]|@#_*]", frase):
continue
# 4. a que accion se parece mas, comparando con TODAS sus frases
mejor, punto = None, 0.0
for a in cat.acciones:
if a.captura:
continue # el relleno de prueba se horneraria en el alias
for f2 in a.frases:
r = difflib.SequenceMatcher(None, norm, f2).ratio()
if r > punto:
mejor, punto = a, r
# 5. ni muy lejos (seria inventarse) ni muy cerca (ya lo cogeria)
if mejor is None or not (0.55 <= punto < 0.86):
continue
# 6. tiene que compartir una palabra con significado, o el alias
# convierte cualquier ruido en una orden
suyas = {w for f2 in mejor.frases for w in f2.split() if len(w) > 3}
if not (set(w for w in palabras if len(w) > 3) & suyas):
continue
# 7. y no puede encajar en dos acciones a la vez
otras = [a for a in cat.acciones
if a is not mejor and not a.captura
and max((difflib.SequenceMatcher(None, norm, f2).ratio()
for f2 in a.frases), default=0) >= punto - 0.05]
if otras:
continue
# 8. tiene que parecer una orden, no charla
if not parece_orden(frase):
continue
# 9. y el modelo tiene que confirmar que pide ESA accion. Es lo unico
# que distingue significado de coincidencia de letras.
if not el_modelo_lo_confirma(frase, mejor):
continue
propuestas.append((frase, norm, mejor.id, punto, veces))
if not propuestas:
print("nada que proponer: ninguna frase pasa los filtros")
return 1
propuestas = propuestas[:args.tope]
for frase, norm, ident, punto, veces in propuestas:
print(f" {ident:22s}\"{frase[:52]}\" (x{veces}, parecido {punto:.2f})")
if not args.aplicar:
return 0
# 8. se escribe en el GENERADOR, que es la fuente. Editar el JSON seria
# perder el cambio la proxima vez que alguien regenere.
gen = os.path.join(RAIZ, "newelle", "data", "acciones", "_generar.py")
texto = open(gen, encoding="utf-8").read()
puestos = 0
for frase, norm, ident, punto, veces in propuestas:
patron = re.compile(r'(\("' + re.escape(ident) + r'", ")([^"]*)(")')
mm = patron.search(texto)
if not mm or norm in mm.group(2):
continue
texto = texto[:mm.start(2)] + mm.group(2) + "|" + norm + texto[mm.end(2):]
puestos += 1
ya.add(frase)
if puestos:
open(gen, "w", encoding="utf-8").write(texto)
os.system(f"cd {os.path.dirname(gen)} && python3 _generar.py >/dev/null 2>&1")
json.dump(sorted(ya), open(APLICADOS, "w"), ensure_ascii=False, indent=1)
print(f"{puestos} alias aplicados al generador")
return 0 if puestos else 1
if __name__ == "__main__":
sys.exit(main())