"""Parser v2: PDF → chunks semánticos con estructura, overlap y parent-child. Módulo importable. Funciones principales: - parse_libro_v2(libro_id, pdf_path) → (children, parents) - detect_structure(pages, libro_id) → structured_pages - generate_chunks_v2(structured_pages, libro_id) → (children, parents) CLI: python parser_v2.py [--all] [--status] """ import fitz # PyMuPDF import json import os import re import unicodedata from datetime import datetime LIBROS_DIR = os.path.join(os.path.dirname(__file__), "..", "LIBROS") OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "parsed") CATALOG_PATH = os.path.join(os.path.dirname(__file__), "catalog.json") # --- Configuración de chunking --- TARGET_SIZE = 280 # palabras objetivo por child chunk MIN_SIZE = 150 # mínimo aceptable MAX_SIZE = 380 # máximo antes de forzar corte OVERLAP_SIZE = 60 # palabras de overlap entre chunks PARENT_WINDOW = 3 # cantidad de children por parent chunk MAX_PARENT_WORDS = 1200 # máximo de palabras por parent # --- Patrones de estructura por libro --- STRUCTURE_PATTERNS = { "farreras-2020": { "capitulo": [ r'^SECCIÓN\s+[IVXLCDM]+\b', r'^Capítulo\s+\d+', r'^CAPÍTULO\s+\d+', ], "seccion": [ r'^\d+\.\d+[\s\.]+[A-ZÁÉÍÓÚÑ]', # 23.4 Glaucoma... r'^[A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{5,60}$', # MODELOS DE REGRESIÓN (línea sola en mayúsculas) ], }, "garcia-feijoo-2012": { "capitulo": [ r'PA\s*R\s*T\s*E\s+\d+', # PA R T E 1 : BÁSICO r'^PARTE\s+\d+', ], "seccion": [ r'^\d+\s*\|\s*.+', # 1 | Embriología. Desarrollo... r'^\d+\.\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]{3,}', # 1. Embriología... (requiere palabra real, no "3. Mixto.") ], }, "diamante-orl": { "capitulo": [ r'^SECCIÓN\s+[IVXLCDM]+', r'^Sección\s+[IVXLCDM]+', r'^CAPÍTULO\s+\d+', ], "seccion": [ r'^\d+\.\s+[A-ZÁÉÍÓÚÑ]', r'^[A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{5,50}$', ], }, "_default": { "capitulo": [ r'^SECCIÓN\s+[IVXLCDM]+', r'^CAPÍTULO\s+\d+', r'^Capítulo\s+\d+', r'^PARTE\s+\d+', ], "seccion": [ r'^\d+\.\d+[\s\.]+[A-ZÁÉÍÓÚÑ]', r'^[A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{5,60}$', ], }, } def load_catalog(): with open(CATALOG_PATH, "r", encoding="utf-8") as f: return json.load(f) def save_catalog(catalog): with open(CATALOG_PATH, "w", encoding="utf-8") as f: json.dump(catalog, f, ensure_ascii=False, indent=2) def normalize_for_search(text: str) -> str: """Quita acentos y pasa a minúsculas para full-text search.""" nfkd = unicodedata.normalize('NFKD', text) return ''.join(c for c in nfkd if not unicodedata.combining(c)).lower() def clean_text(text: str) -> str: """Limpia texto extraído de PDF.""" text = re.sub(r'\n{3,}', '\n\n', text) text = re.sub(r'^\s*\d{1,4}\s*$', '', text, flags=re.MULTILINE) text = re.sub(r'(?i)^.*medicina interna.*edici[oó]n.*$', '', text, flags=re.MULTILINE) text = re.sub(r'(?i)^.*booksmedicos\.org.*$', '', text, flags=re.MULTILINE) text = re.sub(r'(?i)^.*© Elsevier\. Fotocopiar sin autorización es un delito\..*$', '', text, flags=re.MULTILINE) text = re.sub(r'(?i)^.*© \d{4}.*Elsevier.*$', '', text, flags=re.MULTILINE) text = re.sub(r'[ \t]+', ' ', text) text = '\n'.join(line.strip() for line in text.split('\n')) return text.strip() def classify_content_type(text: str) -> str: """Clasifica el tipo de contenido de un chunk.""" lines = text.strip().split('\n') if not lines: return "body" # Tablas: muchas líneas con | o tabulaciones tab_lines = sum(1 for l in lines if '|' in l or '\t' in l) if tab_lines > len(lines) * 0.3 and tab_lines >= 3: return "tabla" # Listas: muchas líneas empezando con -, *, •, números list_lines = sum(1 for l in lines if re.match(r'^\s*[-*•●■]\s', l) or re.match(r'^\s*\d+[\.\)]\s', l)) if list_lines > len(lines) * 0.3 and list_lines >= 3: return "lista" # Definiciones: empieza con patrones típicos first_100 = text[:200].lower() if any(p in first_100 for p in ['concepto', 'definición', 'se define como', 'se denomina', 'es la']): return "definicion" return "body" def _clean_spaced_text(text: str) -> str: """Reconstruye texto con letras espaciadas del PDF. "PA R T E 3 : A M E T R O P Í A S" → "PARTE 3 : AMETROPÍAS" "B Á S I C O" → "BÁSICO" """ # Buscar secuencias donde hay letras sueltas separadas por un espacio # Patrón: al menos 2 pares de "letra espacio" seguidos de una letra final letter = r'[A-ZÁÉÍÓÚÑa-záéíóúñ]' pattern = rf'({letter} ){{2,}}{letter}' def collapse_spaced(match): return match.group(0).replace(' ', '') result = re.sub(pattern, collapse_spaced, text) return result def detect_structure(pages: list, libro_id: str) -> list: """Detecta títulos de capítulo y sección en las páginas. Args: pages: Lista de {page, text} libro_id: ID del libro para seleccionar patrones Returns: Lista de {page, text, titulo_capitulo, titulo_seccion} """ patterns = STRUCTURE_PATTERNS.get(libro_id, STRUCTURE_PATTERNS["_default"]) cap_patterns = [re.compile(p, re.MULTILINE) for p in patterns["capitulo"]] sec_patterns = [re.compile(p, re.MULTILINE) for p in patterns["seccion"]] current_capitulo = "" current_seccion = "" structured = [] for page_data in pages: text = page_data["text"] lines = text.split('\n') for line in lines: line_stripped = line.strip() if not line_stripped or len(line_stripped) < 3: continue # Detectar capítulo for pat in cap_patterns: if pat.match(line_stripped): # Limpiar: reconstruir texto con espacios intercalados # "PA R T E 3 : A M E T R O P Í A S" → "PARTE 3: AMETROPÍAS" clean_cap = _clean_spaced_text(line_stripped) clean_cap = re.sub(r'\s{2,}', ' ', clean_cap).strip() current_capitulo = clean_cap[:120] current_seccion = "" break # Detectar sección (solo si no fue detectado como capítulo) is_capitulo = any(pat.match(line_stripped) for pat in cap_patterns) if not is_capitulo: for pat in sec_patterns: if pat.match(line_stripped): # Solo aceptar como sección si es línea corta (título, no contenido) # y no contiene punto seguido de más texto (indica oración, no título) if len(line_stripped) < 80 and line_stripped.count('.') <= 2: current_seccion = line_stripped[:100] break structured.append({ "page": page_data["page"], "text": text, "titulo_capitulo": current_capitulo, "titulo_seccion": current_seccion, }) return structured def _find_sentence_boundary(words: list, target_idx: int) -> int: """Busca el final de oración más cercano al target_idx. Retorna el índice del último word que termina una oración, buscando en un rango de ±30 palabras del target. """ search_start = max(0, target_idx - 30) search_end = min(len(words), target_idx + 30) best = target_idx best_dist = 999 for i in range(search_start, search_end): word = words[i] if word.endswith(('.', '?', '!', ':')) and not re.match(r'^\d+\.$', word): dist = abs(i - target_idx) if dist < best_dist: best = i best_dist = dist return best + 1 # Retorna posición después del punto def generate_chunks_v2(structured_pages: list, libro_id: str, target_size: int = TARGET_SIZE, overlap: int = OVERLAP_SIZE) -> tuple: """Genera child chunks y parent chunks a partir de páginas estructuradas. Args: structured_pages: Lista de {page, text, titulo_capitulo, titulo_seccion} libro_id: ID del libro target_size: Palabras objetivo por chunk (default 280) overlap: Palabras de overlap (default 60) Returns: (child_chunks, parent_chunks) """ # Construir un buffer continuo con metadata por página all_words = [] # Lista plana de palabras word_meta = [] # Metadata por palabra: (page, capitulo, seccion) for sp in structured_pages: page = sp["page"] cap = sp["titulo_capitulo"] sec = sp["titulo_seccion"] words = sp["text"].split() for w in words: all_words.append(w) word_meta.append((page, cap, sec)) if not all_words: return [], [] # Generar child chunks con overlap children = [] pos = 0 chunk_index = 0 while pos < len(all_words): # Determinar fin del chunk end_target = pos + target_size if end_target >= len(all_words): # Último chunk: tomar todo lo que queda end = len(all_words) else: # Buscar límite de oración cerca del target end = _find_sentence_boundary(all_words, end_target) # Si el chunk es demasiado grande, forzar corte if end - pos > MAX_SIZE: end = _find_sentence_boundary(all_words, pos + MAX_SIZE) if end - pos > MAX_SIZE + 50: end = pos + MAX_SIZE # Corte duro como último recurso # Chunk demasiado pequeño al final: merge con anterior if end - pos < MIN_SIZE and children: prev = children[-1] prev["text"] = prev["text"] + " " + " ".join(all_words[pos:end]) prev["word_count"] = len(prev["text"].split()) prev["page_end"] = word_meta[end - 1][0] break chunk_text = " ".join(all_words[pos:end]) page_start = word_meta[pos][0] page_end = word_meta[end - 1][0] # Metadata: tomar la del inicio del chunk (más representativa) capitulo = word_meta[pos][1] seccion = word_meta[pos][2] # Si hay cambio de capítulo/sección dentro del chunk, usar el más nuevo for i in range(pos, min(end, pos + 50)): if word_meta[i][1] and word_meta[i][1] != capitulo: capitulo = word_meta[i][1] if word_meta[i][2] and word_meta[i][2] != seccion: seccion = word_meta[i][2] tipo = classify_content_type(chunk_text) children.append({ "id": f"{libro_id}_v2_{chunk_index:05d}", "libro_id": libro_id, "page_start": page_start, "page_end": page_end, "text": chunk_text, "word_count": end - pos, "titulo_capitulo": capitulo, "titulo_seccion": seccion, "tipo_contenido": tipo, "parent_id": None, # Se asigna después "chunk_index": chunk_index, "version": 2, }) chunk_index += 1 # Avanzar con overlap next_pos = end - overlap if next_pos <= pos: next_pos = end # Evitar loop infinito pos = next_pos # Generar parent chunks (ventana de PARENT_WINDOW children) parents = [] parent_idx = 0 i = 0 while i < len(children): window_end = min(i + PARENT_WINDOW, len(children)) window = children[i:window_end] # Concatenar textos de los children (sin overlap duplicado) parent_text_parts = [] for j, child in enumerate(window): if j == 0: parent_text_parts.append(child["text"]) else: # Quitar overlap del inicio de este child (ya está en el anterior) child_words = child["text"].split() # El overlap son las últimas ~OVERLAP_SIZE palabras del child anterior skip = min(overlap, len(child_words) // 3) # No skipear más de 1/3 parent_text_parts.append(" ".join(child_words[skip:])) parent_text = " ".join(parent_text_parts) parent_words = len(parent_text.split()) # Si el parent es muy grande, solo tomar lo necesario if parent_words > MAX_PARENT_WORDS: parent_text = " ".join(parent_text.split()[:MAX_PARENT_WORDS]) parent_words = MAX_PARENT_WORDS parent_id = f"{libro_id}_v2_parent_{parent_idx:05d}" parents.append({ "id": parent_id, "libro_id": libro_id, "page_start": window[0]["page_start"], "page_end": window[-1]["page_end"], "text": parent_text, "word_count": parent_words, "titulo_capitulo": window[0]["titulo_capitulo"], "titulo_seccion": window[0]["titulo_seccion"], "child_ids": [c["id"] for c in window], "version": 2, }) # Asignar parent_id a los children for child in window: child["parent_id"] = parent_id parent_idx += 1 i += PARENT_WINDOW return children, parents def parse_pdf_v2(pdf_path: str, libro_id: str) -> tuple: """Parsea un PDF completo a chunks v2. Args: pdf_path: Ruta al archivo PDF libro_id: ID del libro Returns: (children, parents) — listas de dicts """ doc = fitz.open(pdf_path) total_pages = doc.page_count print(f" Parseando {os.path.basename(pdf_path)} ({total_pages} págs)...") # Extraer texto por página pages = [] for i in range(total_pages): page = doc[i] text = page.get_text() text = clean_text(text) if len(text.strip()) > 20: pages.append({"page": i + 1, "text": text}) if (i + 1) % 500 == 0: print(f" ... {i + 1}/{total_pages} páginas") doc.close() print(f" {len(pages)} páginas con texto extraído") # Detectar estructura structured = detect_structure(pages, libro_id) # Generar chunks children, parents = generate_chunks_v2(structured, libro_id) print(f" Resultado: {len(children)} children, {len(parents)} parents") return children, parents def parse_libro_v2(libro_id: str, pdf_path: str = None, on_progress: callable = None) -> tuple: """Parsea un libro por ID o ruta directa. Función principal importable. Args: libro_id: ID del libro en el catálogo pdf_path: Ruta al PDF (opcional, se busca en catálogo si no se da) on_progress: Callback(step, pct, msg) para reportar progreso Returns: (children, parents) — listas de dicts """ def report(pct, msg): if on_progress: on_progress("parse", pct, msg) print(f" [{pct}%] {msg}") # Resolver ruta del PDF if not pdf_path: catalog = load_catalog() libro = next((l for l in catalog["libros"] if l["id"] == libro_id), None) if not libro: raise ValueError(f"Libro '{libro_id}' no encontrado en catálogo") pdf_path = os.path.join(LIBROS_DIR, libro["archivo"]) if not os.path.exists(pdf_path): raise FileNotFoundError(f"PDF no encontrado: {pdf_path}") report(0, f"Iniciando parseo de {libro_id}") # Parsear children, parents = parse_pdf_v2(pdf_path, libro_id) # Guardar JSONs os.makedirs(OUTPUT_DIR, exist_ok=True) children_path = os.path.join(OUTPUT_DIR, f"{libro_id}_v2_chunks.json") with open(children_path, "w", encoding="utf-8") as f: json.dump(children, f, ensure_ascii=False, indent=2) parents_path = os.path.join(OUTPUT_DIR, f"{libro_id}_v2_parents.json") with open(parents_path, "w", encoding="utf-8") as f: json.dump(parents, f, ensure_ascii=False, indent=2) report(100, f"Guardado: {len(children)} children -> {children_path}") # Actualizar catálogo catalog = load_catalog() libro_entry = next((l for l in catalog["libros"] if l["id"] == libro_id), None) if libro_entry: libro_entry["chunks_v2"] = len(children) libro_entry["parents_v2"] = len(parents) libro_entry["fecha_parseo_v2"] = datetime.now().isoformat() save_catalog(catalog) return children, parents # --- CLI --- if __name__ == "__main__": import sys if len(sys.argv) < 2: print("\nparser_v2 — Chunking semántico con estructura, overlap y parent-child") print("\nUso:") print(" python parser_v2.py status - Ver estado") print(" python parser_v2.py - Parsear un libro") print(" python parser_v2.py all - Parsear todos los digitales") print(" python parser_v2.py --preview - Preview: 10 chunks sin guardar") catalog = load_catalog() print("\nLibros disponibles:") for lib in catalog["libros"]: v2 = f" (v2: {lib.get('chunks_v2', 0)} chunks)" if lib.get('chunks_v2') else "" print(f" {lib['id']}: {lib['titulo']} ({lib['estado']}){v2}") sys.exit(0) cmd = sys.argv[1] if cmd == "status": catalog = load_catalog() print(f"\n{'='*60}") print(f" ESTADO DE PARSEO V2") print(f"{'='*60}") for lib in catalog["libros"]: v2_chunks = lib.get("chunks_v2", 0) v2_parents = lib.get("parents_v2", 0) if v2_chunks: icon = "[v2]" info = f" → {v2_chunks} children, {v2_parents} parents" elif lib["estado"] == "parseado": icon = "[v1]" info = f" → {lib.get('chunks_generados', 0)} chunks v1" else: icon = "[ ]" info = "" print(f" {icon} {lib['titulo']} ({lib['paginas']} págs, {lib['tipo_pdf']}){info}") elif cmd == "all": catalog = load_catalog() for lib in catalog["libros"]: if lib["tipo_pdf"] == "digital" and lib["estado"] == "parseado": print(f"\n{'='*60}") print(f" {lib['titulo']}") print(f"{'='*60}") parse_libro_v2(lib["id"]) elif lib["tipo_pdf"] == "escaneado": print(f"\n SKIP: {lib['titulo']} (escaneado)") elif "--preview" in sys.argv: libro_id = cmd catalog = load_catalog() libro = next((l for l in catalog["libros"] if l["id"] == libro_id), None) if not libro: print(f"Libro '{libro_id}' no encontrado") sys.exit(1) pdf_path = os.path.join(LIBROS_DIR, libro["archivo"]) children, parents = parse_pdf_v2(pdf_path, libro_id) print(f"\n{'='*60}") print(f" PREVIEW: {len(children)} children, {len(parents)} parents") print(f"{'='*60}") # Mostrar stats sizes = [c["word_count"] for c in children] print(f" Tamaño promedio: {sum(sizes)/len(sizes):.0f} palabras") print(f" Min: {min(sizes)}, Max: {max(sizes)}") caps = set(c["titulo_capitulo"] for c in children if c["titulo_capitulo"]) secs = set(c["titulo_seccion"] for c in children if c["titulo_seccion"]) print(f" Capítulos detectados: {len(caps)}") print(f" Secciones detectadas: {len(secs)}") # Mostrar 10 chunks del medio (no prólogos) start = len(children) // 3 for c in children[start:start+10]: print(f"\n--- [{c['id']}] págs {c['page_start']}-{c['page_end']} ({c['word_count']} words) ---") print(f" Cap: {c['titulo_capitulo'][:60] if c['titulo_capitulo'] else '(no detectado)'}") print(f" Sec: {c['titulo_seccion'][:60] if c['titulo_seccion'] else '(no detectado)'}") print(f" Tipo: {c['tipo_contenido']}") print(f" Parent: {c['parent_id']}") preview_text = c['text'][:200].encode('ascii', 'replace').decode('ascii') print(f" Texto: {preview_text}...") else: parse_libro_v2(cmd)