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
110
nucleo/saber/enriquece/README.md
Normal file
110
nucleo/saber/enriquece/README.md
Normal 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.
|
||||
92
nucleo/saber/enriquece/correr_noche.sh
Executable file
92
nucleo/saber/enriquece/correr_noche.sh
Executable 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
282
nucleo/saber/enriquece/cosecha.py
Executable 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())
|
||||
45
nucleo/saber/enriquece/eval_set.jsonl
Normal file
45
nucleo/saber/enriquece/eval_set.jsonl
Normal 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
173
nucleo/saber/enriquece/evalua.py
Executable 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())
|
||||
142
nucleo/saber/enriquece/experimento_embedder.py
Normal file
142
nucleo/saber/enriquece/experimento_embedder.py
Normal 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())
|
||||
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())
|
||||
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