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
207
nucleo/saber/enriquece/sintetiza.py
Executable file
207
nucleo/saber/enriquece/sintetiza.py
Executable file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Genera preguntas en castellano para cada fragmento cosechado.
|
||||
|
||||
./sintetiza.py procesa datos/cosecha.jsonl (reanudable, horas)
|
||||
./sintetiza.py --muestra 5 ensena 5 y para
|
||||
|
||||
## Que problema resuelve, y por que no corrompe comandos
|
||||
|
||||
El indice usa embeddings estaticos. Su punto flaco conocido: una pregunta
|
||||
coloquial en castellano —"como saco una shell reversa"— no se parece
|
||||
vectorialmente a un fragmento tecnico en ingles con `bash -i >& /dev/tcp/...`.
|
||||
La busqueda hibrida por palabras ayuda, pero solo si coinciden literales.
|
||||
|
||||
La solucion es **indexacion multi-representacion**: por cada fragmento se generan
|
||||
las preguntas que ese fragmento responde, y se EMBEBE LA PREGUNTA apuntando al
|
||||
fragmento. Asi la consulta del usuario se compara contra otras preguntas, que es
|
||||
comparar peras con peras.
|
||||
|
||||
Clave para no meter la pata: la respuesta que se le muestra al cerebro es el
|
||||
**fragmento original, literal** (campo `texto`). El modelo local solo escribe la
|
||||
PREGUNTA (campo `indexar`). Si inventa una pregunta rara, lo peor que pasa es que
|
||||
esa entrada no recupere bien; **nunca corrompe un comando**, porque no reescribe
|
||||
la respuesta. Y el fragmento crudo sigue en el indice por su lado (lo pone
|
||||
cosecha), asi que la cobertura solo puede subir.
|
||||
|
||||
## El modelo
|
||||
|
||||
Habla con el ollama local. Usa el modelo que ya este CARGADO (mira /api/ps) para
|
||||
no forzar a la tarjeta de 4 GB a cambiar de modelo con el carril verde abierto;
|
||||
si no hay ninguno, carga qwen3.5:4b-jarvis. think:false —qwen3.5 razona por
|
||||
defecto y tardaria 20x—.
|
||||
|
||||
## Reanudable
|
||||
|
||||
Un trabajo de horas no puede perder todo si se corta. Se apunta en un .hecho por
|
||||
que linea de cosecha.jsonl iba, y al arrancar salta hasta ahi. La metodologia
|
||||
(el oro) se procesa PRIMERO, para que si no termina la noche, lo valioso este.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||
COSECHA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
|
||||
SALIDA = os.path.join(RAIZ, "datos", "sintesis.jsonl")
|
||||
MARCA = SALIDA + ".hecho"
|
||||
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
|
||||
|
||||
INSTRUCCION = """Eres un generador de preguntas para un buscador de apuntes de
|
||||
pentesting. Te doy un fragmento de apuntes tecnicos. Devuelve de UNA a TRES
|
||||
preguntas, en castellano, que una persona haria por voz y que ese fragmento
|
||||
responde.
|
||||
|
||||
Reglas:
|
||||
- Preguntas naturales y variadas, como las diria alguien: "como...", "que
|
||||
comando...", "cual es la forma de...".
|
||||
- NO respondas, NO copies comandos: solo las preguntas.
|
||||
- Si el fragmento es ruido (un titulo suelto, una lista de enlaces), devuelve
|
||||
una lista vacia.
|
||||
|
||||
Responde SOLO con JSON: {"preguntas": ["...", "..."]}"""
|
||||
|
||||
|
||||
FIJO = os.environ.get("JARVIS_MODELO") # si se fija a mano, manda
|
||||
RESPALDO = "qwen3.5:4b-jarvis"
|
||||
|
||||
|
||||
def _modelo_cargado():
|
||||
"""El modelo que ollama ya tiene en memoria, para no forzar un cambio."""
|
||||
try:
|
||||
with urllib.request.urlopen(ENDPOINT + "/api/ps", timeout=5) as r:
|
||||
m = json.load(r).get("models", [])
|
||||
if m:
|
||||
return m[0]["name"]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _modelo():
|
||||
"""Que modelo usar AHORA. Se re-mira cada tanto en vez de fijarlo al
|
||||
arrancar: en una tarjeta de 4 GB solo cabe uno, asi que si el carril verde
|
||||
tiene el suyo cargado, generamos con ESE (una pregunta no necesita el ajuste
|
||||
anti-alucinacion, solo la respuesta lo necesitaria y la respuesta es el
|
||||
fragmento literal). Asi no se fuerza a ollama a cambiar de modelo en cada
|
||||
peticion, que en esta maquina cuesta 2-3 s de recarga.
|
||||
"""
|
||||
return FIJO or _modelo_cargado() or RESPALDO
|
||||
|
||||
|
||||
MODELO = _modelo()
|
||||
|
||||
|
||||
def _pide(fragmento, modelo):
|
||||
cuerpo = json.dumps({
|
||||
"model": modelo,
|
||||
"messages": [{"role": "system", "content": INSTRUCCION},
|
||||
{"role": "user", "content": fragmento[:1400]}],
|
||||
"stream": False, "think": False,
|
||||
"options": {"temperature": 0.7, "num_predict": 200},
|
||||
"format": "json",
|
||||
}).encode()
|
||||
req = urllib.request.Request(ENDPOINT + "/api/chat", data=cuerpo,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
contenido = json.loads(r.read())["message"]["content"]
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
d = json.loads(contenido)
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r"\[.*\]", contenido, re.S)
|
||||
if not m:
|
||||
return []
|
||||
try:
|
||||
d = {"preguntas": json.loads(m.group(0))}
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
preguntas = d.get("preguntas") if isinstance(d, dict) else d
|
||||
if not isinstance(preguntas, list):
|
||||
return []
|
||||
fuera = []
|
||||
for p in preguntas:
|
||||
if isinstance(p, str) and 8 <= len(p.strip()) <= 200:
|
||||
fuera.append(p.strip())
|
||||
return fuera[:3]
|
||||
|
||||
|
||||
def fragmentos():
|
||||
"""Los fragmentos de cosecha, el ORO (metodologia) primero."""
|
||||
todos = []
|
||||
with open(COSECHA, encoding="utf-8") as f:
|
||||
for l in f:
|
||||
try:
|
||||
todos.append(json.loads(l))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
todos.sort(key=lambda d: 0 if d.get("tipo") == "metodologia" else 1)
|
||||
return todos
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--muestra", type=int, default=0)
|
||||
a = p.parse_args()
|
||||
|
||||
if not os.path.exists(COSECHA):
|
||||
print(" no hay cosecha.jsonl: corre cosecha.py primero")
|
||||
return 1
|
||||
|
||||
frags = fragmentos()
|
||||
print(f" {len(frags)} fragmentos · modelo {MODELO}", flush=True)
|
||||
|
||||
if a.muestra:
|
||||
for fr in frags[:a.muestra]:
|
||||
print(f"\n ── {fr.get('fichero')} ──")
|
||||
for q in _pide(fr["texto"], MODELO):
|
||||
print(f" P: {q}")
|
||||
return 0
|
||||
|
||||
desde = 0
|
||||
if os.path.exists(MARCA) and os.path.exists(SALIDA):
|
||||
try:
|
||||
desde = int(open(MARCA).read().strip()) + 1
|
||||
except (ValueError, OSError):
|
||||
desde = 0
|
||||
modo = "a" if desde else "w"
|
||||
hechas = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0
|
||||
if desde:
|
||||
print(f" reanudando desde el fragmento {desde} ({hechas} preguntas)", flush=True)
|
||||
|
||||
total = len(frags)
|
||||
modelo = MODELO
|
||||
with open(SALIDA, modo, encoding="utf-8", buffering=1) as out:
|
||||
for i in range(desde, total):
|
||||
# re-mirar que modelo esta cargado cada 25: si el verde se abre o se
|
||||
# cierra durante la noche, seguimos al que este en la tarjeta.
|
||||
if i % 25 == 0:
|
||||
modelo = _modelo()
|
||||
fr = frags[i]
|
||||
for q in _pide(fr["texto"], modelo):
|
||||
out.write(json.dumps({
|
||||
"tipo": "pregunta",
|
||||
"herramienta": fr.get("herramienta"),
|
||||
"perfil": fr.get("perfil"),
|
||||
"fichero": fr.get("fichero"),
|
||||
"tema": fr.get("tema", ""),
|
||||
"texto": fr["texto"], # la respuesta: el fragmento LITERAL
|
||||
"indexar": q, # lo que se embebe: la pregunta
|
||||
}, ensure_ascii=False) + "\n")
|
||||
hechas += 1
|
||||
with open(MARCA, "w") as m:
|
||||
m.write(str(i))
|
||||
if (i + 1) % 25 == 0:
|
||||
print(f" PROGRESO sintetiza hecho={i+1} total={total} "
|
||||
f"preguntas={hechas}", flush=True)
|
||||
print(f"\n hecho: {hechas} preguntas en {SALIDA}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue