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.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
173
nucleo/saber/enriquece/evalua.py
Executable file
173
nucleo/saber/enriquece/evalua.py
Executable file
|
|
@ -0,0 +1,173 @@
|
|||
#!/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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue