JARVIS/nucleo/saber/enriquece/sintetiza.py
sito 8e4bc8ad94 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.
2026-08-16 15:34:37 +02:00

207 lines
7.5 KiB
Python
Executable file

#!/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())