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
159
nucleo/saber/busca.py
Normal file
159
nucleo/saber/busca.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta.
|
||||
|
||||
Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos
|
||||
de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro
|
||||
construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que
|
||||
evita que se invente flags.
|
||||
|
||||
## Por que se carga una vez y se guarda
|
||||
|
||||
El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo
|
||||
la primera vez: el modelo y los vectores se quedan en memoria. Una consulta
|
||||
despues es un producto escalar contra 2.866 filas, milisegundos.
|
||||
|
||||
## Por que no una base de datos vectorial
|
||||
|
||||
FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un
|
||||
producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y
|
||||
el indice es un jsonl que se puede abrir y leer con los ojos.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
|
||||
|
||||
_modelo = None
|
||||
_entradas = None
|
||||
_matriz = None
|
||||
|
||||
|
||||
def _carga():
|
||||
global _modelo, _entradas, _matriz
|
||||
if _entradas is not None:
|
||||
return _entradas is not False
|
||||
|
||||
if not os.path.exists(INDICE):
|
||||
_entradas = False
|
||||
return False
|
||||
|
||||
import numpy as np
|
||||
from model2vec import StaticModel
|
||||
|
||||
entradas, vectores = [], []
|
||||
with open(INDICE, 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)
|
||||
vectores.append(v)
|
||||
|
||||
_entradas = entradas
|
||||
_matriz = np.array(vectores, dtype="float32")
|
||||
# normalizar una vez: asi la similitud es un simple producto escalar
|
||||
normas = np.linalg.norm(_matriz, axis=1, keepdims=True)
|
||||
_matriz = _matriz / np.clip(normas, 1e-9, None)
|
||||
_modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
return True
|
||||
|
||||
|
||||
def _norm(t: str) -> str:
|
||||
import re
|
||||
import unicodedata
|
||||
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)
|
||||
|
||||
|
||||
# El texto normalizado de cada entrada y su nombre, cacheados para el bono.
|
||||
_texto_norm = None
|
||||
_nombre_norm = None
|
||||
|
||||
|
||||
def _bono_palabras(palabras):
|
||||
"""Un vector de bonos, uno por entrada, segun cuantas palabras compartan."""
|
||||
import numpy as np
|
||||
global _texto_norm, _nombre_norm
|
||||
if _texto_norm is None:
|
||||
_texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas]
|
||||
_nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas]
|
||||
bono = np.zeros(len(_entradas), dtype="float32")
|
||||
for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)):
|
||||
comunes = len(palabras & palabras_e)
|
||||
b = 0.08 * comunes # cada palabra compartida suma
|
||||
if nombre and nombre in palabras: # y nombrar la herramienta, mucho
|
||||
b += 0.5
|
||||
bono[i] = b
|
||||
return bono
|
||||
|
||||
|
||||
def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list:
|
||||
"""Los fragmentos mas parecidos a la pregunta, mejor primero.
|
||||
|
||||
Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el
|
||||
texto. perfil filtra por carpeta de COFRE si se quiere acotar.
|
||||
"""
|
||||
if not _carga() or not pregunta.strip():
|
||||
return []
|
||||
import numpy as np
|
||||
|
||||
q = _modelo.encode([pregunta])[0]
|
||||
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||
sim = _matriz @ q
|
||||
|
||||
# Impulso por palabras exactas (busqueda hibrida).
|
||||
#
|
||||
# Los embeddings estaticos fallan cuando la pregunta va en castellano y el
|
||||
# texto en ingles —"cambiar permisos" no se parece a "change file mode
|
||||
# bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o
|
||||
# comparte palabras con el texto, eso es una señal fuerte que la similitud
|
||||
# semantica sola no aprovecha. Se suma un bono por cada palabra de la
|
||||
# pregunta que aparezca, y uno grande si coincide el nombre de la
|
||||
# herramienta: asi "usa nmap para..." lleva nmap al primer puesto.
|
||||
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
|
||||
if palabras:
|
||||
bono = _bono_palabras(palabras)
|
||||
sim = sim + bono
|
||||
|
||||
orden = np.argsort(-sim)[: cuantos * 6]
|
||||
salida, vistos = [], set()
|
||||
for i in orden:
|
||||
e = _entradas[i]
|
||||
if perfil and e.get("perfil") != perfil:
|
||||
continue
|
||||
# sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel.
|
||||
# Se comparan los primeros 120 caracteres, que basta para reconocerlos.
|
||||
firma = e["texto"][:120]
|
||||
if firma in vistos:
|
||||
continue
|
||||
vistos.add(firma)
|
||||
salida.append({**e, "sim": round(float(sim[i]), 3)})
|
||||
if len(salida) >= cuantos:
|
||||
break
|
||||
return salida
|
||||
|
||||
|
||||
def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str:
|
||||
"""Lo mismo, pero ya montado como texto para meterle al cerebro.
|
||||
|
||||
Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos
|
||||
que no vienen a cuento es lo que le hace mezclar herramientas que no tienen
|
||||
nada que ver. Mejor darle poco y bueno que mucho y ruidoso.
|
||||
"""
|
||||
trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo]
|
||||
if not trozos:
|
||||
return ""
|
||||
lineas = ["De los apuntes de COFRE del usuario:\n"]
|
||||
for t in trozos:
|
||||
fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}"
|
||||
lineas.append(f"--- {fuente} ---\n{t['texto']}\n")
|
||||
return "\n".join(lineas)
|
||||
|
||||
|
||||
def disponible() -> bool:
|
||||
return os.path.exists(INDICE)
|
||||
Loading…
Add table
Add a link
Reference in a new issue