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:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Construye el indice candidato uniendo lo viejo, lo cosechado y lo sintetizado.
./reindexa.py escribe datos/saber.jsonl.nuevo (NO toca el vivo)
## Que une
1. El indice vivo (datos/saber.jsonl): comandos de Linux, glosario, fichas,
servidores... todo lo que cosecha no produce. Se conserva entero.
2. cosecha.jsonl: la metodologia de pentesting que faltaba.
3. sintesis.jsonl: preguntas en castellano que apuntan a esos fragmentos.
## Indexacion multi-representacion
Cada entrada se EMBEBE por su campo `indexar` si lo tiene (las preguntas
sintetizadas), y si no por `texto` (todo lo demas). Asi una entrada de pregunta
se compara con la consulta del usuario pregunta-contra-pregunta, pero lo que se
le muestra al cerebro sigue siendo el fragmento literal de `texto`. busca.py no
cambia: solo lee `texto` y `v`.
## No destruye nada
Escribe a `saber.jsonl.nuevo`. Quien decide si sustituye al vivo es evalua.py +
correr_noche.sh, y solo si mide que es mejor, guardando antes una copia.
## Deduplicado
Por los primeros 120 caracteres del texto normalizado (para las entradas
normales) o de la pregunta (para las sintetizadas). OSCP_APUNTES duplica apuntes
de primer nivel; esto los colapsa.
"""
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")
# La base son las entradas que cosecha NO produce (comandos Linux, glosario,
# fichas). Por defecto el indice vivo, pero se puede fijar con SABER_BASE al
# indice ORIGINAL para una reconstruccion limpia que no apile sintesis sobre
# sintesis y no infle el indice a cada pasada.
VIVO = os.environ.get("SABER_BASE") or os.path.join(DATOS, "saber.jsonl")
COSECHA = os.path.join(DATOS, "cosecha.jsonl")
SINTESIS = os.path.join(DATOS, "sintesis.jsonl")
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
MODELO = "minishlab/potion-multilingual-128M"
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"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", t)).strip()
def _lee(ruta, quita_v=False):
if not os.path.exists(ruta):
return
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
if quita_v:
d.pop("v", None)
yield d
def main():
entradas, vistos = [], set()
fuentes = [("vivo", VIVO, True), ("cosecha", COSECHA, False),
("sintesis", SINTESIS, False)]
cuenta = {}
for nombre, ruta, quita in fuentes:
n = 0
for d in _lee(ruta, quita_v=quita):
clave_txt = d.get("indexar") or d.get("texto", "")
firma = _norm(clave_txt)[:120]
if not firma or firma in vistos:
continue
vistos.add(firma)
entradas.append(d)
n += 1
cuenta[nombre] = n
print(f" {nombre:9s} {n:6d} entradas nuevas", flush=True)
print(f"\n total tras deduplicar: {len(entradas)}", flush=True)
print(" cargando el modelo de embeddings...", flush=True)
from model2vec import StaticModel
modelo = StaticModel.from_pretrained(MODELO)
# se embebe `indexar` si existe (la pregunta), si no `texto`
a_embeber = [(e.get("indexar") or e["texto"]) for e in entradas]
print(f" calculando {len(a_embeber)} vectores...", flush=True)
vectores = modelo.encode(a_embeber, show_progress_bar=False)
with open(NUEVO, "w", encoding="utf-8") as f:
for e, v in zip(entradas, vectores):
e["v"] = [round(float(x), 5) for x in v]
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f" escrito: {NUEVO} ({os.path.getsize(NUEVO)//1024} KB)", flush=True)
print(f" RESUMEN vivo={cuenta['vivo']} cosecha={cuenta['cosecha']} "
f"sintesis={cuenta['sintesis']} total={len(entradas)}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())