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