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.
227 lines
8.9 KiB
Python
Executable file
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("SÍ")
|
|
|
|
|
|
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())
|