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.
173 lines
5.5 KiB
Python
Executable file
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())
|