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.
207 lines
7.5 KiB
Python
Executable file
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())
|