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,110 @@
# Enriquecer el RAG a nivel de pentesting profesional
El RAG del naranja ya funcionaba, pero se dejaba fuera lo mejor que hay en el
disco. Medido antes de empezar: **de las 72 notas del `OSCP_Vault` —la
metodologia OSCP escrita a mano, con comandos reales— había 0 en el índice.**
`indexa.py` las descartaba por dos motivos a la vez: el nombre (`SQL
injection.md` no lleva "notas" ni "guia") y la profundidad (viven a 5 niveles).
Este pipeline las rescata, multiplica su recuperabilidad con preguntas
generadas, y **mide** que el resultado es mejor antes de tocar nada.
## Correrlo
```bash
./correr_noche.sh # las cuatro fases, y decide si promociona
./correr_noche.sh --sin-promo # igual, pero deja el vivo intacto
```
Es reanudable: cada fase se salta si ya está hecha, así que si se corta,
relanzarlo continúa. La fase larga (síntesis) es reanudable fragmento a
fragmento.
## Las cuatro fases
| | Script | Qué hace | Coste |
|---|---|---|---|
| 1 | `cosecha.py` | rescata la metodología de COFRE que faltaba | segundos, CPU |
| 2 | `sintetiza.py` | preguntas en castellano por cada fragmento | **horas**, modelo local |
| 3 | `reindexa.py` | une todo y calcula vectores → `saber.jsonl.nuevo` | minutos, CPU |
| 4 | `evalua.py` | mide recuperación vivo vs candidato | segundos |
### 1 · Cosecha
Donde `indexa.py` es prudente (solo ficheros que *parecen* documentación, 3
niveles), esto es agresivo con el oro: las carpetas de metodología (OSCP vaults,
apuntes, cheatsheets) enteras, sin límite de profundidad. Fuera de ahí pone un
**tope por herramienta**: si un repo tiene más de 25 documentos, es una base de
datos (exploitdb son 29.925 ficheros), no unos apuntes, y solo se coge su
README. Así el oro no se ahoga en ruido.
### 2 · Síntesis — indexación multi-representación
El punto flaco de los embeddings estáticos: "cómo saco una shell reversa" no se
parece vectorialmente a `bash -i >& /dev/tcp/...`. La solución es **embeber la
pregunta y devolver el fragmento**. Por cada fragmento, el modelo local escribe
las preguntas que responde; se indexa la pregunta, pero lo que se le muestra al
cerebro es el fragmento **literal**.
Clave: el modelo **solo escribe preguntas, nunca reescribe comandos**. Si
inventa una pregunta rara, esa entrada recupera peor y ya está; no corrompe una
respuesta. Y el fragmento crudo sigue en el índice por su lado.
### 3 · Reindexado
Une el índice vivo (comandos Linux, glosario, fichas), la cosecha y la síntesis;
deduplica por prefijo; calcula los vectores con el mismo `model2vec` de siempre.
Escribe a `saber.jsonl.nuevo`. **No toca el vivo.** `busca.py` no cambia.
### 4 · Evaluación
`eval_set.jsonl` son ~45 preguntas de pentesting con las palabras que un
fragmento correcto debe contener. Mide `hit@1`, `hit@5` y similitud media, del
índice vivo contra el candidato. Determinista y reproducible, sin juez-LLM.
## La decisión, y por qué es segura
El índice vivo **solo se sustituye si la evaluación dice que el candidato es
mejor**, y antes se guarda `saber.jsonl.antes-<fecha>`. Si empeora, se queda el
vivo y el candidato espera revisión. Siempre reversible con un `cp`.
## Ficheros que genera (en `../../datos/`)
cosecha.jsonl la metodología rescatada, sin vectores
sintesis.jsonl las preguntas generadas (+ .hecho, el marcador)
saber.jsonl.nuevo el índice candidato, con vectores
informe_rag.txt los números de la evaluación
noche_rag.log el registro de la noche
saber.jsonl.antes-* copia del índice anterior, si se promocionó
## Iteraciones posteriores a la primera noche
- **Troceado que no tira comandos cortos** (`cosecha.py`). El troceado por
encabezado descartaba toda sección de menos de 60 caracteres, y eso perdía
el oro: `## Detección time-based` + `' AND SLEEP(5)-- -` son 50. Ahora una
sección corta se pega a la siguiente y cada fragmento lleva delante el título
de la nota. `SLEEP` pasó de 0 a 13 apariciones en el índice; hit@1 80→82 %.
El salto en la métrica es pequeño porque las preguntas que quedan son
cross-lingual, y de eso no salva el troceado.
- **`experimento_embedder.py`** — mide, sin tocar nada, si un transformer (e5,
bge) supera al model2vec estático. **Resultado (16 ago): NO compensa.**
`intfloat/multilingual-e5-base` empató con el estático (hit@1 86 %, hit@5
95 % los dos): arregla las 2 preguntas que el estático fallaba, pero rompe
otras 2 distintas. A cambio pediría ~50-150 ms por consulta en CPU y una
dependencia de torch en cada búsqueda. Medido antes de cambiar → **el
estático se queda.** El trabajo de recuperación lo hace la búsqueda híbrida
(coseno + bono por palabras), no el embedder, y por eso subir el embedder no
mueve la aguja. Reproducible: `./experimento_embedder.py`.
- **Re-síntesis limpia** sobre el troceado nuevo (`SABER_BASE` en `reindexa.py`
reconstruye desde el índice original en vez de apilar, para no inflar). Da a
los comandos rescatados (SLEEP y demás) su gancho-pregunta en castellano, que
es lo que de verdad ataca las consultas cross-lingual —no el embedder—.
## Nota sobre la tarjeta
La síntesis usa el modelo local por `127.0.0.1:11434`. Fija `qwen3.5:4b-jarvis`
(el naranja, que no inventa comandos). Si dejas el TUI verde abierto, ollama
tiene que cambiar de modelo en cada petición y la noche va más lenta: para la
síntesis más rápida, cierra el verde.

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# El pipeline de la noche: mejora el RAG hasta nivel pentesting profesional.
#
# ./correr_noche.sh corre las cuatro fases y decide
# ./correr_noche.sh --sin-promo igual, pero NO sustituye el indice vivo
#
# ── Que hace, en orden ─────────────────────────────────────────────────────
#
# 1. COSECHA rescata la metodologia de COFRE que el indexador se dejaba
# (el OSCP_Vault entero, 0 de 72 notas estaban en el indice).
# 2. SINTETIZA por cada fragmento, preguntas en castellano que lo encuentran.
# Es la fase larga: horas, con el modelo local. Reanudable.
# 3. REINDEXA une vivo + cosecha + sintesis y calcula los vectores. Escribe
# a saber.jsonl.nuevo. NO toca el indice vivo.
# 4. EVALUA mide recuperacion del vivo contra el candidato, con numeros.
#
# ── No rompe nada ──────────────────────────────────────────────────────────
#
# El indice vivo solo se sustituye si evalua DICE que el candidato es mejor, y
# antes se guarda una copia con fecha. Si el candidato empeora, se queda el
# vivo y el candidato espera revision. Reversible siempre.
#
# Cada fase se salta si ya esta hecha, asi que relanzarlo continua donde iba.
set -uo pipefail
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
RAIZ=$(cd -P "$AQUI/../.." && pwd) # .../nucleo
DATOS="$RAIZ/datos"
PY="$RAIZ/venv/bin/python"
[ -x "$PY" ] || PY=python3
LOG="$DATOS/noche_rag.log"
# El modelo NO se fija aqui a proposito: sintetiza.py mira que tiene ollama
# cargado y usa ESE, para no forzar cambios en la tarjeta de 4 GB. Si el verde
# esta abierto, generara con el verde; si no, cae al naranja. Solo se fuerza si
# exportas JARVIS_MODELO tu. Aun asi, para la noche mas rapida y limpia, lo
# suyo es cerrar el TUI verde: con un solo modelo ollama no recarga nada.
PROMO=si
[ "${1:-}" = "--sin-promo" ] && PROMO=no
fecha() { date '+%Y-%m-%d %H:%M:%S'; }
fase() { echo "" | tee -a "$LOG"; echo "[$(fecha)] === FASE $* ===" | tee -a "$LOG"; }
echo "[$(fecha)] arranca el pipeline de la noche (modelo $JARVIS_MODELO)" | tee -a "$LOG"
# ── 1. COSECHA ─────────────────────────────────────────────────────────────
fase "1/4 COSECHA"
if [ -s "$DATOS/cosecha.jsonl" ]; then
echo " ya hecha ($(wc -l <"$DATOS/cosecha.jsonl") fragmentos), se salta" | tee -a "$LOG"
else
"$PY" "$AQUI/cosecha.py" 2>&1 | tee -a "$LOG"
fi
# ── 2. SINTETIZA (la larga) ────────────────────────────────────────────────
fase "2/4 SINTETIZA (horas; reanudable)"
# hecha del todo = el .hecho llego al ultimo fragmento
TOTAL=$(wc -l <"$DATOS/cosecha.jsonl")
HECHO=$(cat "$DATOS/sintesis.jsonl.hecho" 2>/dev/null || echo -1)
if [ "$HECHO" -ge "$((TOTAL - 1))" ] 2>/dev/null; then
echo " ya completa ($(wc -l <"$DATOS/sintesis.jsonl" 2>/dev/null || echo 0) preguntas)" | tee -a "$LOG"
else
"$PY" "$AQUI/sintetiza.py" 2>&1 | tee -a "$LOG"
fi
# ── 3. REINDEXA ────────────────────────────────────────────────────────────
fase "3/4 REINDEXA"
"$PY" "$AQUI/reindexa.py" 2>&1 | tee -a "$LOG"
# ── 4. EVALUA ──────────────────────────────────────────────────────────────
fase "4/4 EVALUA"
INFORME="$DATOS/informe_rag.txt"
"$PY" "$AQUI/evalua.py" 2>&1 | tee "$INFORME" | tee -a "$LOG"
VEREDICTO=$(grep -oE 'VEREDICTO (PROMOCIONAR|MANTENER)' "$INFORME" | awk '{print $2}' | tail -1)
# ── Decision ───────────────────────────────────────────────────────────────
fase "DECISION"
if [ "$PROMO" = no ]; then
echo " --sin-promo: dejo el candidato en saber.jsonl.nuevo sin tocar el vivo" | tee -a "$LOG"
elif [ "$VEREDICTO" = PROMOCIONAR ]; then
COPIA="$DATOS/saber.jsonl.antes-$(date +%Y%m%d-%H%M)"
cp "$DATOS/saber.jsonl" "$COPIA"
mv "$DATOS/saber.jsonl.nuevo" "$DATOS/saber.jsonl"
echo " PROMOCIONADO. El candidato es mejor y ya es el indice vivo." | tee -a "$LOG"
echo " copia del anterior: $COPIA" | tee -a "$LOG"
echo " para deshacer: cp '$COPIA' '$DATOS/saber.jsonl'" | tee -a "$LOG"
else
echo " MANTENIDO el vivo: el candidato no mejora la evaluacion." | tee -a "$LOG"
echo " candidato en saber.jsonl.nuevo e informe en informe_rag.txt para revisar." | tee -a "$LOG"
fi
echo "[$(fecha)] pipeline terminado" | tee -a "$LOG"

