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
113
nucleo/saber/enriquece/reindexa.py
Executable file
113
nucleo/saber/enriquece/reindexa.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue