"""Regresión del troceo estructural (Tier 1 RAG). Valida las invariantes del troceo sin depender del grupo pesado `rag`: las funciones de troceo no importan pymupdf4llm/sentence-transformers a nivel de módulo, así que corren en el entorno `dev` por defecto con el contador de tokens heurístico. """ from __future__ import annotations from app.rag.ingest import ( _MIN_TOKENS_FRAGMENTO, CHUNK_TOKENS, SOLAPE_TOKENS, _cargar_contador_tokens, _ensamblar_documento, _extraer_parrafos, _limpiar_titulo, _texto_contextualizado, _titulo_valido, _trocear_estructural, ) contar = _cargar_contador_tokens("BAAI/bge-m3") # cae a heurística por palabras def _chunks_desde_paginas(paginas): doc = _ensamblar_documento(paginas) return _trocear_estructural(_extraer_parrafos(doc), contar) def test_fragmentos_cruzan_saltos_de_pagina(): paginas = [ (10, "# Anemia Regenerativa\n\nLa anemia regenerativa cursa con reticulocitosis."), (11, "La respuesta medular continúa describiéndose aquí sin cambio de tema."), ] chunks = _chunks_desde_paginas(paginas) assert any(c.pmin < c.pmax for c in chunks), "ningún fragmento cruza el salto de página" def test_capitulo_se_puebla_y_sin_markup(): paginas = [(5, "# **Nonregenerative Anemia**\n\nAusencia de respuesta reticulocitaria clara.")] chunks = _chunks_desde_paginas(paginas) assert chunks and chunks[0].capitulo == "Nonregenerative Anemia" assert all("*" not in c.capitulo for c in chunks) def test_se_filtra_ruido_de_marca_de_agua_y_numeros_sueltos(): paginas = [(7, "vetbooks.ir\n7\n\nContenido clínico real sobre eritrocitos y anemia.")] chunks = _chunks_desde_paginas(paginas) assert chunks assert all("vetbooks" not in c.texto.lower() for c in chunks) assert all(c.texto.strip() != "7" for c in chunks) def test_tamano_acotado_por_tokens(): grande = "oración clínica de prueba. " * 400 chunks = _chunks_desde_paginas([(1, f"# Capítulo\n\n{grande}")]) assert len(chunks) > 1 for c in chunks: assert contar(c.texto) <= CHUNK_TOKENS + SOLAPE_TOKENS + 5 def test_marcadores_de_pagina_no_se_almacenan(): chunks = _chunks_desde_paginas([(3, "Texto normal de una página cualquiera.")]) assert all("〔p" not in c.texto for c in chunks) def test_no_fragmentos_triviales_cuando_hay_continuacion(): # Un encabezado seguido de cuerpo del mismo capítulo debe fusionarse, no quedar suelto. cuerpo = ("Descripción amplia del hallazgo clínico con longitud más que suficiente para " "superar con holgura el umbral mínimo de tokens exigido a un fragmento del índice.") chunks = _chunks_desde_paginas([(2, f"# Hallazgos\n\n{cuerpo}")]) assert len(chunks) == 1 assert chunks[0].capitulo == "Hallazgos" assert chunks[0].texto.startswith("Hallazgos ") # el encabezado se fusionó con el cuerpo assert contar(chunks[0].texto) >= _MIN_TOKENS_FRAGMENTO def test_texto_contextualizado_antepone_contexto(): assert _texto_contextualizado("Contexto clínico.", "Cuerpo.") == "Contexto clínico.\n\nCuerpo." # Sin contexto (fallo al generar) → texto original intacto. assert _texto_contextualizado("", "Cuerpo.") == "Cuerpo." def test_titulo_valido_rechaza_basura_ocr(): # Cadenas reales observadas en el volcado de recuperación. for basura in ["va — yy ~~e~~", "ge", "° ~~g~~ e ~~E~~ s", "nRBC 100 WBC", "yy", "e"]: assert _titulo_valido(_limpiar_titulo(basura)) is False, basura def test_titulo_valido_acepta_capitulos_reales(): for bueno in ["Nonregenerative Anemia", "9 Regenerative Anemia", "ERYTHROCYTES", "Sodium to Potassium Ratio", "Hallazgos de Laboratorio"]: assert _titulo_valido(_limpiar_titulo(bueno)) is True, bueno def test_encabezado_basura_no_contamina_capitulo(): # Un encabezado basura entre capítulos válidos no debe sobrescribir el capítulo vigente. paginas = [(5, "# Anemia Regenerativa\n\nCuerpo clínico del capítulo válido y suficiente.\n\n" "# nRBC 100 WBC\n\nMás cuerpo clínico que sigue tras la cabecera basura.")] chunks = _chunks_desde_paginas(paginas) assert all(c.capitulo == "Anemia Regenerativa" for c in chunks), [c.capitulo for c in chunks]