JARVIS/nucleo/saber/indexa.py
sito 873a22ea6a saber: preguntas-gancho sintetizadas horneadas en el indice publico
- sintetiza_catalogo.py genera preguntas en castellano (indexacion multi-
  representacion) para el pack de conocimiento y el catalogo de awesome lists.
- indexa.py hornea los ganchos: al indice publico solo los de fragmentos
  publicos (~1856); el personal ademas rehornea la sintesis nocturna de COFRE
  (privada), para que reconstruir no pise ese trabajo.
- saber-publico.jsonl pasa a 2555 entradas con los vectores de las preguntas:
  un clon recien hecho consulta con recall alto sin correr sintesis.
- eval del indice personal: hit@1 82%, hit@5 97%, sim 0.927.
2026-08-16 19:59:21 +02:00

363 lines
15 KiB
Python
Executable file

#!/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")
# El indice PUBLICO y portable: solo glosario + pack de conocimiento, sin nada de
# la maquina (ni COFRE, ni man pages, ni servidores). Se commitea con vectores ya
# calculados para que un clon recien hecho consulte al instante, sin reindexar.
INDICE_PUBLICO = os.path.join(AQUI, "datos", "saber-publico.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 _catalogo(solo_publico=False):
"""Las awesome lists clonadas en CODERS, si el modulo y las listas estan."""
try:
try:
from saber import awesome
except ImportError:
import awesome
except ImportError:
return []
fragmentos = []
for e in awesome.entradas(solo_publico=solo_publico):
e.pop("publico", None) # marca interna de filtrado, no va al indice
fragmentos.append(e)
return fragmentos
def _ganchos(solo_publico=False):
"""Las preguntas-gancho sintetizadas, si existen. Multi-representacion: cada
una embebe una pregunta (`indexar`) y muestra el fragmento (`texto`). Es lo
que sube el recall (48->82 % hit@1). Dos fuentes:
- sintesis-catalogo.jsonl: pack del repo + catalogo (enriquece/
sintetiza_catalogo.py). Lleva marca `publico` por fragmento.
- sintesis.jsonl: la sintesis NOCTURNA sobre los apuntes de COFRE
(enriquece/sintetiza.py). Es privada, nunca va al indice publico.
Se incluye aqui para que un `indexa.py` no pise el trabajo de la noche: la
reconstruccion desde cero rehornea ambas.
"""
fuentes = [
(os.path.join(AQUI, "datos", "sintesis-catalogo.jsonl"), None),
(os.path.join(AQUI, "datos", "sintesis.jsonl"), False), # COFRE: privada
]
ganchos = []
for ruta, forzar in fuentes:
if not os.path.exists(ruta):
continue
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
e = json.loads(l)
except json.JSONDecodeError:
continue
pub = e.get("publico") if forzar is None else forzar
if solo_publico and not pub:
continue
e.pop("publico", None)
ganchos.append(e)
return ganchos
def _conocimiento():
"""El pack de metodologia y manuales que viene con el repo (conocimiento/)."""
fragmentos = []
base_con = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conocimiento")
if not os.path.isdir(base_con):
return fragmentos
for raiz, _, ficheros in os.walk(base_con):
m = re.match(r"^\d{2}\s*-\s*(.+)$", os.path.basename(raiz))
tema = m.group(1).strip().lower() if m else ""
for fich in ficheros:
if not fich.endswith(".md"):
continue
for trozo in trocea(os.path.join(raiz, fich)):
fragmentos.append({
"tipo": "metodologia", "herramienta": fich[:-3],
"perfil": "PENTESTERS", "tema": tema,
"fichero": os.path.join("conocimiento", os.path.relpath(
os.path.join(raiz, fich), base_con)),
"texto": trozo})
return fragmentos
def _guarda_indice(todo, ruta):
"""Calcula los vectores de las entradas y las escribe a un indice jsonl."""
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)
# se embebe `indexar` si existe (la pregunta-gancho sintetizada), si no `texto`.
# busca.py solo lee `texto` y `v`, asi la respuesta mostrada es el fragmento.
vectores = modelo.encode([e.get("indexar") or e["texto"] for e in todo],
show_progress_bar=False)
os.makedirs(os.path.dirname(ruta), exist_ok=True)
with open(ruta, "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: {ruta} ({os.path.getsize(ruta)//1024} KB)")
def construye_publico(solo_cuenta=False):
"""El indice PORTABLE que se sube al repo: glosario + pack de conocimiento.
Nada de la maquina —ni COFRE, ni man pages, ni servidores SSH—, asi que es
100 % publico y vale igual en cualquier equipo. Un clon lo usa tal cual hasta
que corre su propio indexa.py; asi el trabajo de indexar el pack no se repite.
"""
try:
from saber import glosario
except ImportError:
import glosario
glo = list(glosario.entradas())
con = _conocimiento()
cat = _catalogo(solo_publico=True) # solo las awesome de licencia redistribuible
gan = _ganchos(solo_publico=True) # preguntas-gancho de fragmentos publicos
todo = glo + con + cat + gan
print(f" glosario {len(glo)} + conocimiento {len(con)} + catalogo {len(cat)} "
f"+ ganchos {len(gan)} = {len(todo)} entradas publicas")
if solo_cuenta:
return 0
_guarda_indice(todo, INDICE_PUBLICO)
return 0
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")
# El pack de metodologia que VIENE con el repo (conocimiento/). Es publico y
# esta scrubeado, asi que un clon recien hecho —sin apuntes propios de COFRE—
# ya sabe de pentesting. En la maquina del usuario se solapa con sus apuntes
# y el deduplicado de busca lo colapsa.
conocimiento = _conocimiento()
print(f" CONOCIMIENTO {len(conocimiento):5d} fragmentos (pack del repo)")
# Las awesome lists clonadas en CODERS (catalogo de que-existe-para-X). En el
# indice personal entran todas; el filtro por licencia solo aplica al publico.
catalogo = _catalogo(solo_publico=False)
if catalogo:
print(f" CATALOGO {len(catalogo):5d} fragmentos (awesome lists)")
ganchos = _ganchos(solo_publico=False)
if ganchos:
print(f" GANCHOS {len(ganchos):5d} preguntas sintetizadas")
todo = fichas + fragmentos + sistema + conocimiento + catalogo + ganchos
print(f"\n total: {len(fichas)} fichas + {len(fragmentos)} apuntes + "
f"{len(sistema)} sistema + {len(conocimiento)} conocimiento + "
f"{len(catalogo)} catalogo + {len(ganchos)} ganchos = {len(todo)} entradas")
if solo_cuenta:
return 0
_guarda_indice(todo, INDICE)
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")
p.add_argument("--publico", action="store_true",
help="construir el indice portable (glosario + conocimiento) que se sube al repo")
a = p.parse_args()
if a.publico:
return construye_publico(solo_cuenta=a.cuenta)
return construye(solo_cuenta=a.cuenta, solo_cofre=a.solo_cofre)
if __name__ == "__main__":
sys.exit(main())