JARVIS/nucleo/saber/enriquece/evalua.py
sito 8e4bc8ad94 JARVIS: asistente de voz local para Linux
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.
2026-08-16 15:34:37 +02:00

173 lines
5.5 KiB
Python
Executable file

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