File size: 5,511 Bytes
1e215c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Atribución verificable, común a las tres rutas de modelo.

El problema que resuelve: sólo Ollama y Claude devuelven salida estructurada, así que sólo
ellas pueden rellenar `citas[]` por diferencial. La ruta por defecto en producción es el HF
Space, que devuelve PROSA — y con ella el usuario recibía texto fundamentado en la
literatura recuperada sin ninguna forma de saber en qué se apoyaba. Grounding sin
atribución.

La solución es no dejar las fuentes en manos del modelo:

- Las `Fuente` se construyen desde los fragmentos que la recuperación entregó realmente,
  numerados igual que en el prompt. El modelo no puede inventarlas porque no las escribe.
- En prosa, el modelo sólo aporta el MARCADOR `[n]`. Un marcador fuera de rango se borra
  del texto (apuntaría a una fuente inexistente).
- En las rutas estructuradas, cada cita del modelo se resuelve contra un fragmento real;
  la que no se puede resolver se descarta, porque una cita no verificable es peor que
  ninguna: parece respaldo y no lo es.
"""

from __future__ import annotations

import logging
import re
import unicodedata

from ..rag.retriever import Fragmento
from ..schemas import Fuente, InterpretacionClinica

log = logging.getLogger("morphos.citas")

_MARCADOR = re.compile(r"\[(\d{1,2})\]")
# Palabras demasiado genéricas para identificar un libro ("manual de medicina interna…").
_VACIAS = {"de", "del", "la", "el", "los", "las", "and", "of", "the", "en", "y"}


def construir_fuentes(fragmentos: list[Fragmento]) -> list[Fuente]:
    """Numera los fragmentos recuperados igual que `prompt._bloque_contexto_rag` (base 1)."""
    return [
        Fuente(
            indice=i,
            libro=f.libro,
            edicion=f.edicion,
            capitulo=f.capitulo,
            pagina=f.pagina,
            cita=f.cita(),
        )
        for i, f in enumerate(fragmentos, 1)
    ]


def _normalizar(texto: str) -> str:
    sin_tildes = "".join(
        c for c in unicodedata.normalize("NFD", texto.lower()) if unicodedata.category(c) != "Mn"
    )
    return re.sub(r"[^a-z0-9]+", " ", sin_tildes).strip()


def _tokens_libro(libro: str) -> list[str]:
    return [t for t in _normalizar(libro).split() if len(t) > 2 and t not in _VACIAS]


def resolver_cita(cita: str, fuentes: list[Fuente]) -> int | None:
    """Índice (base 1) de la fuente a la que se refiere una cita libre, o None.

    Acepta el marcador `[n]` y también el título del libro escrito por el modelo, que es lo
    que suele emitir aunque el prompt pida el número.
    """
    texto = cita.strip()
    marcador = _MARCADOR.search(texto)
    if marcador:
        indice = int(marcador.group(1))
        return indice if 1 <= indice <= len(fuentes) else None

    normalizada = _normalizar(texto)
    if not normalizada:
        return None
    mejor_indice, mejor_cobertura = None, 0.0
    for fuente in fuentes:
        tokens = _tokens_libro(fuente.libro)
        if not tokens:
            continue
        cobertura = sum(t in normalizada for t in tokens) / len(tokens)
        if cobertura > mejor_cobertura:
            mejor_indice, mejor_cobertura = fuente.indice, cobertura
    # 0.6 del título presente: tolera "Ettinger, Textbook of Vet. Internal Medicine, p. 812"
    # pero rechaza una cita a un libro que nunca se recuperó.
    return mejor_indice if mejor_cobertura >= 0.6 else None


def indices_citados(texto: str, n_fuentes: int) -> set[int]:
    """Marcadores `[n]` válidos presentes en la prosa."""
    return {i for m in _MARCADOR.findall(texto) if 1 <= (i := int(m)) <= n_fuentes}


def limpiar_marcadores_invalidos(texto: str, n_fuentes: int) -> str:
    """Borra los `[n]` que apuntan fuera del conjunto recuperado (incluye el caso n_fuentes=0,
    donde el modelo cita literatura que nunca se le dio)."""

    def _sustituir(m: re.Match[str]) -> str:
        return m.group(0) if 1 <= int(m.group(1)) <= n_fuentes else ""

    return re.sub(r"[ \t]+([.,;:])", r"\1", _MARCADOR.sub(_sustituir, texto)).strip()


def aplicar_atribucion(
    resultado: InterpretacionClinica, fragmentos: list[Fragmento]
) -> tuple[InterpretacionClinica, list[Fuente]]:
    """Devuelve (interpretación con citas verificadas, fuentes marcadas como citadas o no).

    No lanza nunca: la atribución es una mejora de la respuesta, no un requisito para
    entregarla.
    """
    fuentes = construir_fuentes(fragmentos)
    citados: set[int] = set()
    descartadas = 0

    # 1) Prosa: marcadores [n] del texto libre (única señal de la ruta HF Space).
    texto = limpiar_marcadores_invalidos(resultado.interpretacion, len(fuentes))
    citados |= indices_citados(texto, len(fuentes))
    resultado.interpretacion = texto

    # 2) Rutas estructuradas: cada cita se resuelve contra una fuente real o se descarta.
    for diferencial in resultado.diferenciales:
        verificadas: list[str] = []
        for cita in diferencial.citas:
            indice = resolver_cita(cita, fuentes)
            if indice is None:
                descartadas += 1
                continue
            citados.add(indice)
            texto_cita = fuentes[indice - 1].cita
            if texto_cita not in verificadas:
                verificadas.append(texto_cita)
        diferencial.citas = verificadas

    for fuente in fuentes:
        fuente.citada = fuente.indice in citados

    if descartadas:
        log.info("Descartadas %d cita(s) no verificables contra la literatura recuperada.", descartadas)
    return resultado, fuentes