medgraph-engine/search.py

229 lines
6.8 KiB
Python

"""Búsqueda por keywords sobre los chunks parseados de los libros."""
import json
import os
import re
import sys
from collections import defaultdict
PARSED_DIR = os.path.join(os.path.dirname(__file__), "parsed")
CATALOG_PATH = os.path.join(os.path.dirname(__file__), "catalog.json")
# Cache de chunks cargados en memoria
_chunks_cache = {}
def load_chunks(libro_id: str = None) -> list:
"""Carga chunks de uno o todos los libros parseados."""
if libro_id and libro_id in _chunks_cache:
return _chunks_cache[libro_id]
catalog = load_catalog()
chunks = []
for libro in catalog["libros"]:
if libro["estado"] != "parseado":
continue
if libro_id and libro["id"] != libro_id:
continue
chunks_path = os.path.join(PARSED_DIR, f"{libro['id']}_chunks.json")
if not os.path.exists(chunks_path):
continue
with open(chunks_path, "r", encoding="utf-8") as f:
libro_chunks = json.load(f)
for chunk in libro_chunks:
chunk["libro_titulo"] = libro["titulo"]
chunks.extend(libro_chunks)
_chunks_cache[libro["id"]] = libro_chunks
return chunks
def load_catalog():
with open(CATALOG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def search(query: str, libro_id: str = None, top_k: int = 5, context: int = 0) -> list:
"""Búsqueda por keywords sobre chunks.
Scoring: cuenta ocurrencias de cada término de búsqueda en el chunk,
ponderado por si aparece en las primeras líneas (probable título/sección).
Args:
query: Texto a buscar
libro_id: Filtrar por libro específico (None = todos)
top_k: Cantidad de resultados
context: Líneas de contexto adicional a mostrar
Returns:
Lista de resultados ordenados por relevancia
"""
chunks = load_chunks(libro_id)
terms = normalize(query).split()
if not terms:
return []
results = []
for chunk in chunks:
text_normalized = normalize(chunk["text"])
text_lower = text_normalized.lower()
# Calcular score
score = 0
matched_terms = set()
for term in terms:
term_lower = term.lower()
# Contar ocurrencias
count = text_lower.count(term_lower)
if count > 0:
matched_terms.add(term)
score += count
# Bonus si aparece en las primeras 200 chars (probablemente título)
if term_lower in text_lower[:200]:
score += 3
# Solo incluir si matchea al menos la mitad de los términos
if len(matched_terms) >= max(1, len(terms) // 2):
# Bonus por matchear más términos distintos
score += len(matched_terms) * 2
results.append({
"chunk_id": chunk["id"],
"libro_id": chunk["libro_id"],
"libro_titulo": chunk.get("libro_titulo", chunk["libro_id"]),
"page_start": chunk["page_start"],
"page_end": chunk["page_end"],
"score": score,
"matched_terms": list(matched_terms),
"preview": get_preview(chunk["text"], terms),
"full_text": chunk["text"] if context > 0 else None,
"word_count": chunk["word_count"]
})
# Ordenar por score descendente
results.sort(key=lambda x: x["score"], reverse=True)
return results[:top_k]
def normalize(text: str) -> str:
"""Normaliza texto para búsqueda."""
# Remover acentos comunes
replacements = {
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ú': 'u',
'ü': 'u', 'ñ': 'n',
'Á': 'A', 'É': 'E', 'Í': 'I', 'Ó': 'O', 'Ú': 'U',
'Ü': 'U', 'Ñ': 'N'
}
for old, new in replacements.items():
text = text.replace(old, new)
return text
def safe_print(text: str):
"""Print safe para Windows cp1252."""
try:
print(text)
except UnicodeEncodeError:
print(text.encode("ascii", errors="replace").decode("ascii"))
def get_preview(text: str, terms: list, max_len: int = 300) -> str:
"""Extrae un preview del chunk centrado en los términos encontrados."""
text_lower = normalize(text).lower()
terms_lower = [normalize(t).lower() for t in terms]
# Buscar la primera ocurrencia de cualquier término
best_pos = len(text)
for term in terms_lower:
pos = text_lower.find(term)
if pos != -1 and pos < best_pos:
best_pos = pos
# Extraer ventana alrededor de la primera ocurrencia
start = max(0, best_pos - 80)
end = min(len(text), start + max_len)
preview = text[start:end].strip()
if start > 0:
preview = "..." + preview
if end < len(text):
preview = preview + "..."
return preview
def print_results(results: list, verbose: bool = False):
"""Imprime resultados de búsqueda."""
if not results:
print("\n Sin resultados.")
return
print(f"\n {len(results)} resultado(s):\n")
for i, r in enumerate(results):
safe_print(f" [{i+1}] {r['libro_titulo']} - pags {r['page_start']}-{r['page_end']} (score: {r['score']})")
safe_print(f" Terminos: {', '.join(r['matched_terms'])}")
safe_print(f" {r['preview']}")
if verbose and r.get("full_text"):
safe_print(f"\n --- TEXTO COMPLETO ---")
for line in r["full_text"].split("\n"):
safe_print(f" {line}")
safe_print(f" --- FIN ---")
print()
if __name__ == "__main__":
args = sys.argv[1:]
if not args:
print("\nUso:")
print(' python search.py "otitis media tratamiento"')
print(' python search.py "ECG normal" --libro farreras-2020')
print(' python search.py "otalgia" --top 10')
print(' python search.py "ergometria indicaciones" --verbose')
sys.exit(0)
# Parsear argumentos
query_parts = []
libro_id = None
top_k = 5
verbose = False
i = 0
while i < len(args):
if args[i] == "--libro" and i + 1 < len(args):
libro_id = args[i + 1]
i += 2
elif args[i] == "--top" and i + 1 < len(args):
top_k = int(args[i + 1])
i += 2
elif args[i] == "--verbose":
verbose = True
i += 1
else:
query_parts.append(args[i])
i += 1
query = " ".join(query_parts)
if not query:
print("Error: query vacia")
sys.exit(1)
print(f"\n Buscando: \"{query}\"", end="")
if libro_id:
print(f" (en {libro_id})", end="")
print()
results = search(query, libro_id=libro_id, top_k=top_k, context=1 if verbose else 0)
print_results(results, verbose=verbose)