Spaces:
Sleeping
Sleeping
File size: 7,536 Bytes
bf9f7d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """Qué partes de cada libro entran en el corpus RAG, y con qué especie, por rangos de página.
Dos decisiones, ambas declaradas en `data/rag_alcance.json`:
- **`descartes`** — páginas que no entran: el índice alfabético del final de cada libro. Son
listas de entradas con números («Acantocito, 186t, 188, 189f, 194»), sin ninguna frase
clínica. Recuperarlas gasta presupuesto de prompt en ruido. Los límites se fijaron midiendo
la densidad de entradas de índice por 1000 caracteres: salta de ~0 a 23–32 en una página
concreta de cada libro.
- **`rangos`** — páginas restringidas a una especie (abajo).
Por qué existe: el 2026-08-01 se midió el índice real y **los 6772 chunks tenían `especie`
vacía**. El filtro por especie de `retriever.py` conserva el fragmento cuando su metadato viene
vacío, así que no excluía nada nunca — estaba muerto sobre datos reales, aunque sus tests
pasaran contra un índice sintético que sí traía el metadato.
No es teórico. Morphos atiende canino y felino; el corpus es de patología clínica veterinaria
COMPARADA y el 5,7 % de los chunks habla de aves. Que aquel día no se colara ninguno en los 102
fragmentos recuperados fue mérito del reranker, no de una guarda. Y el generador ya había
demostrado el riesgo: en `gammapatia-canino` escribió «la literatura [1] menciona que la
exposición a antígenos puede aumentar las gammaglobulinas en aves».
`ingest.py` sólo sabía poner una especie por LIBRO (desde el sidecar `.meta.json`), que es la
granularidad equivocada para un texto comparado: la especie cambia por sección, no por tomo.
Qué se etiqueta y qué no, deliberadamente conservador: **sólo secciones que el propio libro
declara en su índice**. El material comparado que menciona caballo o vaca de pasada se deja
intacto: enseña el principio general y sirve igual para un perro. Un detector por palabras clave
marcaba en falso dos bloques que resultaron ser proteínas de fase aguda entre especies e
infecciones sistémicas — contenido perfectamente aplicable.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from ..config import RAIZ_REPO
log = logging.getLogger("morphos.rag")
RUTA_ALCANCE = RAIZ_REPO / "data" / "rag_alcance.json"
# El campo `pagina` guarda «305» o «305–306» (guion largo). Se usa la primera. Un fragmento que
# cruza el límite de una sección queda del lado de donde empieza: medido sobre el índice real,
# eso afecta a un puñado de fragmentos de portadilla, y equivocarse por el lado de NO etiquetar
# es el seguro (conserva material, no lo esconde).
_PRIMERA_PAGINA = re.compile(r"\d+")
def numero_de_pagina(pagina: str) -> int | None:
"""Primera página de un campo «305» o «305–306». None si no hay ningún dígito."""
m = _PRIMERA_PAGINA.search(str(pagina or ""))
return int(m.group(0)) if m else None
@dataclass(frozen=True)
class RangoAlcance:
libro_empieza_por: str
desde: int
hasta: int
especie: str
motivo: str = ""
def cubre(self, libro: str, pagina: int) -> bool:
return libro.startswith(self.libro_empieza_por) and self.desde <= pagina <= self.hasta
@dataclass(frozen=True)
class RangoDescarte:
libro_empieza_por: str
desde: int
hasta: int
motivo: str = ""
def cubre(self, libro: str, pagina: int) -> bool:
return libro.startswith(self.libro_empieza_por) and self.desde <= pagina <= self.hasta
@lru_cache
def cargar_descartes(ruta: Path | None = None) -> tuple[RangoDescarte, ...]:
"""Rangos que no entran en el corpus (índices alfabéticos del final de cada libro)."""
destino = ruta or RUTA_ALCANCE
if not destino.exists():
return ()
datos = json.loads(destino.read_text(encoding="utf-8"))
return tuple(
RangoDescarte(
libro_empieza_por=d["libro_empieza_por"],
desde=int(d["desde"]),
hasta=int(d["hasta"]),
motivo=d.get("motivo", ""),
)
for d in datos.get("descartes", [])
)
# Líder de puntos de un sumario: «Urine Samples . . . . . . . . . . 6». Cuatro puntos separados
# por espacios ya no aparecen en prosa clínica; una elipsis («…» o «...») no los tiene espaciados.
_LIDER_DE_PUNTOS = re.compile(r"(?:\.\s){4,}\.")
@lru_cache
def _umbral_lideres(ruta: Path | None = None) -> float:
destino = ruta or RUTA_ALCANCE
if not destino.exists():
return 1.1 # inalcanzable: sin config, no se descarta nada por contenido
datos = json.loads(destino.read_text(encoding="utf-8"))
return float(datos.get("umbral_lideres_de_puntos", 1.1))
def fraccion_lideres_de_puntos(texto: str) -> float:
"""Parte del fragmento ocupada por líderes de puntos. 0.0 en cualquier prosa."""
if not texto:
return 0.0
return sum(len(m.group(0)) for m in _LIDER_DE_PUNTOS.finditer(texto)) / len(texto)
def es_listado_de_contenidos(texto: str, ruta: Path | None = None) -> bool:
"""True si el fragmento es un sumario con líderes de puntos.
Va por firma tipográfica y no por rango de página porque Fundamentals repite un sumario al
principio de CADA capítulo: 23 bloques repartidos por todo el libro que, por rangos, serían
23 entradas a mano y otras tantas ocasiones de equivocarse.
"""
return fraccion_lideres_de_puntos(texto) >= _umbral_lideres(ruta)
def debe_descartarse(
libro: str, pagina: str, texto: str = "", ruta: Path | None = None
) -> bool:
"""True si el fragmento no debe entrar en el corpus, por rango de página o por contenido.
Sin número de página no se descarta por rango: ante la duda se conserva. Perder literatura
clínica es peor que colar una entrada de índice, que como mucho gasta presupuesto de prompt.
"""
if texto and es_listado_de_contenidos(texto, ruta):
return True
numero = numero_de_pagina(pagina)
if numero is None:
return False
return any(d.cubre(libro, numero) for d in cargar_descartes(ruta))
@lru_cache
def cargar_rangos(ruta: Path | None = None) -> tuple[RangoAlcance, ...]:
"""Lee `data/rag_alcance.json`. Si falta, no etiqueta nada y lo dice en el log.
Un fichero ausente degrada al comportamiento anterior (todo sin etiquetar), no a un fallo:
la ingesta no puede caerse porque falte un mapa de alcance.
"""
destino = ruta or RUTA_ALCANCE
if not destino.exists():
log.warning("Sin mapa de alcance del corpus (%s); no se etiquetará la especie.", destino)
return ()
datos = json.loads(destino.read_text(encoding="utf-8"))
return tuple(
RangoAlcance(
libro_empieza_por=r["libro_empieza_por"],
desde=int(r["desde"]),
hasta=int(r["hasta"]),
especie=r["especie"],
motivo=r.get("motivo", ""),
)
for r in datos.get("rangos", [])
)
def especie_de(libro: str, pagina: str, por_defecto: str = "", ruta: Path | None = None) -> str:
"""Especie del fragmento. `por_defecto` es lo que ya trajera el libro (sidecar `.meta.json`).
Un rango explícito gana al valor del libro: en un texto comparado la sección es más
específica que el tomo.
"""
numero = numero_de_pagina(pagina)
if numero is None:
return por_defecto
for rango in cargar_rangos(ruta):
if rango.cubre(libro, numero):
return rango.especie
return por_defecto
|