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.
This commit is contained in:
sito 2026-08-16 19:59:21 +02:00
parent 43335d329f
commit 873a22ea6a
4 changed files with 2043 additions and 6 deletions

View file

@ -93,7 +93,7 @@ forma de medirlo— sí está; el corpus lo pone cada quien con sus notas.
| El glosario de tareas de Linux en castellano (`glosario.py`) | **sí** (genérico) |
| **El pack de metodología y manuales** (`saber/conocimiento/`) | **sí** — limpio y genérico |
| **El catálogo de *awesome lists*** (`saber/awesome.py`) | **sí** — solo lo redistribuible (CC0, CC BY-SA), ver [ATRIBUCION](../ATRIBUCION.md) |
| **El índice público pre-construido** (`datos/saber-publico.jsonl`) | **sí** — glosario + pack + catálogo, con vectores |
| **El índice público pre-construido** (`datos/saber-publico.jsonl`) | **sí** — glosario + pack + catálogo + preguntas-gancho, con vectores |
| El índice personal (`saber.jsonl`) y sus copias | **no** — los apuntes indexados |
| El diario (`diario.jsonl`) | **no** — las conversaciones |
| Lo derivado (`cosecha.jsonl`, `sintesis.jsonl`) | **no** — salen de los apuntes |
@ -118,3 +118,14 @@ licencia: al índice **público** solo van las redistribuibles —`awesome`
[ATRIBUCION.md](../ATRIBUCION.md))—. `awesome-hacking`, sin licencia explícita, se
indexa solo en el índice **personal** si se clona en `~/COFRE/CODERS/`. Para
ampliar el catálogo, se clona otra lista ahí y se reindexa.
## Las preguntas-gancho, ya horneadas
El salto de recall (48→82 % hit@1) viene de la *indexación multi-representación*:
por cada fragmento, preguntas en castellano que lo encuentran. Generarlas cuesta
horas de modelo local, así que `enriquece/sintetiza_catalogo.py` las produce para
el pack y el catálogo, y el índice público **las trae ya calculadas**. Un clon
recién hecho consulta con esa calidad sin correr ni un minuto de síntesis: sobre
el `saber-publico.jsonl` hay ~1.900 preguntas-gancho además de los fragmentos en
crudo. Las de fragmentos no públicos (p. ej. `awesome-hacking`) se quedan fuera,
como el resto de su contenido.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Genera preguntas-gancho para el pack de conocimiento y el catalogo de awesome.
./sintetiza_catalogo.py procesa todo (reanudable, ~1-2 h)
./sintetiza_catalogo.py --muestra 5 ensena 5 y para
Misma idea multi-representacion que sintetiza.py, pero sobre el contenido que
VIENE con el repo (conocimiento/ + awesome lists) en vez de sobre la cosecha de
COFRE. Por que importa: el indice PUBLICO pre-construido trae los fragmentos en
crudo pero SIN preguntas-gancho, asi que un clon recien hecho no tiene el salto de
recall que dan (48->82 % hit@1 medido en la metodologia). Esto lo genera para que
si venga horneado.
Respeta la marca `publico` de cada fragmento: los ganchos heredan esa marca, y
indexa.py mete en el indice publico solo los publicos (los de awesome-hacking, sin
licencia, se quedan en el personal).
Reanudable: apunta en un .hecho por que fragmento iba. El contenido publico se
procesa PRIMERO, para que si se corta, lo que se sube ya este.
"""
import argparse
import json
import os
import sys
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
SALIDA = os.path.join(RAIZ, "datos", "sintesis-catalogo.jsonl")
MARCA = SALIDA + ".hecho"
# Reutiliza el modelo, la llamada a ollama y el reanudado de sintetiza.py.
sys.path.insert(0, RAIZ) # nucleo/ en el path para saber.*
from saber import awesome # noqa: E402
from saber import indexa # noqa: E402 (_conocimiento)
from saber.enriquece import sintetiza # noqa: E402
# Instruccion mas general que la de sintetiza.py (aquello era solo pentest).
sintetiza.INSTRUCCION = """Eres un generador de preguntas para un buscador de
herramientas y software. Te doy un fragmento con una lista de programas o tecnicas
de una categoria, cada uno con una breve descripcion. Devuelve de UNA a TRES
preguntas, en castellano, que una persona haria por voz buscando algo de esa
categoria y que este fragmento ayuda a responder.
Reglas:
- Preguntas naturales: "que programa hay para...", "con que puedo montar un...",
"cual es una alternativa libre a...", "que herramienta sirve para...".
- Piensa en la NECESIDAD (que quiere hacer), no en nombres concretos.
- NO respondas, NO copies nombres ni comandos: solo las preguntas.
- Si el fragmento es ruido (un titulo suelto, sin descripciones), lista vacia.
Responde SOLO con JSON: {"preguntas": ["...", "..."]}"""
def _fragmentos():
"""Conocimiento (publico) primero, luego catalogo publico, y al final el
personal (awesome-hacking). Cada uno con su marca `publico`."""
frags = []
for e in indexa._conocimiento(): # el pack del repo: todo publico
e = dict(e)
e["publico"] = True
frags.append(e)
for e in awesome.entradas(): # catalogo: ya trae `publico`
frags.append(e)
frags.sort(key=lambda d: (0 if d.get("publico") else 1,
0 if d.get("tipo") == "metodologia" else 1))
return frags
def main():
p = argparse.ArgumentParser()
p.add_argument("--muestra", type=int, default=0)
a = p.parse_args()
frags = _fragmentos()
modelo = sintetiza._modelo()
npub = sum(1 for f in frags if f.get("publico"))
print(f" {len(frags)} fragmentos ({npub} publicos) · modelo {modelo}", flush=True)
if a.muestra:
for fr in frags[:a.muestra]:
print(f"\n ── {fr.get('fichero')} ──")
for q in sintetiza._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 {desde} ({hechas} preguntas)", flush=True)
total = len(frags)
with open(SALIDA, modo, encoding="utf-8", buffering=1) as out:
for i in range(desde, total):
if i % 25 == 0:
modelo = sintetiza._modelo()
fr = frags[i]
for q in sintetiza._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", ""),
"fuente": fr.get("fuente"),
"licencia": fr.get("licencia"),
"publico": bool(fr.get("publico")),
"texto": fr["texto"],
"indexar": q,
}, ensure_ascii=False) + "\n")
hechas += 1
with open(MARCA, "w") as m:
m.write(str(i))
if (i + 1) % 25 == 0:
print(f" PROGRESO catalogo 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())

View file

@ -165,6 +165,41 @@ def _catalogo(solo_publico=False):
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 = []
@ -194,7 +229,10 @@ def _guarda_indice(todo, ruta):
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)
# 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:
@ -218,9 +256,10 @@ def construye_publico(solo_cuenta=False):
glo = list(glosario.entradas())
con = _conocimiento()
cat = _catalogo(solo_publico=True) # solo las awesome de licencia redistribuible
todo = glo + con + cat
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"= {len(todo)} entradas publicas")
f"+ ganchos {len(gan)} = {len(todo)} entradas publicas")
if solo_cuenta:
return 0
_guarda_indice(todo, INDICE_PUBLICO)
@ -286,10 +325,14 @@ def construye(solo_cuenta=False, solo_cofre=False):
if catalogo:
print(f" CATALOGO {len(catalogo):5d} fragmentos (awesome lists)")
todo = fichas + fragmentos + sistema + conocimiento + catalogo
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(todo)} entradas")
f"{len(catalogo)} catalogo + {len(ganchos)} ganchos = {len(todo)} entradas")
if solo_cuenta:
return 0