#!/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())