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
0
nucleo/saber/__init__.py
Normal file
0
nucleo/saber/__init__.py
Normal file
159
nucleo/saber/busca.py
Normal file
159
nucleo/saber/busca.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta.
|
||||
|
||||
Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos
|
||||
de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro
|
||||
construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que
|
||||
evita que se invente flags.
|
||||
|
||||
## Por que se carga una vez y se guarda
|
||||
|
||||
El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo
|
||||
la primera vez: el modelo y los vectores se quedan en memoria. Una consulta
|
||||
despues es un producto escalar contra 2.866 filas, milisegundos.
|
||||
|
||||
## Por que no una base de datos vectorial
|
||||
|
||||
FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un
|
||||
producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y
|
||||
el indice es un jsonl que se puede abrir y leer con los ojos.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
|
||||
|
||||
_modelo = None
|
||||
_entradas = None
|
||||
_matriz = None
|
||||
|
||||
|
||||
def _carga():
|
||||
global _modelo, _entradas, _matriz
|
||||
if _entradas is not None:
|
||||
return _entradas is not False
|
||||
|
||||
if not os.path.exists(INDICE):
|
||||
_entradas = False
|
||||
return False
|
||||
|
||||
import numpy as np
|
||||
from model2vec import StaticModel
|
||||
|
||||
entradas, vectores = [], []
|
||||
with open(INDICE, 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)
|
||||
vectores.append(v)
|
||||
|
||||
_entradas = entradas
|
||||
_matriz = np.array(vectores, dtype="float32")
|
||||
# normalizar una vez: asi la similitud es un simple producto escalar
|
||||
normas = np.linalg.norm(_matriz, axis=1, keepdims=True)
|
||||
_matriz = _matriz / np.clip(normas, 1e-9, None)
|
||||
_modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
return True
|
||||
|
||||
|
||||
def _norm(t: str) -> str:
|
||||
import re
|
||||
import unicodedata
|
||||
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)
|
||||
|
||||
|
||||
# El texto normalizado de cada entrada y su nombre, cacheados para el bono.
|
||||
_texto_norm = None
|
||||
_nombre_norm = None
|
||||
|
||||
|
||||
def _bono_palabras(palabras):
|
||||
"""Un vector de bonos, uno por entrada, segun cuantas palabras compartan."""
|
||||
import numpy as np
|
||||
global _texto_norm, _nombre_norm
|
||||
if _texto_norm is None:
|
||||
_texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas]
|
||||
_nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas]
|
||||
bono = np.zeros(len(_entradas), dtype="float32")
|
||||
for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)):
|
||||
comunes = len(palabras & palabras_e)
|
||||
b = 0.08 * comunes # cada palabra compartida suma
|
||||
if nombre and nombre in palabras: # y nombrar la herramienta, mucho
|
||||
b += 0.5
|
||||
bono[i] = b
|
||||
return bono
|
||||
|
||||
|
||||
def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list:
|
||||
"""Los fragmentos mas parecidos a la pregunta, mejor primero.
|
||||
|
||||
Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el
|
||||
texto. perfil filtra por carpeta de COFRE si se quiere acotar.
|
||||
"""
|
||||
if not _carga() or not pregunta.strip():
|
||||
return []
|
||||
import numpy as np
|
||||
|
||||
q = _modelo.encode([pregunta])[0]
|
||||
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||
sim = _matriz @ q
|
||||
|
||||
# Impulso por palabras exactas (busqueda hibrida).
|
||||
#
|
||||
# Los embeddings estaticos fallan cuando la pregunta va en castellano y el
|
||||
# texto en ingles —"cambiar permisos" no se parece a "change file mode
|
||||
# bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o
|
||||
# comparte palabras con el texto, eso es una señal fuerte que la similitud
|
||||
# semantica sola no aprovecha. Se suma un bono por cada palabra de la
|
||||
# pregunta que aparezca, y uno grande si coincide el nombre de la
|
||||
# herramienta: asi "usa nmap para..." lleva nmap al primer puesto.
|
||||
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
|
||||
if palabras:
|
||||
bono = _bono_palabras(palabras)
|
||||
sim = sim + bono
|
||||
|
||||
orden = np.argsort(-sim)[: cuantos * 6]
|
||||
salida, vistos = [], set()
|
||||
for i in orden:
|
||||
e = _entradas[i]
|
||||
if perfil and e.get("perfil") != perfil:
|
||||
continue
|
||||
# sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel.
|
||||
# Se comparan los primeros 120 caracteres, que basta para reconocerlos.
|
||||
firma = e["texto"][:120]
|
||||
if firma in vistos:
|
||||
continue
|
||||
vistos.add(firma)
|
||||
salida.append({**e, "sim": round(float(sim[i]), 3)})
|
||||
if len(salida) >= cuantos:
|
||||
break
|
||||
return salida
|
||||
|
||||
|
||||
def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str:
|
||||
"""Lo mismo, pero ya montado como texto para meterle al cerebro.
|
||||
|
||||
Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos
|
||||
que no vienen a cuento es lo que le hace mezclar herramientas que no tienen
|
||||
nada que ver. Mejor darle poco y bueno que mucho y ruidoso.
|
||||
"""
|
||||
trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo]
|
||||
if not trozos:
|
||||
return ""
|
||||
lineas = ["De los apuntes de COFRE del usuario:\n"]
|
||||
for t in trozos:
|
||||
fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}"
|
||||
lineas.append(f"--- {fuente} ---\n{t['texto']}\n")
|
||||
return "\n".join(lineas)
|
||||
|
||||
|
||||
def disponible() -> bool:
|
||||
return os.path.exists(INDICE)
|
||||
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())
|
||||
94
nucleo/saber/glosario.py
Normal file
94
nucleo/saber/glosario.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Tareas comunes de Linux en castellano, mapeadas al comando.
|
||||
|
||||
El puente que las man pages no dan: estan en ingles, y "cambiar permisos" no se
|
||||
parece a "change file mode bits". Aqui viven las tareas del dia a dia dichas
|
||||
como las diria el usuario, con el comando y una linea de como se usa. Es
|
||||
curado a mano a proposito: son las 50 cosas que de verdad se piden, y una
|
||||
traduccion buena vale mas que mil man pages mal recuperadas.
|
||||
|
||||
Se anaden al indice como entradas normales, asi que se buscan igual que todo lo
|
||||
demas y ademas se llevan el impulso por palabras exactas.
|
||||
"""
|
||||
|
||||
# (lo que se pide en castellano, el comando, como se usa)
|
||||
TAREAS = [
|
||||
("cambiar los permisos de un fichero o carpeta", "chmod",
|
||||
"chmod 755 fichero (rwx dueño, rx resto). chmod +x da permiso de ejecucion."),
|
||||
("cambiar el dueño de un fichero", "chown",
|
||||
"chown usuario:grupo fichero. Con -R para una carpeta entera."),
|
||||
("comprimir una carpeta en un archivo", "tar",
|
||||
"tar czf archivo.tar.gz carpeta/ comprime. tar xzf archivo.tar.gz extrae."),
|
||||
("descomprimir un zip", "unzip", "unzip archivo.zip. Con -d carpeta para donde."),
|
||||
("buscar texto dentro de ficheros", "grep",
|
||||
"grep -r 'texto' carpeta/ busca en todo. grep -i ignora mayusculas."),
|
||||
("buscar ficheros por nombre", "find",
|
||||
"find /ruta -name '*.txt' busca por nombre. -type f solo ficheros."),
|
||||
("ver los puertos abiertos en escucha", "ss",
|
||||
"ss -tlnp lista los puertos TCP en escucha con su proceso."),
|
||||
("ver que procesos consumen mas", "top",
|
||||
"top o htop en tiempo real. ps aux --sort=-%cpu para una foto."),
|
||||
("matar un proceso", "kill",
|
||||
"kill PID lo termina. kill -9 PID a la fuerza. pkill nombre por nombre."),
|
||||
("ver el espacio libre en disco", "df", "df -h muestra el espacio por particion."),
|
||||
("ver cuanto ocupa una carpeta", "du", "du -sh carpeta/ el tamaño total."),
|
||||
("ver la memoria libre", "free", "free -h muestra RAM y swap."),
|
||||
("descargar un fichero de internet", "wget",
|
||||
"wget URL descarga. curl -O URL tambien. curl -L sigue redirecciones."),
|
||||
("copiar ficheros a un servidor", "scp",
|
||||
"scp fichero usuario@servidor:/ruta lo sube. scp -r para carpetas."),
|
||||
("sincronizar carpetas", "rsync",
|
||||
"rsync -av origen/ destino/ copia solo lo cambiado. -z comprime en red."),
|
||||
("editar un fichero de texto", "nano",
|
||||
"nano fichero (facil) o vim fichero. Ctrl+O guarda y Ctrl+X sale en nano."),
|
||||
("ver el contenido de un fichero", "cat",
|
||||
"cat fichero. less fichero para leer largo. head/tail para principio/final."),
|
||||
("ver los ultimos cambios de un fichero de log", "tail",
|
||||
"tail -f log sigue en vivo. tail -n 50 las ultimas 50 lineas."),
|
||||
("ver los servicios del sistema", "systemctl",
|
||||
"systemctl status servicio. start/stop/restart para manejarlo."),
|
||||
("ver los mensajes del sistema", "journalctl",
|
||||
"journalctl -xe lo reciente. journalctl -u servicio de un servicio."),
|
||||
("montar un disco o usb", "mount",
|
||||
"mount /dev/sdX1 /mnt monta. lsblk lista los discos. umount desmonta."),
|
||||
("ver la configuracion de red", "ip",
|
||||
"ip a las interfaces y sus IP. ip r la tabla de rutas."),
|
||||
("escanear una red o unos puertos", "nmap",
|
||||
"nmap -sV objetivo detecta servicios. nmap -p- todos los puertos."),
|
||||
("ver el uso de red en vivo", "iftop", "iftop o nload muestran el trafico en vivo."),
|
||||
("cambiar de directorio y listar", "ls",
|
||||
"ls -la lista con detalles y ocultos. cd carpeta entra. pwd dice donde estas."),
|
||||
("crear una carpeta", "mkdir", "mkdir carpeta. mkdir -p a/b/c crea la ruta entera."),
|
||||
("mover o renombrar", "mv", "mv origen destino mueve o renombra."),
|
||||
("copiar ficheros", "cp", "cp origen destino. cp -r para carpetas."),
|
||||
("borrar ficheros", "rm",
|
||||
"rm fichero. rm -r carpeta. CUIDADO: no hay papelera, es definitivo."),
|
||||
("dar permisos de administrador a un comando", "sudo",
|
||||
"sudo comando lo ejecuta como root. Pide tu contraseña."),
|
||||
("ver el historial de comandos", "history", "history lista lo tecleado. Ctrl+R busca."),
|
||||
("programar una tarea periodica", "cron",
|
||||
"crontab -e edita tus tareas. Formato: min hora dia mes diasemana comando."),
|
||||
("ver la temperatura y el hardware", "sensors",
|
||||
"sensors muestra temperaturas. lscpu la CPU, lspci los dispositivos."),
|
||||
("gestionar paquetes en Debian", "apt",
|
||||
"apt install paquete, apt update actualiza la lista, apt upgrade el sistema."),
|
||||
("ver o cambiar variables de entorno", "export",
|
||||
"export VAR=valor la pone. env las lista. echo $VAR la muestra."),
|
||||
("comprobar si un comando esta instalado", "which",
|
||||
"which comando dice donde esta, o nada si no esta. type comando tambien."),
|
||||
("ver diferencias entre dos ficheros", "diff",
|
||||
"diff a b muestra las diferencias. diff -u formato de parche."),
|
||||
("contar lineas palabras o bytes", "wc",
|
||||
"wc -l lineas, wc -w palabras. cat fichero | wc -l cuenta lineas."),
|
||||
("conectarse a una base de datos", "psql",
|
||||
"psql -U usuario base para Postgres. mysql -u usuario -p para MySQL."),
|
||||
("ver quien esta conectado", "who", "who los usuarios. w con lo que hacen."),
|
||||
]
|
||||
|
||||
|
||||
def entradas():
|
||||
for pide, cmd, como in TAREAS:
|
||||
yield {
|
||||
"tipo": "tarea", "herramienta": cmd, "perfil": "LINUX",
|
||||
"fichero": f"tarea comun: {cmd}",
|
||||
"texto": f"Para {pide}: usa {cmd}. {como}",
|
||||
}
|
||||
232
nucleo/saber/indexa.py
Executable file
232
nucleo/saber/indexa.py
Executable file
|
|
@ -0,0 +1,232 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Convierte COFRE en algo que JARVIS puede consultar antes de contestar.
|
||||
|
||||
./indexa.py reconstruye el indice
|
||||
./indexa.py --cuenta solo dice que encontraria, sin indexar
|
||||
|
||||
## El problema que resuelve
|
||||
|
||||
COFRE tiene 35.000 documentos y 30 GB. Indexarlo entero seria a la vez inutil y
|
||||
lentisimo: el 95 % es codigo fuente de terceros —node_modules, .git, dist— que
|
||||
no dice nada de COMO USAR una herramienta. Una busqueda sobre eso devuelve
|
||||
ruido con total confianza, que es peor que no tener nada.
|
||||
|
||||
Lo que de verdad vale es el SABER, y son tres cosas:
|
||||
|
||||
1. Los apuntes propios del usuario: 26.000 palabras de flujos de trabajo
|
||||
escritos probandolos. Es lo mas valioso porque es correcto y es suyo.
|
||||
2. Los READMEs de primer nivel de cada herramienta: el "para que sirve y como
|
||||
se llama", una vez por herramienta.
|
||||
3. Una ficha por herramienta: nombre, perfil, ruta, una linea. Asi JARVIS
|
||||
sabe QUE tiene aunque no haya un README.
|
||||
|
||||
## Como se trocea
|
||||
|
||||
En fragmentos por seccion (los encabezados markdown) y no en trozos ciegos de N
|
||||
caracteres: un apunte partido por la mitad de una frase recupera peor que uno
|
||||
partido por donde el autor ya separo las ideas. Cada fragmento recuerda de que
|
||||
fichero y que herramienta viene, para poder citar la fuente.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
COFRE = os.path.expanduser("~/COFRE")
|
||||
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
|
||||
|
||||
# Los perfiles que se indexan. Cada uno es una carpeta de ~/COFRE.
|
||||
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "PROTECTORS", "HARD-WARERS",
|
||||
"AUTOMATAS", "DEFACERS", "SNEAKERS", "ZAPPERS"]
|
||||
|
||||
# Lo que NO se mira: dependencias, control de versiones, builds. Aqui esta la
|
||||
# diferencia entre un indice util y 30 GB de ruido.
|
||||
IGNORA = re.compile(
|
||||
r"(^|/)(node_modules|\.git|vendor|dist|build|__pycache__|\.venv|venv|"
|
||||
r"site-packages|target|\.cache|test|tests|fixtures|examples?|locale|"
|
||||
r"i18n|translations|\.github)(/|$)", re.I)
|
||||
|
||||
# Documentos que son saber, no plantillas de proyecto ni codigo de conducta.
|
||||
DOC_UTIL = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|"
|
||||
r"documentation|guide|guia|cheat|manual)", re.I)
|
||||
DOC_RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|"
|
||||
r"copying|pull_request|issue_template|security\.md|"
|
||||
r"hall_of_fame|redistributed|historical)", re.I)
|
||||
|
||||
MIN_FRAGMENTO = 80 # menos de esto no es un parrafo, es un titulo suelto
|
||||
MAX_FRAGMENTO = 1200 # mas de esto recupera con demasiado ruido alrededor
|
||||
|
||||
|
||||
def _profundidad(ruta):
|
||||
return ruta.rstrip("/").count("/")
|
||||
|
||||
|
||||
def herramientas(perfil):
|
||||
"""Cada subcarpeta de primer nivel es una herramienta. Una ficha por cada."""
|
||||
base = os.path.join(COFRE, perfil)
|
||||
if not os.path.isdir(base):
|
||||
return
|
||||
for nombre in sorted(os.listdir(base)):
|
||||
ruta = os.path.join(base, nombre)
|
||||
if not os.path.isdir(ruta) or nombre.startswith("."):
|
||||
continue
|
||||
yield {"herramienta": nombre, "perfil": perfil, "ruta": ruta}
|
||||
|
||||
|
||||
def _resumen_readme(ruta_herramienta):
|
||||
"""El primer parrafo con sustancia de su README, si tiene."""
|
||||
for nombre in ("README.md", "README", "README.txt", "readme.md"):
|
||||
p = os.path.join(ruta_herramienta, nombre)
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
try:
|
||||
with open(p, encoding="utf-8", errors="ignore") as f:
|
||||
texto = f.read(4000)
|
||||
except OSError:
|
||||
continue
|
||||
# saltar titulos, insignias y lineas vacias hasta el primer parrafo real
|
||||
for parrafo in re.split(r"\n\s*\n", texto):
|
||||
limpio = re.sub(r"[#>*`\[\]!]|\(https?://[^)]+\)|<[^>]+>", "", parrafo).strip()
|
||||
limpio = re.sub(r"\s+", " ", limpio)
|
||||
if len(limpio) >= 60 and not limpio.lower().startswith(("http", "===")):
|
||||
return limpio[:400]
|
||||
return ""
|
||||
|
||||
|
||||
def documentos_utiles(perfil):
|
||||
"""Los .md/.txt que son saber, no codigo ni plantillas."""
|
||||
base = os.path.join(COFRE, perfil)
|
||||
for raiz, dirs, ficheros in os.walk(base):
|
||||
if IGNORA.search(raiz):
|
||||
dirs[:] = []
|
||||
continue
|
||||
# no bajar mas de 3 niveles: el saber vive arriba, el codigo abajo
|
||||
if _profundidad(raiz) - _profundidad(base) > 3:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for fich in ficheros:
|
||||
if not fich.lower().endswith((".md", ".txt")):
|
||||
continue
|
||||
if DOC_RUIDO.search(fich):
|
||||
continue
|
||||
# los apuntes propios entran siempre; de terceros, solo los utiles
|
||||
propio = re.search(r"apuntes|trucos|notas|cheat|guia", fich, re.I)
|
||||
if not propio and not DOC_UTIL.search(fich):
|
||||
continue
|
||||
yield os.path.join(raiz, fich)
|
||||
|
||||
|
||||
def trocea(ruta):
|
||||
"""Un fichero markdown en fragmentos por seccion."""
|
||||
try:
|
||||
with open(ruta, encoding="utf-8", errors="ignore") as f:
|
||||
texto = f.read()
|
||||
except OSError:
|
||||
return
|
||||
# partir por encabezados, conservando el titulo con su seccion
|
||||
partes = re.split(r"\n(?=#{1,3}\s)", texto)
|
||||
for parte in partes:
|
||||
limpio = parte.strip()
|
||||
if len(limpio) < MIN_FRAGMENTO:
|
||||
continue
|
||||
# si una seccion es enorme, partirla por parrafos sin pasar del tope
|
||||
if len(limpio) <= MAX_FRAGMENTO:
|
||||
yield limpio
|
||||
else:
|
||||
buffer = ""
|
||||
for parrafo in re.split(r"\n\s*\n", limpio):
|
||||
if len(buffer) + len(parrafo) > MAX_FRAGMENTO and buffer:
|
||||
yield buffer.strip()
|
||||
buffer = ""
|
||||
buffer += parrafo + "\n\n"
|
||||
if len(buffer.strip()) >= MIN_FRAGMENTO:
|
||||
yield buffer.strip()
|
||||
|
||||
|
||||
def construye(solo_cuenta=False, solo_cofre=False):
|
||||
fichas, fragmentos = [], []
|
||||
|
||||
for perfil in PERFILES:
|
||||
n_herr = 0
|
||||
for h in herramientas(perfil):
|
||||
resumen = _resumen_readme(h["ruta"])
|
||||
texto = f"{h['herramienta']} ({perfil}). {resumen}".strip()
|
||||
fichas.append({
|
||||
"tipo": "ficha", "herramienta": h["herramienta"],
|
||||
"perfil": perfil, "ruta": h["ruta"], "texto": texto,
|
||||
})
|
||||
n_herr += 1
|
||||
|
||||
n_frag = 0
|
||||
for doc in documentos_utiles(perfil):
|
||||
herr = _herramienta_de(doc, perfil)
|
||||
for trozo in trocea(doc):
|
||||
fragmentos.append({
|
||||
"tipo": "apunte", "herramienta": herr, "perfil": perfil,
|
||||
"fichero": os.path.relpath(doc, COFRE), "texto": trozo,
|
||||
})
|
||||
n_frag += 1
|
||||
print(f" {perfil:14s} {n_herr:4d} herramientas {n_frag:5d} fragmentos de apuntes")
|
||||
|
||||
# El conocimiento de la propia maquina: comandos de Linux, servidores SSH,
|
||||
# extensiones. Es lo que convierte "sabe de mis herramientas" en "sabe hacer
|
||||
# cualquier cosa en Linux". Va salvo que se pida solo COFRE.
|
||||
sistema = []
|
||||
if not solo_cofre:
|
||||
try:
|
||||
from saber import sistema as sis
|
||||
except ImportError:
|
||||
import sistema as sis
|
||||
print(" extrayendo la documentacion del sistema (man pages, ssh, firefox)...",
|
||||
flush=True)
|
||||
n_cmd = n_srv = n_nav = 0
|
||||
for e in sis.todo():
|
||||
sistema.append(e)
|
||||
n_cmd += e["tipo"] == "comando"
|
||||
n_srv += e["tipo"] == "servidor"
|
||||
n_nav += e["tipo"] == "navegador"
|
||||
if len(sistema) % 500 == 0:
|
||||
print(f" {len(sistema)} entradas de sistema...", flush=True)
|
||||
print(f" LINUX {n_cmd:4d} comandos {n_srv} servidores {n_nav} navegador")
|
||||
|
||||
todo = fichas + fragmentos + sistema
|
||||
print(f"\n total: {len(fichas)} fichas + {len(fragmentos)} apuntes + "
|
||||
f"{len(sistema)} sistema = {len(todo)} entradas")
|
||||
if solo_cuenta:
|
||||
return 0
|
||||
|
||||
print(" cargando el modelo de embeddings...", flush=True)
|
||||
from model2vec import StaticModel
|
||||
modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
|
||||
print(" calculando vectores...", flush=True)
|
||||
vectores = modelo.encode([e["texto"] for e in todo], show_progress_bar=False)
|
||||
|
||||
os.makedirs(os.path.dirname(INDICE), exist_ok=True)
|
||||
with open(INDICE, "w", encoding="utf-8") as f:
|
||||
for entrada, vec in zip(todo, vectores):
|
||||
entrada["v"] = [round(float(x), 5) for x in vec]
|
||||
f.write(json.dumps(entrada, ensure_ascii=False) + "\n")
|
||||
print(f" indice guardado: {INDICE} ({os.path.getsize(INDICE)//1024} KB)")
|
||||
return 0
|
||||
|
||||
|
||||
def _herramienta_de(ruta, perfil):
|
||||
partes = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
|
||||
return partes[0] if len(partes) > 1 else perfil
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--cuenta", action="store_true",
|
||||
help="di que encontrarias, sin indexar")
|
||||
p.add_argument("--solo-cofre", action="store_true",
|
||||
help="indexar solo COFRE, sin la documentacion del sistema")
|
||||
a = p.parse_args()
|
||||
return construye(solo_cuenta=a.cuenta, solo_cofre=a.solo_cofre)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
183
nucleo/saber/sistema.py
Normal file
183
nucleo/saber/sistema.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""Indexar el conocimiento de la propia maquina: comandos, servidores, extensiones.
|
||||
|
||||
El RAG de COFRE (indexa.py) sabe de las herramientas del usuario. Esto añade lo
|
||||
otro que pidio: que sepa hacer CUALQUIER cosa en GNU/Linux. La fuente ya esta en
|
||||
el disco —9.541 man pages, 3.000 comandos— y es la mejor que hay: la
|
||||
documentacion oficial de cada herramienta, escrita por quien la hizo.
|
||||
|
||||
## Que se extrae de cada man page, y por que no entera
|
||||
|
||||
Una man page entera son cientos de lineas: todas las opciones, los ejemplos, las
|
||||
notas, los bugs. Meterla entera hace el indice enorme y recupera peor —hay tanto
|
||||
texto que la parte relevante se diluye—. Se coge lo que contesta "¿que hace y
|
||||
como se llama?":
|
||||
|
||||
NAME una linea: que es
|
||||
SYNOPSIS como se invoca, con sus opciones
|
||||
DESCRIPTION los primeros parrafos, donde esta lo esencial
|
||||
|
||||
Con eso, preguntar "como listo los puertos abiertos" recupera la man de ss o
|
||||
netstat con su sintaxis, y el cerebro construye el comando desde ahi.
|
||||
|
||||
## Solo los comandos que EXISTEN en esta maquina
|
||||
|
||||
No las 9.541 man pages: muchas son de librerias de C, formatos de fichero y cosas
|
||||
que no se invocan desde la terminal. Se indexan las de los comandos que estan de
|
||||
verdad en el PATH (secciones 1 y 8), que es lo que el usuario puede pedir que se
|
||||
ejecute.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def _limpio(texto: str) -> str:
|
||||
# las man pages traen backspaces para negrita (c\bco\bom\bmo); se quitan
|
||||
texto = re.sub(r".\x08", "", texto)
|
||||
texto = re.sub(r"\x1b\[[0-9;]*m", "", texto) # colores ANSI
|
||||
return re.sub(r"[ \t]+", " ", texto)
|
||||
|
||||
|
||||
def _comandos_del_path() -> list:
|
||||
vistos = set()
|
||||
for carpeta in os.environ.get("PATH", "").split(":"):
|
||||
try:
|
||||
for nombre in os.listdir(carpeta):
|
||||
ruta = os.path.join(carpeta, nombre)
|
||||
if nombre not in vistos and os.access(ruta, os.X_OK) \
|
||||
and not os.path.isdir(ruta):
|
||||
vistos.add(nombre)
|
||||
except OSError:
|
||||
continue
|
||||
return sorted(vistos)
|
||||
|
||||
|
||||
def _man(comando: str) -> str:
|
||||
"""NAME + SYNOPSIS + arranque de DESCRIPTION de un comando, o ''."""
|
||||
try:
|
||||
r = subprocess.run(["man", comando], capture_output=True, text=True,
|
||||
timeout=8, env={**os.environ, "MANWIDTH": "80",
|
||||
"MAN_KEEP_FORMATTING": ""})
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
if r.returncode != 0 or not r.stdout:
|
||||
return ""
|
||||
texto = _limpio(r.stdout)
|
||||
|
||||
trozos = []
|
||||
seccion = None
|
||||
buffer = []
|
||||
# se cortan las secciones por sus titulos en mayuscula al margen izquierdo
|
||||
for linea in texto.splitlines():
|
||||
cabecera = re.match(r"^([A-Z][A-Z ]{2,20})$", linea.strip())
|
||||
if cabecera:
|
||||
if seccion in ("NAME", "SYNOPSIS", "DESCRIPTION") and buffer:
|
||||
trozos.append((seccion, "\n".join(buffer).strip()))
|
||||
seccion = cabecera.group(1).strip()
|
||||
buffer = []
|
||||
if seccion in ("OPTIONS", "SEE ALSO", "AUTHOR", "COPYRIGHT",
|
||||
"REPORTING BUGS", "EXAMPLES"):
|
||||
seccion = None # dejar de acumular
|
||||
elif seccion:
|
||||
buffer.append(linea)
|
||||
if seccion in ("NAME", "SYNOPSIS", "DESCRIPTION") and buffer:
|
||||
trozos.append((seccion, "\n".join(buffer).strip()))
|
||||
|
||||
partes = []
|
||||
for sec, cont in trozos:
|
||||
if sec == "DESCRIPTION":
|
||||
cont = cont[:600] # solo el arranque de la descripcion
|
||||
partes.append(cont)
|
||||
return "\n".join(partes).strip()
|
||||
|
||||
|
||||
def comandos():
|
||||
"""Una entrada por comando del PATH que tenga man page."""
|
||||
for cmd in _comandos_del_path():
|
||||
texto = _man(cmd)
|
||||
if len(texto) < 40:
|
||||
continue
|
||||
yield {
|
||||
"tipo": "comando", "herramienta": cmd, "perfil": "LINUX",
|
||||
"fichero": f"man {cmd}", "texto": texto[:1400],
|
||||
}
|
||||
|
||||
|
||||
def servidores():
|
||||
"""Una ficha por host del ssh config, para que sepa a que puede conectarse."""
|
||||
ruta = os.path.expanduser("~/.ssh/config")
|
||||
if not os.path.exists(ruta):
|
||||
return
|
||||
host = None
|
||||
datos = {}
|
||||
|
||||
def suelta():
|
||||
if host and "*" not in host:
|
||||
hn = datos.get("hostname", host)
|
||||
usuario = datos.get("user", "")
|
||||
desc = (f"Servidor SSH «{host}»: se conecta con «ssh {host}». "
|
||||
f"Direccion {hn}." + (f" Usuario {usuario}." if usuario else "")
|
||||
+ " Para ejecutar algo alli: ssh " + host + " '<comando>'.")
|
||||
return {"tipo": "servidor", "herramienta": host, "perfil": "SSH",
|
||||
"fichero": "~/.ssh/config", "texto": desc}
|
||||
return None
|
||||
|
||||
for linea in open(ruta, encoding="utf-8", errors="ignore"):
|
||||
m = re.match(r"^\s*(\w+)\s+(.+?)\s*$", linea)
|
||||
if not m:
|
||||
continue
|
||||
clave, valor = m.group(1).lower(), m.group(2)
|
||||
if clave == "host":
|
||||
hecho = suelta()
|
||||
if hecho:
|
||||
yield hecho
|
||||
host, datos = valor.split()[0], {}
|
||||
elif host:
|
||||
datos[clave] = valor
|
||||
hecho = suelta()
|
||||
if hecho:
|
||||
yield hecho
|
||||
|
||||
|
||||
def extensiones_firefox():
|
||||
"""Que extensiones tiene puestas, para que sepa con que cuenta en el navegador.
|
||||
|
||||
Aviso honesto de alcance: JARVIS puede ABRIR firefox y llevarlo a una URL,
|
||||
pero NO puede pulsar los botones de una extension —eso vive dentro del
|
||||
navegador, fuera del alcance de un comando de shell—. Lo que si puede es
|
||||
saber que tienes puestas y abrir lo que cada una gestiona por URL.
|
||||
"""
|
||||
import glob
|
||||
import json as _json
|
||||
base = os.path.expanduser("~/.mozilla/firefox")
|
||||
perfiles = glob.glob(os.path.join(base, "*", "extensions.json"))
|
||||
nombres = []
|
||||
for p in perfiles:
|
||||
try:
|
||||
datos = _json.load(open(p, encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for addon in datos.get("addons", []):
|
||||
if not addon.get("active") or addon.get("type") != "extension":
|
||||
continue
|
||||
nombre = ((addon.get("defaultLocale") or {}).get("name")
|
||||
or addon.get("id", ""))
|
||||
if nombre and nombre not in nombres:
|
||||
nombres.append(nombre)
|
||||
if nombres:
|
||||
yield {
|
||||
"tipo": "navegador", "herramienta": "firefox", "perfil": "NAVEGADOR",
|
||||
"fichero": "extensiones de firefox",
|
||||
"texto": ("Extensiones instaladas en Firefox: " + ", ".join(nombres)
|
||||
+ ". JARVIS puede abrir Firefox y llevarlo a una URL; los "
|
||||
"botones de cada extension se pulsan dentro del navegador."),
|
||||
}
|
||||
|
||||
|
||||
def todo():
|
||||
"""Todas las entradas de sistema, para que indexa.py las sume a las de COFRE."""
|
||||
from saber import glosario
|
||||
yield from glosario.entradas() # el glosario en castellano, primero
|
||||
yield from servidores()
|
||||
yield from extensiones_firefox()
|
||||
yield from comandos()
|
||||
Loading…
Add table
Add a link
Reference in a new issue