282
nucleo/saber/enriquece/cosecha.py Executable file
View file

@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Cosecha la metodologia de pentesting que el indexador normal se deja fuera.
./cosecha.py recorre COFRE y escribe datos/cosecha.jsonl
./cosecha.py --cuenta dice que cosecharia, sin escribir
## Por que existe, aparte de indexa.py
`indexa.py` es conservador a proposito: solo mira ficheros cuyo NOMBRE parece
documentacion (readme, notas, guia...) y no baja mas de 3 niveles. Eso funciona
para las herramientas, pero **tira la mejor metodologia que hay en el disco**:
- El `OSCP_Vault` son 72 notas OSCP escritas a mano `SQL injection.md`,
`Reverse shell.md`, `LFI a RCE.md`, `msfvenom.md`... con comandos reales y
enlaces cruzados. Ninguna entra: el nombre no lleva "notas/guia", y ademas
viven en `OSCP_APUNTES/OSCP_Vault/02 - Explotacion web/`, a 5 niveles.
- Igual con las cheatsheets y apuntes sueltos de AD, tunneling, privesc.
Medido antes de escribir esto: **0 de 72 notas del Vault estaban en el indice.**
Este cosechador es agresivo donde el otro es prudente: en los perfiles
ofensivos coge TODO .md/.txt que no sea ruido evidente, a la profundidad que
haga falta, y marca de que TEMA es por la carpeta que lo contiene (las del Vault
van numeradas: "01 - Enumeracion", "02 - Explotacion web"...).
No pisa a indexa.py: escribe a un fichero aparte. La union y el deduplicado los
hace reindexa.py.
"""
import argparse
import json
import os
import re
import sys
import unicodedata
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"[^a-z0-9 ]", " ", t)
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI)) # .../nucleo
COFRE = os.path.expanduser("~/COFRE")
SALIDA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
# Los perfiles ofensivos: aqui vive el saber de pentesting. En estos se cosecha
# a lo ancho. (LINUX se queda para indexa.py, que ya lo hace bien con man pages.)
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "DEFACERS", "SNEAKERS",
"HARD-WARERS", "PROTECTORS", "ZAPPERS", "AUTOMATAS"]
# Carpetas de oro: se recorren ENTERAS, sin limite de profundidad, porque su
# valor esta justo en las notas hondas.
ORO = re.compile(r"(OSCP_APUNTES|OSCP_Vault|TELMO_OSCP|RedTeam-Tools|"
r"apuntes|cheat|vault|metodolog|notas)", re.I)
# Lo que no se mira nunca: dependencias, control de versiones, binarios.
IGNORA = re.compile(
r"(^|/)(node_modules|\.git|\.obsidian|vendor|dist|build|__pycache__|"
r"\.venv|venv|site-packages|target|\.cache|\.github)(/|$)", re.I)
# Ficheros que son plantilla o licencia, no saber.
RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|copying|"
r"pull_request|issue_template|\.min\.|package-lock)", re.I)
# Fuera de las carpetas de ORO, solo se coge lo que PARECE documentacion por el
# nombre. Es la misma idea que indexa.py, un poco mas ancha (tutorial, writeup,
# walkthrough). Sin esto entra cada .md de cada exploit.
DOC = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|tutorial|"
r"walkthrough|writeup|write-up|guide|guia|cheat|manual|tips|"
r"metodolog|documentation|docs?)", re.I)
# Tope de ficheros por herramienta fuera del ORO. exploitdb tiene 29.925 .md:
# es una base de datos, no unos apuntes, y meterla entera ahoga el oro. Si una
# herramienta pasa de esto, se la trata como repo a granel y solo se coge su
# documentacion de primer nivel (README).
CAP_FICHEROS = 25
MIN_FRAGMENTO = 60
MAX_FRAGMENTO = 1200
MAX_PROFUNDIDAD = 4 # niveles bajo el perfil, salvo en carpetas de ORO
# Tope de tamano por fichero. Una nota de metodologia son unos pocos KB; un .txt
# de 300 MB es una wordlist (SecLists y similares llevan .txt de gigabytes) y
# leerlo entero revienta la maquina —la swap de este equipo es de 976 MiB—. Por
# encima de esto no es saber, es un diccionario, y no se lee.
MAX_BYTES = 512 * 1024
def _profundidad(ruta):
return ruta.rstrip("/").count("/")
def _tema(ruta):
"""El tema, deducido de la carpeta. Las del Vault van numeradas."""
for parte in reversed(ruta.split(os.sep)):
# "02 - Explotacion web" -> "explotacion web"
m = re.match(r"^\d{2}\s*[-.]\s*(.+)$", parte)
if m:
return m.group(1).strip().lower()
return ""
def _herramienta(ruta, perfil):
rel = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
return rel[0] if len(rel) > 1 else perfil
def _prefija(titulo, seccion):
"""Lleva el titulo de la nota delante de la seccion, salvo que ya lo tenga.
Sin esto, un fragmento como "## Deteccion time-based\n' AND SLEEP(5)" no
sabe de que va ¿time-based de que?. Con "(SQL injection) ..." delante, el
vector y el cerebro tienen el contexto. Se evita duplicar si el titulo ya
aparece en la cabecera de la seccion."""
if titulo and _norm(titulo) not in _norm(seccion[:80]):
return f"({titulo}) {seccion}"
return seccion
def trocea(texto):
"""En fragmentos por encabezado, fusionando los cortos y con el titulo.
Antes se partia por encabezado y se TIRABA toda seccion de menos de 60
caracteres. Eso perdia justo lo mejor: una seccion "## Deteccion time-based"
con `' AND SLEEP(5)-- -` son 50 caracteres, y es oro. Ahora una seccion
corta se PEGA a la siguiente en vez de tirarse, y cada fragmento lleva
delante el titulo de la nota para no quedar sin contexto.
"""
texto = re.sub(r"\A---\n.*?\n---\n", "", texto, flags=re.S)
m = re.search(r"^#\s+(.+)", texto, re.M)
titulo = m.group(1).strip() if m else ""
crudas = [s.strip() for s in re.split(r"\n(?=#{1,3}\s)", texto) if s.strip()]
# fusionar hacia adelante mientras la seccion sea demasiado corta
fundidas, i = [], 0
while i < len(crudas):
sec = crudas[i]
while len(sec) < MIN_FRAGMENTO and i + 1 < len(crudas):
i += 1
sec += "\n\n" + crudas[i]
fundidas.append(sec)
i += 1
# si la ultima quedo corta, se dobla sobre la anterior. Se saca primero y
# se indexa despues: fundidas.pop() en el lado derecho ya habria acortado
# la lista y fundidas[-2] se saldria de rango.
if len(fundidas) >= 2 and len(fundidas[-1]) < MIN_FRAGMENTO:
cola = fundidas.pop()
fundidas[-1] += "\n\n" + cola
for sec in fundidas:
if len(sec) < MIN_FRAGMENTO:
continue # una nota entera diminuta
if len(sec) <= MAX_FRAGMENTO:
yield _prefija(titulo, sec)
else:
buf = ""
for parrafo in re.split(r"\n\s*\n", sec):
if len(buf) + len(parrafo) > MAX_FRAGMENTO and buf:
yield _prefija(titulo, buf.strip())
buf = ""
buf += parrafo + "\n\n"
if len(buf.strip()) >= MIN_FRAGMENTO:
yield _prefija(titulo, buf.strip())
def _candidato(ruta, fich, en_oro):
"""Un fichero vale si es texto, no es ruido, no es enorme, y —fuera del
oro su nombre parece documentacion."""
if not fich.lower().endswith((".md", ".txt")):
return False
if RUIDO.search(fich):
return False
if not en_oro and not DOC.search(fich):
return False
try:
return os.path.getsize(ruta) <= MAX_BYTES
except OSError:
return False
def ficheros(perfil):
"""Los ficheros a cosechar de un perfil, con el tope por herramienta.
Se recorre cada herramienta (subcarpeta de primer nivel) por separado para
poder contar sus ficheros: si pasa del cap y no es oro, es un repo a granel
(exploitdb) y solo se coge su README, no sus 30.000 entradas.
"""
base = os.path.join(COFRE, perfil)
if not os.path.isdir(base):
return
# sueltos en la raiz del perfil (apuntes-ad-pentest.md, trucos_trampas.md)
for fich in sorted(os.listdir(base)):
ruta = os.path.join(base, fich)
if os.path.isfile(ruta) and _candidato(ruta, fich, en_oro=True):
yield ruta, True
for herr in sorted(os.listdir(base)):
raiz_h = os.path.join(base, herr)
if not os.path.isdir(raiz_h) or herr.startswith("."):
continue
candidatos = []
for raiz, dirs, fichs in os.walk(raiz_h):
if IGNORA.search(raiz):
dirs[:] = []
continue
en_oro = bool(ORO.search(raiz))
if not en_oro and _profundidad(raiz) - _profundidad(raiz_h) > MAX_PROFUNDIDAD:
dirs[:] = []
continue
for fich in sorted(fichs):
ruta = os.path.join(raiz, fich)
if _candidato(ruta, fich, en_oro):
candidatos.append((ruta, en_oro, _profundidad(ruta)))
del_oro = [c for c in candidatos if c[1]]
resto = [c for c in candidatos if not c[1]]
# el oro entra siempre y entero
for ruta, en_oro, _ in del_oro:
yield ruta, True
# el resto, con tope: si es un repo a granel, solo lo mas alto
if len(resto) > CAP_FICHEROS:
resto.sort(key=lambda c: c[2]) # los menos hondos primero
resto = resto[:CAP_FICHEROS]
for ruta, en_oro, _ in resto:
yield ruta, False
def construye(solo_cuenta=False):
salida = []
for perfil in PERFILES:
n_doc = n_frag = 0
for ruta, en_oro in ficheros(perfil):
try:
with open(ruta, encoding="utf-8", errors="ignore") as f:
texto = f.read()
except OSError:
continue
if len(texto.strip()) < MIN_FRAGMENTO:
continue
tema = _tema(ruta)
herr = _herramienta(ruta, perfil)
rel = os.path.relpath(ruta, COFRE)
hubo = False
for trozo in trocea(texto):
salida.append({
"tipo": "metodologia" if en_oro else "apunte",
"herramienta": herr, "perfil": perfil,
"fichero": rel, "tema": tema, "texto": trozo,
})
n_frag += 1
hubo = True
n_doc += hubo
print(f" {perfil:14s} {n_doc:5d} docs {n_frag:6d} fragmentos", flush=True)
oro = sum(1 for e in salida if e["tipo"] == "metodologia")
print(f"\n total: {len(salida)} fragmentos ({oro} de metodologia)", flush=True)
if solo_cuenta:
return 0
os.makedirs(os.path.dirname(SALIDA), exist_ok=True)
with open(SALIDA, "w", encoding="utf-8") as f:
for e in salida:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f" escrito: {SALIDA}", flush=True)
return 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--cuenta", action="store_true")
a = p.parse_args()
return construye(solo_cuenta=a.cuenta)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,45 @@
{"pregunta": "como saco una shell reversa desde bash", "espera": ["bash -i", "/dev/tcp", "reverse", "nc "]}
{"pregunta": "generar un payload de reverse shell con msfvenom", "espera": ["msfvenom", "-p ", "lhost", "payload"]}
{"pregunta": "estabilizar una shell tty para que funcione bien", "espera": ["python", "pty", "stty", "script /dev/null"]}
{"pregunta": "bypass de login por inyeccion sql", "espera": ["or '1'='1", "or 1=1", "-- -", "union select"]}
{"pregunta": "detectar sql injection a ciegas basada en tiempo", "espera": ["sleep", "waitfor", "time-based", "benchmark"]}
{"pregunta": "de una sqli a ejecucion de comandos", "espera": ["xp_cmdshell", "load_file", "into outfile", "rce"]}
{"pregunta": "explotar un local file inclusion para conseguir rce", "espera": ["lfi", "php://", "log poisoning", "/proc/self/environ", "data://"]}
{"pregunta": "probar server side template injection", "espera": ["ssti", "{{7*7", "jinja", "${", "twig"]}
{"pregunta": "payload basico de xss para robar cookie", "espera": ["<script", "document.cookie", "onerror", "alert("]}
{"pregunta": "inyeccion de comandos en un parametro web", "espera": ["command injection", ";", "| ", "$(", "`", "&&"]}
{"pregunta": "saltarme un filtro de subida de ficheros", "espera": ["file upload", "magic bytes", ".phtml", "content-type", "double extension"]}
{"pregunta": "descubrir directorios y ficheros ocultos en una web", "espera": ["gobuster", "ffuf", "feroxbuster", "dirb", "wordlist"]}
{"pregunta": "path traversal para leer etc passwd", "espera": ["../", "directory traversal", "/etc/passwd", "%2e"]}
{"pregunta": "enumerar un servidor smb", "espera": ["smbclient", "enum4linux", "smbmap", "crackmapexec", "139", "445"]}
{"pregunta": "escaneo de puertos y servicios con nmap", "espera": ["nmap", "-sv", "-sc", "-p-", "-a "]}
{"pregunta": "como paso de usuario normal a root en linux", "espera": ["privesc", "sudo -l", "suid", "linpeas", "gtfobins"]}
{"pregunta": "escalada de privilegios en windows", "espera": ["winpeas", "privesc", "seimpersonate", "potato", "token"]}
{"pregunta": "ataque dcsync para sacar hashes del dominio", "espera": ["dcsync", "secretsdump", "mimikatz", "lsadump"]}
{"pregunta": "pass the hash para autenticarme sin la contrasena", "espera": ["pass-the-hash", "pass the hash", "-hashes", "ntlm", "pth"]}
{"pregunta": "kerberoasting para sacar tickets de servicio", "espera": ["kerberoast", "getuserspns", "spn", "ticket"]}
{"pregunta": "usar bloodhound para mapear el active directory", "espera": ["bloodhound", "sharphound", "neo4j", "collector"]}
{"pregunta": "dumpear credenciales de memoria con mimikatz", "espera": ["mimikatz", "sekurlsa", "logonpasswords", "lsass"]}
{"pregunta": "usar impacket para ejecutar comandos remotos", "espera": ["impacket", "psexec", "wmiexec", "smbexec"]}
{"pregunta": "crackear un hash con john the ripper", "espera": ["john", "--wordlist", "rockyou", "--format"]}
{"pregunta": "crackear hashes con hashcat", "espera": ["hashcat", "-m ", "rockyou", "-a "]}
{"pregunta": "ataque de fuerza bruta a un login ssh", "espera": ["hydra", "medusa", "-l ", "-p ", "ssh"]}
{"pregunta": "transferir un fichero a la maquina victima", "espera": ["scp", "http.server", "certutil", "wget", "curl", "impacket-smbserver"]}
{"pregunta": "montar un tunel para pivotar en la red interna", "espera": ["chisel", "ligolo", "ssh -l", "proxychains", "socks"]}
{"pregunta": "pivoting con ssh port forwarding", "espera": ["ssh -l", "ssh -r", "-d ", "port forward", "dynamic"]}
{"pregunta": "usar metasploit para explotar un servicio", "espera": ["metasploit", "msfconsole", "use exploit", "set rhost"]}
{"pregunta": "escanear vulnerabilidades web con nuclei", "espera": ["nuclei", "-t ", "template", "-u "]}
{"pregunta": "escanear un wordpress en busca de fallos", "espera": ["wpscan", "--enumerate", "--url", "plugin"]}
{"pregunta": "buscar exploits publicos de un servicio", "espera": ["searchsploit", "exploit-db", "exploitdb"]}
{"pregunta": "evadir un antivirus o edr al ejecutar payload", "espera": ["av evasion", "amsi", "bypass", "obfusc", "edr"]}
{"pregunta": "hacer un buffer overflow clasico", "espera": ["buffer overflow", "eip", "pattern_create", "badchars", "jmp esp"]}
{"pregunta": "filtrado de salida y como saltarmelo", "espera": ["egress", "filtering", "puerto", "443", "outbound"]}
{"pregunta": "enumerar usuarios y recursos de un active directory", "espera": ["enum", "ldap", "rpcclient", "net user", "adperti"]}
{"pregunta": "atacar autenticacion con credenciales por defecto", "espera": ["authentication", "default", "brute", "credential", "login"]}
{"pregunta": "descubrir contenido con fuzzing de parametros", "espera": ["ffuf", "fuzz", "content discovery", "wordlist"]}
{"pregunta": "usar burp repeater e intruder para probar peticiones", "espera": ["burp", "repeater", "intruder", "proxy"]}
{"pregunta": "escaneo de red para descubrir hosts vivos", "espera": ["nmap", "-sn", "ping sweep", "netdiscover", "arp"]}
{"pregunta": "conseguir persistencia tras comprometer una maquina", "espera": ["persistenc", "cron", "scheduled task", "backdoor", "registry"]}
{"pregunta": "como escaneo redes wifi y capturo handshakes", "espera": ["airodump", "aircrack", "handshake", "wpa", "airgeddon"]}
{"pregunta": "usar crackmapexec para barrer una red windows", "espera": ["crackmapexec", "cme ", "smb", "--shares", "spray"]}
{"pregunta": "leer un hash ntlm y reutilizarlo por la red", "espera": ["ntlm", "hash", "relay", "responder", "ntlmrelayx"]}

173
nucleo/saber/enriquece/evalua.py Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Mide si el indice candidato recupera mejor que el vivo. Con numeros.
./evalua.py compara vivo contra saber.jsonl.nuevo
./evalua.py A.jsonl B.jsonl compara dos indices cualesquiera
## Que mide
Un conjunto de preguntas de pentesting (eval_set.jsonl), cada una con las
palabras que un fragmento CORRECTO tiene que contener (un comando, una
herramienta, un patron). Para cada indice se busca la pregunta y se mira si
alguno de los 5 primeros resultados contiene lo esperado.
hit@1 la pregunta se resuelve con el PRIMER resultado
hit@5 se resuelve con alguno de los cinco primeros
sim similitud media del mejor resultado
La busqueda replica la de busca.py (coseno + bono por palabras) para que la
comparacion sea justa: lo unico que cambia entre las dos columnas es el indice.
## Por que asi y no "le pregunte al modelo si estaba bien"
Un juez-LLM sobre una tarjeta de 4 GB es lento y ruidoso. Esto es determinista,
reproducible y honesto: la palabra esperada esta o no esta en el texto
recuperado. Es una cota inferior de la calidad un fragmento puede ser util sin
contener el literal pero sirve para COMPARAR dos indices, que es lo que decide
si se promociona el candidato.
"""
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")
VIVO = os.path.join(DATOS, "saber.jsonl")
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO = "minishlab/potion-multilingual-128M"
_modelo = None
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"[^a-z0-9 ]", " ", t)
def _carga_modelo():
global _modelo
if _modelo is None:
from model2vec import StaticModel
_modelo = StaticModel.from_pretrained(MODELO)
return _modelo
def _carga_indice(ruta):
import numpy as np
entradas, vs = [], []
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
v = d.pop("v", None)
if v is None:
continue
entradas.append(d)
vs.append(v)
M = np.array(vs, dtype="float32")
M /= np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
txt = [set(_norm(e["texto"]).split()) for e in entradas]
nom = [_norm(e.get("herramienta", "")) for e in entradas]
return entradas, M, txt, nom
def _busca(indice, pregunta, cuantos=5):
import numpy as np
entradas, M, txt, nom = indice
q = _carga_modelo().encode([pregunta])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = M @ q
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
if palabras:
bono = np.zeros(len(entradas), dtype="float32")
for i in range(len(entradas)):
b = 0.08 * len(palabras & txt[i])
if nom[i] and nom[i] in palabras:
b += 0.5
bono[i] = b
sim = sim + bono
orden = np.argsort(-sim)[: cuantos * 4]
salida, vistos = [], set()
for i in orden:
firma = entradas[i]["texto"][:120]
if firma in vistos:
continue
vistos.add(firma)
salida.append((float(sim[i]), entradas[i]["texto"]))
if len(salida) >= cuantos:
break
return salida
def evalua(ruta):
indice = _carga_indice(ruta)
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
hit1 = hit5 = 0
simtop = 0.0
fallos = []
for c in casos:
esperado = [_norm(k).strip() for k in c["espera"]]
res = _busca(indice, c["pregunta"])
simtop += res[0][0] if res else 0.0
textos = [_norm(t) for _, t in res]
acierta = lambda t: any(k and k in t for k in esperado)
if res and acierta(textos[0]):
hit1 += 1
if any(acierta(t) for t in textos):
hit5 += 1
else:
fallos.append(c["pregunta"])
n = len(casos)
return {"n": n, "hit1": hit1, "hit5": hit5,
"sim": simtop / n if n else 0.0, "fallos": fallos}
def _pinta(nombre, r):
print(f" {nombre}")
print(f" hit@1 {r['hit1']:3d}/{r['n']} ({100*r['hit1']//r['n']}%)")
print(f" hit@5 {r['hit5']:3d}/{r['n']} ({100*r['hit5']//r['n']}%)")
print(f" sim {r['sim']:.3f}")
def main():
a = sys.argv[1] if len(sys.argv) > 1 else VIVO
b = sys.argv[2] if len(sys.argv) > 2 else NUEVO
if not os.path.exists(b):
print(f" no existe el candidato: {b}")
return 1
print("Evaluando el indice VIVO...", flush=True)
rv = evalua(a)
print("Evaluando el indice CANDIDATO...", flush=True)
rn = evalua(b)
print("\n" + "=" * 44)
_pinta("VIVO " + os.path.basename(a), rv)
print()
_pinta("CANDIDATO " + os.path.basename(b), rn)
print("=" * 44)
mejora5 = rn["hit5"] - rv["hit5"]
mejora1 = rn["hit1"] - rv["hit1"]
print(f"\n hit@5: {mejora5:+d} hit@1: {mejora1:+d}")
# el veredicto que lee correr_noche.sh
promociona = rn["hit5"] >= rv["hit5"] and rn["hit1"] >= rv["hit1"] - 1
print(f" VEREDICTO {'PROMOCIONAR' if promociona else 'MANTENER'}")
if rn["fallos"]:
print(f"\n el candidato aun no resuelve {len(rn['fallos'])}:")
for q in rn["fallos"][:12]:
print(f" · {q}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Experimento: mide si un embedder transformer supera al model2vec estatico.
./experimento_embedder.py prueba intfloat/multilingual-e5-base
./experimento_embedder.py BAAI/bge-m3 prueba otro modelo
## Por que este experimento
model2vec (potion-multilingual-128M) es un embedder ESTATICO: rapidisimo (un
vector por token, precalculado, sin red neuronal en la consulta) pero flojo en
lo semantico. Su punto ciego se ve en las dos preguntas que el RAG no resuelve:
"detectar sql injection a ciegas basada en tiempo" -> no encuentra SLEEP
"descubrir directorios ocultos en una web" -> no encuentra gobuster
Las dos son cross-lingual: la pregunta va en castellano coloquial y el comando
en ingles tecnico ("time-based", "content discovery"). Un transformer de verdad
e5, bge entiende eso; el estatico no.
Este experimento NO toca nada: coge una MUESTRA del indice vivo (para que quepa
en memoria y sea rapido), la re-embebe con el transformer, y corre la misma
evaluacion. Si gana claro, merece la pena cambiar busca.py; si no, no.
## El coste que habria que pagar si se adopta
El estatico encodea la consulta en microsegundos. Un transformer en CPU tarda
~50-150 ms por consulta. Para un asistente de voz local es asumible, pero no es
gratis, y por eso se mide antes de decidir.
"""
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))
VIVO = os.path.join(RAIZ, "datos", "saber.jsonl")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO_TF = sys.argv[1] if len(sys.argv) > 1 else "intfloat/multilingual-e5-base"
# Muestra: todo lo que un caso de eval podria querer + relleno, para no cargar
# 14.000 vectores de transformer en RAM. Se cogen todas las entradas de
# metodologia y pregunta (el oro) mas una parte del resto.
MAX_ENTRADAS = 6000
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"[^a-z0-9 ]", " ", t)
def _muestra():
oro, resto = [], []
with open(VIVO, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
(oro if d.get("tipo") in ("metodologia", "pregunta") else resto).append(d)
ent = oro + resto[: max(0, MAX_ENTRADAS - len(oro))]
return ent
def _eval(nombre, encode_corpus, encode_query, entradas):
import numpy as np
textos = [(e.get("indexar") or e["texto"]) for e in entradas]
M = encode_corpus(textos)
M = M / np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
palabras_e = [set(_norm(e["texto"]).split()) for e in entradas]
nom = [_norm(e.get("herramienta", "")) for e in entradas]
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
hit1 = hit5 = 0
fallos = []
for c in casos:
q = encode_query([c["pregunta"]])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = M @ q
pal = {w for w in _norm(c["pregunta"]).split() if len(w) > 3}
if pal:
bono = np.array([0.08 * len(pal & palabras_e[i]) +
(0.5 if nom[i] and nom[i] in pal else 0.0)
for i in range(len(entradas))], dtype="float32")
sim = sim + bono
orden = np.argsort(-sim)[:5]
esperado = [_norm(k).strip() for k in c["espera"]]
tops = [_norm(entradas[i]["texto"]) for i in orden]
ok = lambda t: any(k and k in t for k in esperado)
if tops and ok(tops[0]):
hit1 += 1
if any(ok(t) for t in tops):
hit5 += 1
else:
fallos.append(c["pregunta"])
n = len(casos)
print(f"\n {nombre}")
print(f" hit@1 {hit1:3d}/{n} ({100*hit1//n}%)")
print(f" hit@5 {hit5:3d}/{n} ({100*hit5//n}%)")
if fallos:
print(f" no resuelve {len(fallos)}: " + "; ".join(fallos[:6]))
return hit1, hit5
def main():
entradas = _muestra()
print(f"Muestra: {len(entradas)} entradas del indice vivo")
# 1) model2vec estatico, la referencia
from model2vec import StaticModel
est = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
enc_est = lambda xs: est.encode(xs)
h1e, h5e = _eval("model2vec (estatico, el actual)", enc_est, enc_est, entradas)
# 2) el transformer a prueba
print(f"\n cargando {MODELO_TF} (puede descargar ~1-2 GB la 1a vez)...", flush=True)
from sentence_transformers import SentenceTransformer
tf = SentenceTransformer(MODELO_TF)
# los modelos e5 piden prefijos "query:" y "passage:"
es_e5 = "e5" in MODELO_TF.lower()
enc_doc = lambda xs: tf.encode([("passage: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
enc_q = lambda xs: tf.encode([("query: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
h1t, h5t = _eval(f"{MODELO_TF} (transformer)", enc_doc, enc_q, entradas)
print("\n" + "=" * 44)
print(f" hit@1 estatico {h1e} -> transformer {h1t} ({h1t-h1e:+d})")
print(f" hit@5 estatico {h5e} -> transformer {h5t} ({h5t-h5e:+d})")
print("=" * 44)
if h5t > h5e or (h5t == h5e and h1t > h1e):
print(" El transformer gana. Merece plantear el cambio en busca.py")
print(" (coste: ~50-150 ms por consulta en CPU, hoy es instantaneo).")
else:
print(" El transformer NO compensa aqui. El estatico se queda.")
return 0
if __name__ == "__main__":
sys.exit(main())

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())

View 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())