#!/usr/bin/env python3
"""Mide si el indice candidato recupera mejor que el vivo. Con numeros.

    ./evalua.py                        compara vivo contra saber.jsonl.nuevo
    ./evalua.py A.jsonl B.jsonl        compara dos indices cualesquiera

## Que mide

Un conjunto de preguntas de pentesting (eval_set.jsonl), cada una con las
palabras que un fragmento CORRECTO tiene que contener (un comando, una
herramienta, un patron). Para cada indice se busca la pregunta y se mira si
alguno de los 5 primeros resultados contiene lo esperado.

  hit@1   la pregunta se resuelve con el PRIMER resultado
  hit@5   se resuelve con alguno de los cinco primeros
  sim     similitud media del mejor resultado

La busqueda replica la de busca.py (coseno + bono por palabras) para que la
comparacion sea justa: lo unico que cambia entre las dos columnas es el indice.

## Por que asi y no "le pregunte al modelo si estaba bien"

Un juez-LLM sobre una tarjeta de 4 GB es lento y ruidoso. Esto es determinista,
reproducible y honesto: la palabra esperada esta o no esta en el texto
recuperado. Es una cota inferior de la calidad —un fragmento puede ser util sin
contener el literal— pero sirve para COMPARAR dos indices, que es lo que decide
si se promociona el candidato.
"""
import json
import os
import re
import sys
import unicodedata

AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
DATOS = os.path.join(RAIZ, "datos")
VIVO = os.path.join(DATOS, "saber.jsonl")
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO = "minishlab/potion-multilingual-128M"

_modelo = None


def _norm(t):
    t = unicodedata.normalize("NFD", (t or "").lower())
    t = "".join(c for c in t if unicodedata.category(c) != "Mn")
    return re.sub(r"[^a-z0-9 ]", " ", t)


def _carga_modelo():
    global _modelo
    if _modelo is None:
        from model2vec import StaticModel
        _modelo = StaticModel.from_pretrained(MODELO)
    return _modelo


def _carga_indice(ruta):
    import numpy as np
    entradas, vs = [], []
    with open(ruta, encoding="utf-8") as f:
        for l in f:
            try:
                d = json.loads(l)
            except json.JSONDecodeError:
                continue
            v = d.pop("v", None)
            if v is None:
                continue
            entradas.append(d)
            vs.append(v)
    M = np.array(vs, dtype="float32")
    M /= np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
    txt = [set(_norm(e["texto"]).split()) for e in entradas]
    nom = [_norm(e.get("herramienta", "")) for e in entradas]
    return entradas, M, txt, nom


def _busca(indice, pregunta, cuantos=5):
    import numpy as np
    entradas, M, txt, nom = indice
    q = _carga_modelo().encode([pregunta])[0]
    q = q / max(float(np.linalg.norm(q)), 1e-9)
    sim = M @ q
    palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
    if palabras:
        bono = np.zeros(len(entradas), dtype="float32")
        for i in range(len(entradas)):
            b = 0.08 * len(palabras & txt[i])
            if nom[i] and nom[i] in palabras:
                b += 0.5
            bono[i] = b
        sim = sim + bono
    orden = np.argsort(-sim)[: cuantos * 4]
    salida, vistos = [], set()
    for i in orden:
        firma = entradas[i]["texto"][:120]
        if firma in vistos:
            continue
        vistos.add(firma)
        salida.append((float(sim[i]), entradas[i]["texto"]))
        if len(salida) >= cuantos:
            break
    return salida


def evalua(ruta):
    indice = _carga_indice(ruta)
    casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
    hit1 = hit5 = 0
    simtop = 0.0
    fallos = []
    for c in casos:
        esperado = [_norm(k).strip() for k in c["espera"]]
        res = _busca(indice, c["pregunta"])
        simtop += res[0][0] if res else 0.0
        textos = [_norm(t) for _, t in res]
        acierta = lambda t: any(k and k in t for k in esperado)
        if res and acierta(textos[0]):
            hit1 += 1
        if any(acierta(t) for t in textos):
            hit5 += 1
        else:
            fallos.append(c["pregunta"])
    n = len(casos)
    return {"n": n, "hit1": hit1, "hit5": hit5,
            "sim": simtop / n if n else 0.0, "fallos": fallos}


def _pinta(nombre, r):
    print(f"  {nombre}")
    print(f"    hit@1  {r['hit1']:3d}/{r['n']}  ({100*r['hit1']//r['n']}%)")
    print(f"    hit@5  {r['hit5']:3d}/{r['n']}  ({100*r['hit5']//r['n']}%)")
    print(f"    sim    {r['sim']:.3f}")


def main():
    a = sys.argv[1] if len(sys.argv) > 1 else VIVO
    b = sys.argv[2] if len(sys.argv) > 2 else NUEVO
    if not os.path.exists(b):
        print(f"  no existe el candidato: {b}")
        return 1

    print("Evaluando el indice VIVO...", flush=True)
    rv = evalua(a)
    print("Evaluando el indice CANDIDATO...", flush=True)
    rn = evalua(b)

    print("\n" + "=" * 44)
    _pinta("VIVO      " + os.path.basename(a), rv)
    print()
    _pinta("CANDIDATO " + os.path.basename(b), rn)
    print("=" * 44)

    mejora5 = rn["hit5"] - rv["hit5"]
    mejora1 = rn["hit1"] - rv["hit1"]
    print(f"\n  hit@5: {mejora5:+d}   hit@1: {mejora1:+d}")

    # el veredicto que lee correr_noche.sh
    promociona = rn["hit5"] >= rv["hit5"] and rn["hit1"] >= rv["hit1"] - 1
    print(f"  VEREDICTO {'PROMOCIONAR' if promociona else 'MANTENER'}")

    if rn["fallos"]:
        print(f"\n  el candidato aun no resuelve {len(rn['fallos'])}:")
        for q in rn["fallos"][:12]:
            print(f"    · {q}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
