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