gusdelact commited on
Commit
2e3520f
·
verified ·
1 Parent(s): 7689f43

Upload folder using huggingface_hub

Browse files
rag_books_mcp/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # RAG Books MCP Server v2
2
+ """MCP Server v2 que expone herramientas RAG sobre ESL e ISLP.
3
+
4
+ Diferencia clave con v1: la base vectorial ChromaDB no se empaqueta junto al
5
+ código. Se publica como dataset en HF Hub (`gusdelact/rag-esl-islp-chromadb`
6
+ por default) y este server hace `snapshot_download` la primera vez que se
7
+ necesita. Ver `tools.py` para los detalles.
8
+ """
9
+
10
+ __version__ = "2.0.0"
rag_books_mcp/app.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio app v2 que expone las 4 tools como MCP Server (streamable HTTP).
3
+
4
+ Diferencia con v1: la base ChromaDB se descarga del dataset HF Hub al primer
5
+ uso. La carga del modelo de embeddings + descarga del snapshot se hace lazy
6
+ en la primera tool call, no al arrancar el Space.
7
+
8
+ Local:
9
+ uv run python -m rag_books_mcp.app
10
+
11
+ HF Spaces:
12
+ Ver `deploy_to_hf_space.py`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import gradio as gr
18
+
19
+ from rag_books_mcp.tools import (
20
+ cite_foundation,
21
+ get_section,
22
+ list_available_topics,
23
+ search_theory,
24
+ )
25
+
26
+
27
+ def _build_search_tab() -> gr.Interface:
28
+ return gr.Interface(
29
+ fn=search_theory,
30
+ inputs=[
31
+ gr.Textbox(
32
+ label="query",
33
+ value="bias-variance tradeoff",
34
+ placeholder="Consulta en lenguaje natural",
35
+ ),
36
+ gr.Radio(choices=["both", "esl", "islp"], value="both", label="book"),
37
+ gr.Slider(minimum=1, maximum=10, step=1, value=5, label="top_k"),
38
+ ],
39
+ outputs=gr.Markdown(label="Resultados"),
40
+ title="🔎 search_theory",
41
+ description=(
42
+ "Búsqueda semántica en ESL e ISLP. Devuelve los fragmentos más "
43
+ "relevantes ordenados por similitud."
44
+ ),
45
+ api_name="search_theory",
46
+ )
47
+
48
+
49
+ def _build_get_section_tab() -> gr.Interface:
50
+ return gr.Interface(
51
+ fn=get_section,
52
+ inputs=[
53
+ gr.Radio(choices=["esl", "islp"], value="islp", label="book"),
54
+ gr.Textbox(
55
+ label="chapter",
56
+ value="8 Tree-Based Methods",
57
+ placeholder="Nombre del capítulo (búsqueda parcial soportada)",
58
+ ),
59
+ gr.Textbox(
60
+ label="section",
61
+ value="",
62
+ placeholder="(Opcional) Nombre de la sección",
63
+ ),
64
+ gr.Slider(minimum=1, maximum=15, step=1, value=5, label="max_chunks"),
65
+ ],
66
+ outputs=gr.Markdown(label="Sección"),
67
+ title="📑 get_section",
68
+ description=(
69
+ "Recupera una sección específica de ESL o ISLP. Si no se encuentra "
70
+ "por metadata, hace fallback a búsqueda semántica."
71
+ ),
72
+ api_name="get_section",
73
+ )
74
+
75
+
76
+ def _build_cite_tab() -> gr.Interface:
77
+ return gr.Interface(
78
+ fn=cite_foundation,
79
+ inputs=[
80
+ gr.Textbox(
81
+ label="topic",
82
+ value="ridge regression",
83
+ placeholder="Tema a fundamentar",
84
+ ),
85
+ gr.Radio(
86
+ choices=["brief", "medium", "deep"],
87
+ value="medium",
88
+ label="detail_level",
89
+ ),
90
+ ],
91
+ outputs=gr.Markdown(label="Fundamentación"),
92
+ title="📚 cite_foundation",
93
+ description=(
94
+ "Fundamentación teórica que cita ambos libros: ISLP (intuitivo) y "
95
+ "ESL (riguroso)."
96
+ ),
97
+ api_name="cite_foundation",
98
+ )
99
+
100
+
101
+ def _build_list_topics_tab() -> gr.Interface:
102
+ return gr.Interface(
103
+ fn=list_available_topics,
104
+ inputs=[],
105
+ outputs=gr.Markdown(label="Contenido indexado"),
106
+ title="🗂️ list_available_topics",
107
+ description="Lista los capítulos y secciones indexados en ChromaDB.",
108
+ api_name="list_available_topics",
109
+ )
110
+
111
+
112
+ def build_demo() -> gr.Blocks:
113
+ """Construye la UI tabulada del MCP Server v2."""
114
+ with gr.Blocks(title="rag-books-mcp v2 · ESL + ISLP") as demo:
115
+ gr.Markdown(
116
+ """
117
+ # 📖 RAG Books MCP v2 — ESL + ISLP
118
+
119
+ Servidor MCP que expone búsqueda semántica sobre dos libros de
120
+ referencia de Statistical Learning:
121
+
122
+ - **ESL** — *The Elements of Statistical Learning* (Hastie, Tibshirani, Friedman)
123
+ - **ISLP** — *An Introduction to Statistical Learning with Python* (James, Witten, Hastie, Tibshirani)
124
+
125
+ **v2 vs v1:** la base ChromaDB se carga desde el dataset HF
126
+ `gusdelact/rag-esl-islp-chromadb` en lugar de empaquetarla con el
127
+ código. Permite versionar el índice independientemente y reusarlo
128
+ desde otros clientes.
129
+
130
+ **Endpoint MCP:** `/gradio_api/mcp/` (streamable HTTP).
131
+ **Embeddings:** `sentence-transformers/all-MiniLM-L6-v2` (local, sin API key).
132
+ **Vector store:** ChromaDB con 1977 chunks (1093 ESL + 884 ISLP).
133
+
134
+ La primera tool call descarga el dataset (~40 MB). Las siguientes
135
+ son cache hit.
136
+ """
137
+ )
138
+
139
+ gr.TabbedInterface(
140
+ interface_list=[
141
+ _build_search_tab(),
142
+ _build_cite_tab(),
143
+ _build_get_section_tab(),
144
+ _build_list_topics_tab(),
145
+ ],
146
+ tab_names=["search_theory", "cite_foundation", "get_section", "list_available_topics"],
147
+ )
148
+
149
+ return demo
150
+
151
+
152
+ def main() -> None:
153
+ demo = build_demo()
154
+ demo.launch(mcp_server=True, server_name="0.0.0.0")
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
rag_books_mcp/ingest.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Script de ingesta: vectoriza los capítulos de ESL e ISLP en ChromaDB.
3
+
4
+ Uso:
5
+ python -m rag_books_mcp.ingest --books-dir ../ebook
6
+
7
+ Esto crea/actualiza la base vectorial en ./chroma_db/
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import argparse
13
+ from pathlib import Path
14
+
15
+ import chromadb
16
+ from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
17
+
18
+
19
+ # --- Configuración ---
20
+ EMBEDDING_MODEL = "all-MiniLM-L6-v2"
21
+ CHUNK_SIZE = 600 # tokens aprox (caracteres / 4)
22
+ CHUNK_OVERLAP = 100
23
+ CHROMA_DIR = Path(__file__).parent.parent / "chroma_db"
24
+
25
+ BOOKS_CONFIG = {
26
+ "esl": {
27
+ "dir_name": "capitulos_TheElementsOfStatisticalLearning",
28
+ "collection": "esl_chapters",
29
+ "full_name": "The Elements of Statistical Learning (Hastie, Tibshirani, Friedman)",
30
+ },
31
+ "islp": {
32
+ "dir_name": "capitulos_islp",
33
+ "collection": "islp_chapters",
34
+ "full_name": "An Introduction to Statistical Learning with Python (James, Witten, Hastie, Tibshirani)",
35
+ },
36
+ }
37
+
38
+
39
+ def extract_chapter_info(filename: str) -> dict:
40
+ """Extrae número de archivo y nombre del capítulo del filename."""
41
+ # Formato: 04_3_Linear_Methods_for_Regression.md
42
+ stem = Path(filename).stem
43
+ parts = stem.split("_", 1)
44
+ file_order = parts[0] if parts else "00"
45
+ chapter_title = parts[1].replace("_", " ") if len(parts) > 1 else stem
46
+ return {"file_order": file_order, "chapter_title": chapter_title}
47
+
48
+
49
+ def split_by_sections(text: str, chapter_title: str) -> list[dict]:
50
+ """
51
+ Divide el texto en secciones usando headers markdown (# y ##).
52
+ Cada sección se subdivide en chunks si es muy larga.
53
+ """
54
+ # Patrón para detectar headers de nivel 1-3
55
+ header_pattern = re.compile(r"^(#{1,3})\s+(.+)$", re.MULTILINE)
56
+
57
+ sections = []
58
+ matches = list(header_pattern.finditer(text))
59
+
60
+ if not matches:
61
+ # Sin headers, tratar todo como una sección
62
+ sections.append({"title": chapter_title, "level": 1, "content": text.strip()})
63
+ else:
64
+ # Texto antes del primer header
65
+ pre_text = text[: matches[0].start()].strip()
66
+ if pre_text and len(pre_text) > 50:
67
+ sections.append({"title": chapter_title, "level": 1, "content": pre_text})
68
+
69
+ for i, match in enumerate(matches):
70
+ level = len(match.group(1))
71
+ title = match.group(2).strip()
72
+ start = match.end()
73
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
74
+ content = text[start:end].strip()
75
+
76
+ if content and len(content) > 30:
77
+ sections.append({"title": title, "level": level, "content": content})
78
+
79
+ return sections
80
+
81
+
82
+ def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
83
+ """
84
+ Divide texto en chunks por caracteres con overlap.
85
+ Intenta cortar en saltos de línea o puntos para no romper oraciones.
86
+ """
87
+ # Convertir chunk_size de tokens aprox a caracteres (1 token ≈ 4 chars)
88
+ char_size = chunk_size * 4
89
+ char_overlap = overlap * 4
90
+
91
+ if len(text) <= char_size:
92
+ return [text]
93
+
94
+ chunks = []
95
+ start = 0
96
+
97
+ while start < len(text):
98
+ end = start + char_size
99
+
100
+ if end < len(text):
101
+ # Buscar un buen punto de corte (párrafo o punto)
102
+ # Primero intentar doble newline (párrafo)
103
+ cut_point = text.rfind("\n\n", start + char_size // 2, end)
104
+ if cut_point == -1:
105
+ # Intentar punto seguido de espacio
106
+ cut_point = text.rfind(". ", start + char_size // 2, end)
107
+ if cut_point != -1:
108
+ cut_point += 1 # incluir el punto
109
+ if cut_point == -1:
110
+ # Intentar newline simple
111
+ cut_point = text.rfind("\n", start + char_size // 2, end)
112
+ if cut_point == -1:
113
+ cut_point = end
114
+
115
+ end = cut_point
116
+
117
+ chunk = text[start:end].strip()
118
+ if chunk:
119
+ chunks.append(chunk)
120
+
121
+ start = end - char_overlap
122
+ if start >= len(text):
123
+ break
124
+
125
+ return chunks
126
+
127
+
128
+ def clean_text(text: str) -> str:
129
+ """Limpia artefactos de la extracción PDF."""
130
+ # Eliminar marcadores de página
131
+ text = re.sub(r"---\s*Página\s*\d+\s*---", "", text)
132
+ # Eliminar líneas con solo números (números de página sueltos)
133
+ text = re.sub(r"^\d+\s*$", "", text, flags=re.MULTILINE)
134
+ # Reducir múltiples líneas vacías
135
+ text = re.sub(r"\n{4,}", "\n\n\n", text)
136
+ # Eliminar copyright notices
137
+ text = re.sub(r"©.*?(?:\n|$)", "", text)
138
+ return text.strip()
139
+
140
+
141
+ def ingest_book(books_dir: Path, book_key: str, client: chromadb.ClientAPI, embedding_fn):
142
+ """Ingesta un libro completo en ChromaDB."""
143
+ config = BOOKS_CONFIG[book_key]
144
+ chapters_dir = books_dir / config["dir_name"]
145
+
146
+ if not chapters_dir.exists():
147
+ print(f" ⚠️ Directorio no encontrado: {chapters_dir}")
148
+ return 0
149
+
150
+ # Crear o obtener colección (reset si existe)
151
+ try:
152
+ client.delete_collection(config["collection"])
153
+ except Exception:
154
+ pass
155
+
156
+ collection = client.get_or_create_collection(
157
+ name=config["collection"],
158
+ embedding_function=embedding_fn,
159
+ metadata={"hnsw:space": "cosine"},
160
+ )
161
+
162
+ total_chunks = 0
163
+ md_files = sorted(chapters_dir.glob("*.md"))
164
+
165
+ print(f"\n 📚 {config['full_name']}")
166
+ print(f" Archivos encontrados: {len(md_files)}")
167
+
168
+ for md_file in md_files:
169
+ chapter_info = extract_chapter_info(md_file.name)
170
+ raw_text = md_file.read_text(encoding="utf-8")
171
+ text = clean_text(raw_text)
172
+
173
+ if len(text) < 100:
174
+ continue
175
+
176
+ # Dividir en secciones
177
+ sections = split_by_sections(text, chapter_info["chapter_title"])
178
+
179
+ for section in sections:
180
+ # Dividir secciones largas en chunks
181
+ chunks = chunk_text(section["content"])
182
+
183
+ for i, chunk in enumerate(chunks):
184
+ chunk_id = f"{book_key}_{chapter_info['file_order']}_{section['title'][:30]}_{i}"
185
+ # Sanitizar ID
186
+ chunk_id = re.sub(r"[^a-zA-Z0-9_-]", "_", chunk_id)
187
+
188
+ metadata = {
189
+ "book": book_key,
190
+ "book_full_name": config["full_name"],
191
+ "chapter": chapter_info["chapter_title"],
192
+ "section": section["title"],
193
+ "section_level": section["level"],
194
+ "chunk_index": i,
195
+ "total_chunks_in_section": len(chunks),
196
+ "file": md_file.name,
197
+ }
198
+
199
+ collection.add(
200
+ ids=[chunk_id],
201
+ documents=[chunk],
202
+ metadatas=[metadata],
203
+ )
204
+ total_chunks += 1
205
+
206
+ print(f" ✓ {md_file.name} → {len(sections)} secciones")
207
+
208
+ print(f" Total chunks: {total_chunks}")
209
+ return total_chunks
210
+
211
+
212
+ def main():
213
+ parser = argparse.ArgumentParser(description="Ingesta de libros ESL/ISLP en ChromaDB")
214
+ parser.add_argument(
215
+ "--books-dir",
216
+ type=Path,
217
+ default=Path(__file__).parent.parent.parent / "ebook",
218
+ help="Directorio raíz con las carpetas de capítulos",
219
+ )
220
+ parser.add_argument(
221
+ "--chroma-dir",
222
+ type=Path,
223
+ default=CHROMA_DIR,
224
+ help="Directorio para la base de datos ChromaDB",
225
+ )
226
+ args = parser.parse_args()
227
+
228
+ print("🔧 Inicializando embedding model...")
229
+ embedding_fn = SentenceTransformerEmbeddingFunction(model_name=EMBEDDING_MODEL)
230
+
231
+ print(f"🗄️ ChromaDB persistente en: {args.chroma_dir}")
232
+ client = chromadb.PersistentClient(path=str(args.chroma_dir))
233
+
234
+ print("\n📖 Iniciando ingesta de libros...")
235
+ total = 0
236
+ for book_key in BOOKS_CONFIG:
237
+ total += ingest_book(args.books_dir, book_key, client, embedding_fn)
238
+
239
+ print(f"\n✅ Ingesta completada. Total de chunks vectorizados: {total}")
240
+ print(f" Base de datos en: {args.chroma_dir}")
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()
rag_books_mcp/server.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Server v2 (transporte stdio) — RAG sobre ESL e ISLP.
3
+
4
+ Diferencia con v1: la base ChromaDB se obtiene de un dataset HF Hub
5
+ (ver `rag_books_mcp.tools` para la resolución de la ruta).
6
+ """
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from rag_books_mcp.tools import (
11
+ cite_foundation as _cite_foundation,
12
+ get_section as _get_section,
13
+ list_available_topics as _list_available_topics,
14
+ search_theory as _search_theory,
15
+ )
16
+
17
+
18
+ mcp = FastMCP(
19
+ "rag-books-mcp-v2",
20
+ instructions=(
21
+ "RAG sobre los libros ESL e ISLP. v2: base vectorial ChromaDB cargada "
22
+ "desde un dataset publicado en HF Hub (separación código/datos)."
23
+ ),
24
+ )
25
+
26
+
27
+ @mcp.tool()
28
+ def search_theory(query: str, book: str = "both", top_k: int = 5) -> str:
29
+ """Busca fragmentos relevantes en ESL/ISLP usando búsqueda semántica.
30
+
31
+ Args:
32
+ query: Consulta en lenguaje natural (ej: "bias-variance tradeoff").
33
+ book: "esl", "islp" o "both" (default: "both").
34
+ top_k: Número de resultados (1-10, default: 5).
35
+ """
36
+ return _search_theory(query=query, book=book, top_k=top_k)
37
+
38
+
39
+ @mcp.tool()
40
+ def get_section(book: str, chapter: str, section: str = "", max_chunks: int = 5) -> str:
41
+ """Recupera una sección específica de ESL o ISLP por referencia exacta.
42
+
43
+ Args:
44
+ book: "esl" o "islp".
45
+ chapter: Nombre del capítulo (búsqueda parcial soportada).
46
+ section: Nombre de la sección dentro del capítulo (opcional).
47
+ max_chunks: Máximo de chunks a devolver (default: 5).
48
+ """
49
+ return _get_section(book=book, chapter=chapter, section=section, max_chunks=max_chunks)
50
+
51
+
52
+ @mcp.tool()
53
+ def cite_foundation(topic: str, detail_level: str = "medium") -> str:
54
+ """Fundamentación teórica de un tema citando ambos libros (ESL + ISLP).
55
+
56
+ Args:
57
+ topic: Tema a fundamentar (ej: "ridge regression", "bagging").
58
+ detail_level: "brief", "medium" (default) o "deep".
59
+ """
60
+ return _cite_foundation(topic=topic, detail_level=detail_level)
61
+
62
+
63
+ @mcp.tool()
64
+ def list_available_topics() -> str:
65
+ """Lista los capítulos y temas indexados en la base de conocimiento."""
66
+ return _list_available_topics()
67
+
68
+
69
+ def main():
70
+ """Punto de entrada del MCP server (stdio)."""
71
+ mcp.run(transport="stdio")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
rag_books_mcp/tools.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lógica de las 4 tools de RAG sobre ESL e ISLP (v2).
3
+
4
+ Diferencia con v1: la base ChromaDB se obtiene de un dataset publicado en
5
+ HF Hub vía `snapshot_download`. La primera invocación tarda lo que tarde
6
+ la descarga (~40 MB); las siguientes son cache hit.
7
+
8
+ Variables de entorno:
9
+ - RAG_CHROMA_DIR Si está set y apunta a una carpeta existente, se usa
10
+ en lugar del dataset (útil para dev local con índice
11
+ recién regenerado por `ingest.py`).
12
+ - RAG_CHROMA_DATASET Repo del dataset HF a descargar.
13
+ Default: gusdelact/rag-esl-islp-chromadb
14
+ - RAG_CHROMA_REVISION Revision (branch/tag/commit) del dataset.
15
+ Default: main
16
+ - RAG_CHROMA_CACHE_DIR Directorio cache para el snapshot_download.
17
+ Default: ~/.cache/rag-books-mcp/chroma_db (o /data/chroma_db
18
+ si existe /data, como en HF Spaces con persistent storage).
19
+ - HF_TOKEN Solo si el dataset es privado.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ import chromadb
30
+ from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
31
+
32
+
33
+ # --- Configuración ---
34
+ EMBEDDING_MODEL = "all-MiniLM-L6-v2"
35
+
36
+ DEFAULT_DATASET = "gusdelact/rag-esl-islp-chromadb"
37
+ DEFAULT_REVISION = "main"
38
+
39
+
40
+ def _resolve_cache_dir() -> Path:
41
+ """Decide dónde guardar el snapshot del dataset.
42
+
43
+ Prioridad:
44
+ 1. RAG_CHROMA_CACHE_DIR si está set.
45
+ 2. /data/chroma_db si existe /data (HF Spaces con persistent storage).
46
+ 3. ~/.cache/rag-books-mcp/chroma_db.
47
+ """
48
+ explicit = os.environ.get("RAG_CHROMA_CACHE_DIR")
49
+ if explicit:
50
+ return Path(explicit)
51
+ if Path("/data").is_dir():
52
+ return Path("/data/chroma_db")
53
+ return Path.home() / ".cache" / "rag-books-mcp" / "chroma_db"
54
+
55
+
56
+ # Singletons por proceso
57
+ _client: Optional[chromadb.ClientAPI] = None
58
+ _embedding_fn = None
59
+ _chroma_path_resolved: Optional[str] = None
60
+
61
+
62
+ def _resolve_chroma_path() -> str:
63
+ """Resuelve la ruta a usar como ChromaDB persistente.
64
+
65
+ Si `RAG_CHROMA_DIR` apunta a una carpeta existente, la usa tal cual.
66
+ En caso contrario, baja `RAG_CHROMA_DATASET@RAG_CHROMA_REVISION` desde HF
67
+ Hub y devuelve la ruta al snapshot.
68
+ """
69
+ global _chroma_path_resolved
70
+ if _chroma_path_resolved is not None:
71
+ return _chroma_path_resolved
72
+
73
+ override = os.environ.get("RAG_CHROMA_DIR")
74
+ if override and Path(override).is_dir():
75
+ print(f"[rag-books-mcp v2] Using local RAG_CHROMA_DIR: {override}", file=sys.stderr)
76
+ _chroma_path_resolved = override
77
+ return override
78
+
79
+ repo_id = os.environ.get("RAG_CHROMA_DATASET", DEFAULT_DATASET)
80
+ revision = os.environ.get("RAG_CHROMA_REVISION", DEFAULT_REVISION)
81
+ cache_dir = _resolve_cache_dir()
82
+ cache_dir.mkdir(parents=True, exist_ok=True)
83
+
84
+ print(
85
+ f"[rag-books-mcp v2] Downloading ChromaDB from HF dataset "
86
+ f"{repo_id}@{revision} into {cache_dir} ...",
87
+ file=sys.stderr,
88
+ )
89
+
90
+ # Import perezoso para no pagar el costo si está cacheado vía RAG_CHROMA_DIR.
91
+ from huggingface_hub import snapshot_download
92
+
93
+ snapshot = snapshot_download(
94
+ repo_id=repo_id,
95
+ repo_type="dataset",
96
+ revision=revision,
97
+ cache_dir=str(cache_dir),
98
+ token=os.environ.get("HF_TOKEN"), # solo si es privado
99
+ )
100
+
101
+ print(f"[rag-books-mcp v2] ChromaDB ready at {snapshot}", file=sys.stderr)
102
+ _chroma_path_resolved = snapshot
103
+ return snapshot
104
+
105
+
106
+ def get_client() -> chromadb.ClientAPI:
107
+ """Cliente ChromaDB persistente (singleton)."""
108
+ global _client
109
+ if _client is None:
110
+ _client = chromadb.PersistentClient(path=_resolve_chroma_path())
111
+ return _client
112
+
113
+
114
+ def get_embedding_fn():
115
+ """Función de embedding `sentence-transformers/all-MiniLM-L6-v2` (singleton)."""
116
+ global _embedding_fn
117
+ if _embedding_fn is None:
118
+ _embedding_fn = SentenceTransformerEmbeddingFunction(model_name=EMBEDDING_MODEL)
119
+ return _embedding_fn
120
+
121
+
122
+ def get_collection(name: str):
123
+ """Obtiene una colección de ChromaDB por nombre."""
124
+ return get_client().get_collection(name=name, embedding_function=get_embedding_fn())
125
+
126
+
127
+ # --- Tools (idénticas en comportamiento a v1) ---
128
+
129
+ def search_theory(
130
+ query: str,
131
+ book: str = "both",
132
+ top_k: int = 5,
133
+ ) -> str:
134
+ """
135
+ Busca fragmentos relevantes en los libros ESL e ISLP usando búsqueda semántica.
136
+
137
+ Args:
138
+ query (str): Consulta en lenguaje natural (ej: "bias-variance tradeoff",
139
+ "regularización L1 vs L2", "random forest out-of-bag error").
140
+ book (str): Libro donde buscar. Opciones: "esl", "islp", "both" (default: "both").
141
+ top_k (int): Número de resultados a devolver (default: 5, máximo: 10).
142
+
143
+ Returns:
144
+ str: Fragmentos relevantes con metadata (libro, capítulo, sección, similitud).
145
+ """
146
+ top_k = min(max(int(top_k), 1), 10)
147
+
148
+ collections_to_search = []
149
+ if book in ("esl", "both"):
150
+ try:
151
+ collections_to_search.append(("ESL", get_collection("esl_chapters")))
152
+ except Exception:
153
+ pass
154
+ if book in ("islp", "both"):
155
+ try:
156
+ collections_to_search.append(("ISLP", get_collection("islp_chapters")))
157
+ except Exception:
158
+ pass
159
+
160
+ if not collections_to_search:
161
+ return (
162
+ "❌ No se encontraron colecciones. Verifica que el dataset HF "
163
+ "esté disponible o ejecuta la ingesta local."
164
+ )
165
+
166
+ results = []
167
+ for book_label, collection in collections_to_search:
168
+ res = collection.query(query_texts=[query], n_results=top_k)
169
+
170
+ if res["documents"] and res["documents"][0]:
171
+ for doc, meta, dist in zip(
172
+ res["documents"][0], res["metadatas"][0], res["distances"][0]
173
+ ):
174
+ similarity = 1 - dist
175
+ results.append({
176
+ "book": book_label,
177
+ "chapter": meta.get("chapter", ""),
178
+ "section": meta.get("section", ""),
179
+ "similarity": similarity,
180
+ "content": doc,
181
+ })
182
+
183
+ results.sort(key=lambda x: x["similarity"], reverse=True)
184
+ results = results[:top_k]
185
+
186
+ if not results:
187
+ return f"No se encontraron resultados para: '{query}'"
188
+
189
+ output_parts = [f"## Resultados para: \"{query}\"\n"]
190
+ for i, r in enumerate(results, 1):
191
+ output_parts.append(
192
+ f"### [{i}] {r['book']} — {r['chapter']} § {r['section']}\n"
193
+ f"**Similitud:** {r['similarity']:.3f}\n\n"
194
+ f"{r['content'][:1500]}\n\n---\n"
195
+ )
196
+ return "\n".join(output_parts)
197
+
198
+
199
+ def get_section(
200
+ book: str,
201
+ chapter: str,
202
+ section: str = "",
203
+ max_chunks: int = 5,
204
+ ) -> str:
205
+ """
206
+ Recupera una sección específica de un libro por referencia exacta.
207
+
208
+ Args:
209
+ book (str): Libro a consultar. Opciones: "esl" o "islp".
210
+ chapter (str): Nombre del capítulo (búsqueda parcial soportada).
211
+ section (str): Nombre de la sección dentro del capítulo (opcional).
212
+ max_chunks (int): Máximo de chunks a devolver (default: 5).
213
+
214
+ Returns:
215
+ str: Contenido de la sección con metadata.
216
+ """
217
+ max_chunks = int(max_chunks)
218
+ collection_name = f"{book}_chapters"
219
+ try:
220
+ collection = get_collection(collection_name)
221
+ except Exception:
222
+ return f"❌ Colección '{collection_name}' no encontrada. Opciones: esl, islp"
223
+
224
+ try:
225
+ if section:
226
+ results = collection.get(
227
+ where={"$and": [
228
+ {"chapter": {"$contains": chapter}},
229
+ {"section": {"$contains": section}},
230
+ ]},
231
+ limit=max_chunks,
232
+ )
233
+ else:
234
+ results = collection.get(
235
+ where={"chapter": {"$contains": chapter}},
236
+ limit=max_chunks,
237
+ )
238
+ except Exception:
239
+ search_query = f"{chapter} {section}".strip()
240
+ results = collection.query(query_texts=[search_query], n_results=max_chunks)
241
+ if results["documents"] and results["documents"][0]:
242
+ output_parts = [f"## {book.upper()} — {chapter}\n"]
243
+ for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
244
+ output_parts.append(
245
+ f"### § {meta.get('section', 'N/A')}\n\n{doc}\n\n---\n"
246
+ )
247
+ return "\n".join(output_parts)
248
+ return f"No se encontró el capítulo '{chapter}' en {book.upper()}"
249
+
250
+ if not results["documents"]:
251
+ search_query = f"{chapter} {section}".strip()
252
+ results = collection.query(query_texts=[search_query], n_results=max_chunks)
253
+ if results["documents"] and results["documents"][0]:
254
+ output_parts = [f"## {book.upper()} — {chapter}\n"]
255
+ for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
256
+ output_parts.append(
257
+ f"### § {meta.get('section', 'N/A')}\n\n{doc}\n\n---\n"
258
+ )
259
+ return "\n".join(output_parts)
260
+ return f"No se encontró el capítulo '{chapter}' en {book.upper()}"
261
+
262
+ output_parts = [f"## {book.upper()} — {chapter}"]
263
+ if section:
264
+ output_parts[0] += f" § {section}"
265
+ output_parts[0] += "\n"
266
+
267
+ for doc, meta in zip(results["documents"], results["metadatas"]):
268
+ sec_title = meta.get("section", "")
269
+ chunk_idx = meta.get("chunk_index", 0)
270
+ total = meta.get("total_chunks_in_section", 1)
271
+ output_parts.append(
272
+ f"### § {sec_title} (parte {chunk_idx + 1}/{total})\n\n{doc}\n\n---\n"
273
+ )
274
+ return "\n".join(output_parts)
275
+
276
+
277
+ def cite_foundation(
278
+ topic: str,
279
+ detail_level: str = "medium",
280
+ ) -> str:
281
+ """
282
+ Devuelve la fundamentación teórica para un tema citando ambos libros (ESL + ISLP).
283
+
284
+ Args:
285
+ topic (str): Tema a fundamentar (ej: "ridge regression", "bagging").
286
+ detail_level (str): "brief" (1-2), "medium" (3-4) o "deep" (6-8).
287
+
288
+ Returns:
289
+ str: Fundamentación teórica con citas, organizada de intuitivo (ISLP)
290
+ a riguroso (ESL).
291
+ """
292
+ top_k_map = {"brief": 2, "medium": 4, "deep": 8}
293
+ top_k = top_k_map.get(detail_level, 4)
294
+
295
+ islp_results = []
296
+ try:
297
+ islp_col = get_collection("islp_chapters")
298
+ res = islp_col.query(query_texts=[topic], n_results=top_k)
299
+ if res["documents"] and res["documents"][0]:
300
+ for doc, meta, dist in zip(
301
+ res["documents"][0], res["metadatas"][0], res["distances"][0]
302
+ ):
303
+ islp_results.append({
304
+ "content": doc,
305
+ "chapter": meta.get("chapter", ""),
306
+ "section": meta.get("section", ""),
307
+ "similarity": 1 - dist,
308
+ })
309
+ except Exception:
310
+ pass
311
+
312
+ esl_results = []
313
+ try:
314
+ esl_col = get_collection("esl_chapters")
315
+ res = esl_col.query(query_texts=[topic], n_results=top_k)
316
+ if res["documents"] and res["documents"][0]:
317
+ for doc, meta, dist in zip(
318
+ res["documents"][0], res["metadatas"][0], res["distances"][0]
319
+ ):
320
+ esl_results.append({
321
+ "content": doc,
322
+ "chapter": meta.get("chapter", ""),
323
+ "section": meta.get("section", ""),
324
+ "similarity": 1 - dist,
325
+ })
326
+ except Exception:
327
+ pass
328
+
329
+ if not islp_results and not esl_results:
330
+ return (
331
+ f"❌ No se encontró fundamentación para '{topic}'. "
332
+ "Verifica que la ingesta se haya ejecutado correctamente."
333
+ )
334
+
335
+ output_parts = [
336
+ f"# Fundamentación Teórica: {topic}\n",
337
+ f"**Nivel de detalle:** {detail_level}\n",
338
+ ]
339
+
340
+ if islp_results:
341
+ output_parts.append("\n## 📘 ISLP (Explicación Intuitiva)\n")
342
+ for i, r in enumerate(islp_results, 1):
343
+ output_parts.append(
344
+ f"### [{i}] Cap. {r['chapter']} § {r['section']} "
345
+ f"(sim: {r['similarity']:.3f})\n\n"
346
+ f"{r['content'][:1200]}\n\n---\n"
347
+ )
348
+
349
+ if esl_results:
350
+ output_parts.append("\n## 📗 ESL (Tratamiento Riguroso)\n")
351
+ for i, r in enumerate(esl_results, 1):
352
+ output_parts.append(
353
+ f"### [{i}] Cap. {r['chapter']} § {r['section']} "
354
+ f"(sim: {r['similarity']:.3f})\n\n"
355
+ f"{r['content'][:1200]}\n\n---\n"
356
+ )
357
+
358
+ output_parts.append("\n## 📚 Referencias\n")
359
+ if islp_results:
360
+ chapters = set(r["chapter"] for r in islp_results)
361
+ output_parts.append(f"- **ISLP:** {', '.join(chapters)}\n")
362
+ if esl_results:
363
+ chapters = set(r["chapter"] for r in esl_results)
364
+ output_parts.append(f"- **ESL:** {', '.join(chapters)}\n")
365
+
366
+ return "\n".join(output_parts)
367
+
368
+
369
+ def list_available_topics() -> str:
370
+ """
371
+ Lista los capítulos y temas indexados en la base de conocimiento.
372
+
373
+ Returns:
374
+ str: Lista organizada de capítulos por libro con sus secciones principales.
375
+ """
376
+ output_parts = ["# 📚 Contenido Disponible en la Base de Conocimiento\n"]
377
+
378
+ for book_key, collection_name in [("ESL", "esl_chapters"), ("ISLP", "islp_chapters")]:
379
+ try:
380
+ collection = get_collection(collection_name)
381
+ all_data = collection.get(include=["metadatas"])
382
+
383
+ if not all_data["metadatas"]:
384
+ output_parts.append(f"\n## {book_key}: Sin datos\n")
385
+ continue
386
+
387
+ chapters = {}
388
+ for meta in all_data["metadatas"]:
389
+ chapter = meta.get("chapter", "Unknown")
390
+ section = meta.get("section", "")
391
+ if chapter not in chapters:
392
+ chapters[chapter] = set()
393
+ if section:
394
+ chapters[chapter].add(section)
395
+
396
+ output_parts.append(f"\n## 📗 {book_key}\n")
397
+ for chapter in sorted(chapters.keys()):
398
+ sections = sorted(chapters[chapter])
399
+ output_parts.append(f"\n### {chapter}\n")
400
+ if sections:
401
+ for sec in sections[:8]:
402
+ output_parts.append(f" - {sec}\n")
403
+ if len(sections) > 8:
404
+ output_parts.append(f" - ... y {len(sections) - 8} secciones más\n")
405
+
406
+ total = collection.count()
407
+ output_parts.append(f"\n**Total chunks indexados:** {total}\n")
408
+
409
+ except Exception as e:
410
+ output_parts.append(f"\n## {book_key}: ❌ Error ({e})\n")
411
+
412
+ return "\n".join(output_parts)