JARVIS/nucleo/saber/busca.py
sito 9fa0a40edf saber: manuales cosechados e indice publico pre-construido
- manuales.py cosecha el --help de las herramientas que no traen man page
  (ffuf, sqlmap, suite impacket, netexec, hydra...) y lo escribe como markdown
  en conocimiento/09 - Manuales/. Es la referencia de opciones que la man page
  no da; complementa el cheatsheet de invocaciones comunes.
- indexa.py --publico construye saber-publico.jsonl: glosario + pack de
  conocimiento con los vectores ya calculados, 100% publico y portable.
- busca.py usa ese indice publico como fallback cuando no hay indice personal,
  asi un clon recien hecho consulta al instante sin reindexar nada.
- .gitignore deja pasar saber-publico.jsonl; el personal (saber.jsonl) sigue
  fuera.
- docs/rag.md y el README del pack documentan ambas cosas.
2026-08-16 18:43:56 +02:00

206 lines
7.4 KiB
Python

"""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")
# El indice portable que viene en el repo. Se usa tal cual mientras no exista el
# personal, para que un clon recien hecho ya sepa de pentesting sin reindexar.
INDICE_PUBLICO = os.path.join(AQUI, "datos", "saber-publico.jsonl")
_modelo = None
_entradas = None
_matriz = None
def _fuente():
"""El indice personal si existe; si no, el publico que trae el repo."""
if os.path.exists(INDICE):
return INDICE
if os.path.exists(INDICE_PUBLICO):
return INDICE_PUBLICO
return None
def _carga():
global _modelo, _entradas, _matriz
if _entradas is not None:
return _entradas is not False
fuente = _fuente()
if not fuente:
_entradas = False
return False
import numpy as np
from model2vec import StaticModel
entradas, vectores = [], []
with open(fuente, 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 resumen(tope_temas: int = 10) -> str:
"""Un resumen de QUE hay indexado, para que el modelo sepa de que sabe.
Se deriva del indice de VERDAD, no de una lista fija en el prompt. Asi es
honesto en cualquier maquina: en la del usuario dice sus areas reales; en un
clon sin apuntes propios dira lo que haya (comandos de Linux) o nada. Lo usa
el cerebro para el prompt de sistema; evita que presuma de un conocimiento
que en esa maquina no tiene."""
if not _carga() or not _entradas:
return ""
from collections import Counter
perfiles, temas = Counter(), Counter()
n_comandos = 0
for e in _entradas:
t = e.get("tipo")
if t == "comando":
n_comandos += 1
elif t in ("apunte", "metodologia", "ficha", "pregunta") and e.get("perfil"):
perfiles[e["perfil"]] += 1
if e.get("tema"):
temas[e["tema"]] += 1
partes = []
if perfiles:
partes.append("apuntes de " + ", ".join(p for p, _ in perfiles.most_common(6)))
if temas:
partes.append("metodologia en " + ", ".join(t for t, _ in temas.most_common(tope_temas)))
if n_comandos:
partes.append(f"{n_comandos} comandos de Linux")
if not partes:
return ""
return ("En los apuntes que tienes indexados (buscalos con buscar_en_apuntes) "
"hay: " + "; ".join(partes) + ".")
def disponible() -> bool:
return _fuente() is not None