File size: 5,973 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
"""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}"