morphos / backend /app /ai /prescripcion.py
Jose Salazar
Ajustes varios al engine y al prompt basado en resultados de los evals
bf9f7d1
Raw
History Blame Contribute Delete
5.97 kB
"""Guarda determinista contra indicaciones terapéuticas en la salida del modelo.
Por qué existe: el 2026-07-31, al probar `SALTAR_RAZONAMIENTO` en el Space, medGemma pasó de
interpretar a prescribir en `cetoacidosis-diabetica-canino` — «iniciar tratamiento inmediato con
insulina, fluidoterapia intravenosa y reposición de potasio», sin encuadrarlo como acto
veterinario presencial. Con potasio 3,0 mEq/L, administrar insulina antes de corregir el potasio
puede precipitar arritmias mortales. El juez lo marcó como violación de seguridad.
Se revirtió aquel experimento, pero el agujero es previo e independiente: ni `SISTEMA` ni
`SISTEMA_PROSA` prohibían dar pautas de tratamiento. Se ha añadido la regla al prompt, y este
módulo es la red que no depende de que el modelo la obedezca — la tercera vez en este proyecto
que una regla de seguridad confiada al prompt de un 4B no se sostiene (ver `alcance.py` y
`_derivacion_obligatoria`).
Qué hace, deliberadamente conservador: NO borra texto clínico. Detectar es fiable; reescribir
prosa clínica automáticamente no lo es, y una frase mutilada puede cambiar de sentido. Cuando
detecta lenguaje prescriptivo, antepone el encuadre que faltaba y fuerza la derivación.
"""
from __future__ import annotations
import re
import unicodedata
# Verbos que ORDENAN una intervención. «Se recomienda valorar» o «considerar» no entran: son
# lenguaje de sugerencia diagnóstica, que es justo lo que la herramienta sí debe hacer.
_VERBOS = (
r"administr\w+|inici\w+|instaur\w+|paut\w+|prescrib\w+|comenz\w+|comienc\w+|"
r"aplic\w+|inyect\w+|suministr\w+|dosific\w+|trate|tratar|reponer|repong\w+"
)
# Términos terapéuticos. No incluye pruebas diagnósticas: pedir una ecografía o un cultivo es
# parte legítima de la interpretación.
_TERAPIA = (
r"insulina|fluidoterapia|fluidos|suero|cristaloides|coloides|transfusi\w+|antibi\w+|"
r"corticoid\w+|glucocorticoid\w+|prednisolona|prednisona|dexametasona|furosemida|"
r"benazepril|enalapril|levotiroxina|metimazol|fenobarbital|bromuro|ciclosporina|"
r"vitamina k|bicarbonato|potasio|calcio gluconato|gluconato c\w+lcico|analg\w+sic\w+|"
r"antiem\w+tic\w+|maropitant|omeprazol|sucralfato|oxigenoterapia|quimioterapia"
)
# Una posología es inequívoca por sí sola, sin necesidad de verbo.
_POSOLOGIA = re.compile(
r"\b\d+([.,]\d+)?\s*(mg|g|mcg|ui|ml|meq|mmol)\s*/\s*(kg|m2|animal|gato|perro)"
r"|\b\d+([.,]\d+)?\s*(mg|ui)\s*/\s*kg"
r"|\bcada\s+\d+\s*(h|horas)\b"
r"|\bq\d+h\b",
re.IGNORECASE,
)
# Verbo imperativo/infinitivo cerca de un término terapéutico, en la misma oración.
_ORDEN = re.compile(rf"\b({_VERBOS})\b[^.;]{{0,80}}?\b({_TERAPIA})\b", re.IGNORECASE)
_ORDEN_INVERSA = re.compile(rf"\b({_TERAPIA})\b[^.;]{{0,40}}?\b({_VERBOS})\b", re.IGNORECASE)
ENCUADRE = (
"Nota de alcance: lo que sigue es interpretación de laboratorio, no una pauta terapéutica. "
"La indicación, la dosificación y la monitorización de cualquier tratamiento son competencia "
"del veterinario responsable del paciente, de forma presencial. "
)
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"\s+", " ", sin_tildes)
# Un fármaco administrado como parte de una PRUEBA DIAGNÓSTICA no es una prescripción: describir
# el protocolo de la supresión con dexametasona o la estimulación con ACTH es exactamente lo que
# la herramienta debe hacer. Sin esta excepción la guarda daba un falso positivo real —medido el
# 2026-08-01 sobre `hiperadrenocorticismo-canino`, donde «se administra dexametasona y se mide el
# cortisol tras 24 horas» describía el LDDST— y le habría estampado un aviso de «esto no es una
# pauta terapéutica» a una recomendación diagnóstica correcta.
_CONTEXTO_DIAGNOSTICO = re.compile(
r"prueba de (estimulacion|supresion|provocacion|ayuno)|"
r"test de (estimulacion|supresion|acth|tolerancia)|"
r"estimulacion con acth|supresion con dexametasona|lddst|psddb|hddst|"
r"curva de glucosa|se mide (el|la) (cortisol|glucemia|glucosa)|"
r"midiendo (nuevamente )?(el|la) cortisol|cortisol (basal|tras|post)",
re.IGNORECASE,
)
# La exención se evalúa por ORACIÓN, y el corte NO incluye ':' ni ';' a propósito: el modelo
# titula la prueba y la describe después («Prueba de Supresión con Dexametasona (LDDST):
# Alternativa para confirmar HAC»), así que partir por ':' separaría el protocolo de su nombre.
# Tampoco vale una ventana de caracteres: con ella, «Prueba de estimulación con ACTH… Mientras
# tanto, iniciar fluidoterapia» quedaba absuelta por la mención diagnóstica de la frase anterior,
# que es justo lo que la guarda debe atrapar. Lo fijan dos tests, uno por cada lado.
_ORACIONES = re.compile(r"(?<=[.!?])\s+|\n+")
def detectar_prescripcion(texto: str) -> list[str]:
"""Fragmentos con lenguaje prescriptivo. Lista vacía si no hay ninguno.
Un fármaco administrado dentro de un protocolo diagnóstico queda exento. Se trabaja sobre
el texto sin tildes y en minúsculas para no depender de cómo acentúe el modelo, que varía
entre generaciones.
"""
hallados: list[str] = []
for oracion in _ORACIONES.split(_normalizar(texto)):
if not oracion.strip() or _CONTEXTO_DIAGNOSTICO.search(oracion):
continue
for patron in (_ORDEN, _ORDEN_INVERSA, _POSOLOGIA):
hallados.extend(m.group(0).strip() for m in patron.finditer(oracion))
# Deduplica conservando el orden de aparición.
vistos: set[str] = set()
return [h for h in hallados if not (h in vistos or vistos.add(h))]
def encuadrar(texto: str) -> str:
"""Antepone el encuadre que faltaba, sin tocar el texto clínico ni duplicar la nota."""
if texto.startswith(ENCUADRE.strip()[:40]):
return texto
return f"{ENCUADRE}{texto}"