morphos / backend /app /ai /prosa.py
Jose Salazar
Allow list para modelos locales configurables por el usuario
6ab7946
Raw
History Blame Contribute Delete
6.86 kB
"""Higiene de la salida de los modelos que devuelven TEXTO libre.
Vivía dentro de `hf_space.py` cuando el Space era la única ruta de prosa. Ahora también la
usa `medgemma.py` cuando el modelo local elegido está declarado como `prosa` en la lista
blanca: no todo modelo que corre en Ollama sabe respetar una decodificación restringida
—medido con qwen2.5:7b, que devolvía JSON válido con los tres campos estructurados
vacíos—, y para ésos es mejor pedir prosa y envolverla que aceptar un objeto hueco.
Los marcadores de `<unused95>` y `<start_of_turn>` son de la familia Gemma, pero el resto
(razonamiento filtrado, bucle de repetición, frase cortada) son modos de fallo genéricos de
cualquier modelo pequeño, así que se aplican a las dos rutas por igual.
"""
from __future__ import annotations
import re
from ..schemas import InterpretacionClinica
from .base import ErrorModelo
# medGemma es un modelo con "pensamiento": de forma intermitente emite una cadena de
# razonamiento en inglés (etiquetada `thought` / "thinking process") en lugar de responder,
# y a veces degenera en un bucle de repetición que agota el presupuesto de tokens sin llegar
# a la respuesta. Estos marcadores permiten detectar y descartar esa salida defectuosa.
_MARCADOR_PENSAMIENTO = re.compile(
r"(?im)^\s*(thought|thinking)\s*:?\s*$"
r"|here'?s\s+(a|my)\s+thinking\s+process"
r"|thinking\s+process\s+to\s+arrive"
r"|proceso\s+de\s+(pensamiento|razonamiento)"
)
def limpiar_respuesta(text: str) -> str:
"""Versión compacta de la limpieza que antes vivía en ia.js (limpiarRespuesta).
Sólo se aplica a las rutas de PROSA (texto crudo del modelo); las rutas con salida
estructurada validan contra el esquema y no la necesitan.
"""
if "<start_of_turn>model" in text:
text = text.split("<start_of_turn>model")[-1]
if "<end_of_turn>" in text:
text = text[: text.index("<end_of_turn>")]
if "<unused95>" in text:
text = text.split("<unused95>")[-1]
elif "<unused94>" in text:
text = "".join(text.split("<unused94>")[1:]).strip()
text = re.sub(r"<unused\d+>", "", text)
text = re.sub(r"<start_of_turn>\w+\n?", "", text)
text = re.sub(r"^\d+\s+(medical assistant|assistant|model)\s*", "", text, flags=re.I)
# LaTeX y bloques matemáticos
text = re.sub(r"\$\\boxed\{[^}]*\}\$", "", text)
text = re.sub(r"\\[a-zA-Z]+(\{[^}]*\})?", "", text)
text = re.sub(r"\$[^$]*\$", "", text)
text = re.sub(r"\n{3,}", "\n\n", text).strip()
text = _cortar_bucle_lineas(text)
# Corta al primer párrafo repetido (bucle del modelo)
vistos: set[str] = set()
sin_rep = []
for p in re.split(r"\n\n+", text):
clave = p.strip()[:80]
if clave in vistos:
break
vistos.add(clave)
sin_rep.append(p)
return "\n\n".join(sin_rep).strip() or "Sin respuesta del modelo."
def _cortar_bucle_lineas(text: str) -> str:
"""Trunca en cuanto una línea sustantiva se repite por 3.ª vez (bucle a nivel de viñeta,
que la deduplicación por párrafos `\\n\\n` no detecta)."""
conteo: dict[str, int] = {}
salida: list[str] = []
for linea in text.split("\n"):
clave = linea.strip()
if len(clave) > 15:
conteo[clave] = conteo.get(clave, 0) + 1
if conteo[clave] >= 3:
break
salida.append(linea)
return "\n".join(salida)
# Caracteres que pueden ir DESPUÉS del punto final sin que la frase esté cortada: énfasis
# markdown, comillas y cierres de paréntesis.
_ADORNOS_FINALES = "*_`\"'”»)]}"
_FIN_DE_FRASE = ".!?…:"
# Una línea final corta puede ser un ítem de lista o un encabezado legítimos sin puntuación
# ("- Ecografía abdominal"). Sólo una línea larga sin cierre delata una frase interrumpida.
_LARGO_MINIMO_TRUNCADO = 40
def interpretacion_truncada(text: str) -> bool:
"""True si la respuesta se corta a mitad de frase.
El Space puede devolver una interpretación cortada en seco (visto en producción:
«…los diferenciales incluyen la lip», justo antes de nombrar el diferencial clave). La
limpieza no la toca —el corte viene de arriba— y el resto de comprobaciones no la ven:
el texto es largo, no repite y no filtra razonamiento. Sin esto, una interpretación
incompleta llega al veterinario con aspecto de completa, callando justo lo que importa.
"""
limpio = text.rstrip()
if not limpio:
return True
ultima_linea = limpio.split("\n")[-1].strip()
if len(ultima_linea) < _LARGO_MINIMO_TRUNCADO:
return False
return limpio.rstrip(_ADORNOS_FINALES)[-1:] not in _FIN_DE_FRASE
def interpretacion_defectuosa(text: str) -> bool:
"""True si la salida limpiada no es una interpretación válida: demasiado corta, cadena de
razonamiento filtrada, bucle de repetición o frase cortada. Se usa para forzar un
reintento."""
if len(text.strip()) < 40:
return True
if _MARCADOR_PENSAMIENTO.search(text[:500]):
return True
if interpretacion_truncada(text):
return True
conteo: dict[str, int] = {}
for linea in text.split("\n"):
clave = linea.strip()
if len(clave) > 15:
conteo[clave] = conteo.get(clave, 0) + 1
if conteo[clave] >= 3:
return True
return False
def interpretacion_desde_prosa(texto: str) -> InterpretacionClinica:
"""Limpia el texto crudo y lo envuelve en el contrato estructurado, o lanza ErrorModelo.
Salida defectuosa (razonamiento filtrado / bucle / frase cortada) → error reintentable:
el servicio vuelve a muestrear una vez y suele obtener una respuesta correcta. Se
distingue el motivo porque una respuesta truncada se ve completa y el mensaje genérico
despistaría al diagnosticar.
`requiere_derivacion=True` es un marcador de posición conservador, NO una lectura del
modelo: en esta ruta el modelo devuelve prosa y no puede rellenar campos estructurados.
Quien le pone valor real es el servicio, desde el motor determinista
(`_derivacion_en_ruta_de_prosa`). Dejarlo constante hacía que `acierto_derivacion`
midiera este default y no al modelo, y contradecía al propio texto en un panel normal.
"""
limpio = limpiar_respuesta(texto)
if interpretacion_defectuosa(limpio):
truncada = interpretacion_truncada(limpio)
motivo = (
"la respuesta llegó cortada a mitad de frase"
if truncada
else "el modelo devolvió razonamiento o texto repetido, no la interpretación"
)
raise ErrorModelo(f"Respuesta inutilizable del modelo: {motivo}.", truncado=truncada)
return InterpretacionClinica(
interpretacion=limpio,
requiere_derivacion=True,
idioma="es",
)