diff --git a/README.md b/README.md index 761fc08..2cd4ebf 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,24 @@ Después: ./nucleo/arranca.sh # el asistente y el panel, en 127.0.0.1 ``` +El RAG funciona nada más clonar: el repo trae un índice público pre-construido, así +que no hace falta indexar para que sepa de metodología, comandos y herramientas. + +### Comprobar el RAG + +Si la búsqueda de conocimiento no responde bien, este diagnóstico revisa la cadena +entera y marca en verde/rojo cada eslabón —dependencias, índice, embedder, +búsqueda y Ollama— con una línea de qué hacer si algo falla: + +```bash +./probar_rag.sh # comprobaciones rápidas +./probar_rag.sh --eval # además mide la calidad de recuperación (hit@k) +./probar_rag.sh --verboso # enseña el primer resultado de cada búsqueda +``` + +No necesita nada arrancado y sale con código distinto de cero si hay algún fallo +duro, así que sirve también para CI. + ## Qué le puedes pedir Habla en castellano, natural. **150 acciones** en el catálogo, agrupadas: diff --git a/nucleo/saber/diagnostico.py b/nucleo/saber/diagnostico.py new file mode 100644 index 0000000..9018960 --- /dev/null +++ b/nucleo/saber/diagnostico.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Diagnostico del RAG: comprueba la cadena entera y dice que falla y como arreglarlo. + + python -m saber.diagnostico comprobaciones rapidas (segundos) + python -m saber.diagnostico --eval ademas mide hit@k con el eval_set + python -m saber.diagnostico --verboso ensena el detalle de cada resultado + +Pensado para cuando "el RAG no responde bien": en vez de adivinar, corres esto y +te marca en verde/rojo cada eslabon —dependencias, indice, embedder, la busqueda, +y opcionalmente ollama— con una linea de que hacer si falla. Sale con codigo != 0 +si hay algun fallo duro, para poder meterlo en CI. +""" +import argparse +import os +import sys +import traceback + +AQUI = os.path.dirname(os.path.abspath(__file__)) +NUCLEO = os.path.dirname(AQUI) +if NUCLEO not in sys.path: + sys.path.insert(0, NUCLEO) + +_ok = _fallo = _aviso = 0 + + +def _color(txt, c): + return f"\033[{c}m{txt}\033[0m" if sys.stdout.isatty() else txt + + +def marca(estado, nombre, detalle="", arregla=""): + global _ok, _fallo, _aviso + etq = {"ok": _color("OK ", "32"), "fallo": _color("FALLO", "31"), + "aviso": _color("AVISO", "33")}[estado] + print(f" {etq} {nombre}") + if detalle: + print(f" {detalle}") + if arregla and estado != "ok": + print(f" {_color('→ ' + arregla, '90')}") + _ok += estado == "ok" + _fallo += estado == "fallo" + _aviso += estado == "aviso" + + +# ── Consultas de humo: valen tanto en el indice publico como en el personal ── +# (consulta, [palabras que un buen resultado deberia contener, cualquiera vale]) +HUMO = [ + ("como cambiar los permisos de un fichero", ["chmod"]), + ("como saco una shell reversa", ["/dev/tcp", "nc ", "bash -i", "reversa"]), + ("escanear puertos de una maquina", ["nmap"]), + ("alternativa self-hosted a google analytics", ["analytic", "plausible", + "matomo", "goatcounter", "umami"]), +] + + +def check_deps(): + try: + import numpy # noqa: F401 + import model2vec # noqa: F401 + marca("ok", "dependencias (numpy, model2vec) importan") + return True + except ImportError as e: + marca("fallo", "dependencias", f"falta: {e.name}", + "instala con: pip install -r requirements.txt (o activa el venv)") + return False + + +def check_indice(): + from saber import busca + fuente = busca._fuente() + if not fuente: + marca("fallo", "indice presente", "no hay saber.jsonl ni saber-publico.jsonl", + "corre nucleo/saber/indexa.py, o comprueba que el repo trae " + "nucleo/datos/saber-publico.jsonl") + return None + cual = "personal" if fuente.endswith("saber.jsonl") else "publico (del repo)" + kb = os.path.getsize(fuente) // 1024 + marca("ok", "indice presente", f"{cual}: {os.path.basename(fuente)} ({kb} KB)") + return busca + + +def check_carga(busca): + try: + if not busca._carga(): + marca("fallo", "carga del indice", "el indice esta vacio o ilegible", + "reconstruyelo con nucleo/saber/indexa.py") + return False + except Exception as e: + marca("fallo", "carga del indice", f"{type(e).__name__}: {e}", + "el jsonl puede estar corrupto; reconstruye con indexa.py") + if os.environ.get("JARVIS_DEBUG"): + traceback.print_exc() + return False + n = len(busca._entradas) + tipos = {} + for e in busca._entradas: + tipos[e.get("tipo", "?")] = tipos.get(e.get("tipo", "?"), 0) + 1 + resumen = ", ".join(f"{k}:{v}" for k, v in sorted(tipos.items(), + key=lambda x: -x[1])[:6]) + marca("ok", "carga del indice", f"{n} entradas ({resumen})") + # avisos si falta contenido que deberia venir con el repo + if tipos.get("metodologia", 0) == 0: + marca("aviso", "pack de conocimiento", "0 fragmentos de metodologia", + "deberia venir en nucleo/saber/conocimiento/; revisa el clon") + if tipos.get("catalogo", 0) == 0: + marca("aviso", "catalogo (awesome lists)", "0 fragmentos de catalogo", + "clona listas en ~/COFRE/CODERS/ y reindexa, o usa el indice publico") + return True + + +def check_busqueda(busca, verboso): + todo_ok = True + for consulta, esperadas in HUMO: + try: + res = busca.busca(consulta, cuantos=5) + except Exception as e: + marca("fallo", f"busqueda: '{consulta}'", f"{type(e).__name__}: {e}", + "falla el embedder o la busqueda; revisa model2vec") + todo_ok = False + continue + blob = " ".join(r.get("texto", "").lower() for r in res) + acierto = any(p.lower() in blob for p in esperadas) + top = (res[0].get("herramienta") or res[0].get("perfil") or "?") if res else "(nada)" + if acierto: + marca("ok", f"busqueda: '{consulta}'", f"top: {top}" if verboso else "") + else: + marca("aviso", f"busqueda: '{consulta}'", + f"top: {top} — no contiene {esperadas}", + "recuperacion floja para esta consulta; puede faltar contenido") + return todo_ok + + +def check_ollama(): + import json + import urllib.request + endpoint = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434") + try: + with urllib.request.urlopen(endpoint + "/api/tags", timeout=5) as r: + modelos = [m["name"] for m in json.load(r).get("models", [])] + except Exception as e: + marca("aviso", "ollama (para el cerebro)", f"no responde en {endpoint}: {e}", + "el RAG recupera sin ollama, pero el asistente necesita: " + "arranca ollama y descarga el modelo (ollama pull qwen3.5:4b)") + return + if not modelos: + marca("aviso", "ollama (para el cerebro)", "responde pero sin modelos", + "descarga uno: ollama pull qwen3.5:4b") + return + marca("ok", "ollama (para el cerebro)", f"{len(modelos)} modelos: {', '.join(modelos[:4])}") + + +def check_eval(busca): + """Mide hit@k con el conjunto de evaluacion, si esta.""" + import json + ruta = os.path.join(AQUI, "enriquece", "eval_set.jsonl") + if not os.path.exists(ruta): + marca("aviso", "eval hit@k", "no hay enriquece/eval_set.jsonl") + return + casos = [json.loads(l) for l in open(ruta, encoding="utf-8") if l.strip()] + h1 = h5 = 0 + for c in casos: + res = busca.busca(c["pregunta"], cuantos=5) + textos = [r.get("texto", "").lower() for r in res] + esperado = [p.lower() for p in c.get("espera", c.get("contiene", []))] + # misma semantica que evalua.py: basta que UNA palabra esperada aparezca + acierta = lambda t: any(p and p in t for p in esperado) + if res and acierta(textos[0]): + h1 += 1 + if any(acierta(t) for t in textos): + h5 += 1 + n = len(casos) or 1 + est = "ok" if 100 * h1 // n >= 50 else "aviso" + marca(est, "eval hit@k", + f"hit@1 {h1}/{n} ({100*h1//n}%) hit@5 {h5}/{n} ({100*h5//n}%)", + "recuperacion baja: reconstruye el indice o revisa el eval_set") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--eval", action="store_true", help="ademas mide hit@k") + p.add_argument("--verboso", action="store_true", help="ensena el top de cada busqueda") + a = p.parse_args() + + print("\n Diagnostico del RAG de JARVIS\n " + "─" * 40) + if not check_deps(): + print(f"\n {_color('Para el resto hacen falta las dependencias.', '31')}\n") + return 1 + busca = check_indice() + if busca and check_carga(busca): + check_busqueda(busca, a.verboso) + if a.eval: + check_eval(busca) + check_ollama() + + print(" " + "─" * 40) + resumen = f" {_ok} OK" + if _aviso: + resumen += f" {_aviso} avisos" + if _fallo: + resumen += f" {_color(str(_fallo) + ' fallos', '31')}" + print(resumen + "\n") + return 1 if _fallo else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/probar_rag.sh b/probar_rag.sh new file mode 100755 index 0000000..3543257 --- /dev/null +++ b/probar_rag.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Diagnostico del RAG: comprueba que la busqueda de conocimiento funciona y, si +# algo falla, dice que y como arreglarlo. +# +# ./probar_rag.sh comprobaciones rapidas +# ./probar_rag.sh --eval ademas mide la calidad de recuperacion (hit@k) +# ./probar_rag.sh --verboso ensena el primer resultado de cada busqueda +# +# No necesita nada arrancado: si no tienes indice propio, usa el publico del repo. +set -e +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) +PY="$RAIZ/nucleo/venv/bin/python" +[ -x "$PY" ] || PY=$(command -v python3) +[ -n "$PY" ] || { echo "no encuentro python3"; exit 1; } +cd "$RAIZ/nucleo" +exec "$PY" -m saber.diagnostico "$@"