#!/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())