Jose Salazar Claude Opus 5 commited on
Commit
7dba92c
·
1 Parent(s): 1fec72e

Léxico clínico compartido y detección de analitos fabricados

Browse files

`ai/lexico.py` centraliza el vocabulario clínico (variantes, abreviaturas,
nombres) que hasta ahora vivía disperso, y `ai/coherencia.py` lo usa para la
comprobación que faltaba: distinguir «no se midió» de «se midió y salió en
rango». Sin ese dato el modelo rellenaba el hueco —medido el 2026-08-04:
medGemma afirmó un leucograma con neutrofilia sobre un panel de calcio/fósforo/
BUN/creatinina, y llamó «trombocitopenia leve» a unas plaquetas en rango—.

De ahí `analitos_medidos` en `PeticionInterpretacion` y en el cliente (`ia.ts`):
el panel COMPLETO, no sólo lo alterado. Campo opcional, así que un cliente
antiguo que no lo mande se comporta como antes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/app/ai/coherencia.py CHANGED
@@ -1,4 +1,4 @@
1
- """Los hallazgos estructurados sólo pueden hablar de analitos que se enviaron.
2
 
3
  Motivo: el 2026-08-01, con salida estructurada, medGemma devolvió en `hallazgos_clave` un
4
  «Potasio (K+): 7.1 mEq/L, alto, grave» en un caso cuyos valores eran calcio, fósforo, BUN y
@@ -9,8 +9,25 @@ Por qué merece guarda propia: la decodificación restringida garantiza JSON bie
9
  veracidad. Y un analito inventado DENTRO de un campo estructurado es peor que en prosa, porque
10
  la interfaz lo pinta como un hallazgo con el mismo rango visual que los del motor determinista.
11
 
12
- A diferencia de `prescripcion.py`, aquí se BORRA: un elemento de lista es una unidad
13
- independiente y quitarlo no deja una frase a medias.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
 
16
  from __future__ import annotations
@@ -20,6 +37,7 @@ import re
20
  import unicodedata
21
 
22
  from ..schemas import InterpretacionClinica, PeticionInterpretacion
 
23
 
24
  log = logging.getLogger("morphos.ia")
25
 
@@ -77,3 +95,200 @@ def descartar_fabricados(
77
  h for h in resultado.hallazgos_clave if h.analito not in fabricados
78
  ]
79
  return resultado
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """El modelo sólo puede hablar de analitos que se enviaron, y sólo alterarlos si lo estaban.
2
 
3
  Motivo: el 2026-08-01, con salida estructurada, medGemma devolvió en `hallazgos_clave` un
4
  «Potasio (K+): 7.1 mEq/L, alto, grave» en un caso cuyos valores eran calcio, fósforo, BUN y
 
9
  veracidad. Y un analito inventado DENTRO de un campo estructurado es peor que en prosa, porque
10
  la interfaz lo pinta como un hallazgo con el mismo rango visual que los del motor determinista.
11
 
12
+ Dos guardas, porque son dos superficies distintas:
13
+
14
+ - `descartar_fabricados` limpia `hallazgos_clave`. Aquí sí se BORRA (a diferencia de
15
+ `prescripcion.py`): un elemento de lista es una unidad independiente y quitarlo no deja una
16
+ frase a medias.
17
+ - `analitos_fabricados_en_prosa` mira la PROSA, que es donde va todo en la ruta desplegada en
18
+ producción —el Space devuelve texto libre y `hallazgos_clave` llega vacío por diseño, así que
19
+ la guarda de arriba no ve nada—. Aquí NO se borra: sólo detecta y devuelve la lista; quien
20
+ actúa es `service.py`, que vuelve a muestrear con una instrucción correctiva.
21
+ - `alterados_declarados_normales` es la simétrica de la anterior sobre la misma prosa: no que
22
+ se invente una alteración, sino que se dé por normal un valor que el motor determinista ve
23
+ fuera de rango. Se remedia igual, regenerando.
24
+
25
+ Lo que motivó la segunda, corrida del 2026-08-04 (juez_seguridad 0.79, umbral 0.90): sobre un
26
+ panel de calcio/fósforo/BUN/creatinina el modelo escribió «La leucograma muestra neutrofilia y
27
+ linfopenia» —única violación de seguridad de la corrida—, en otro caso afirmó «aumento de
28
+ reticulocitos» sin recuento reticulocitario, y en un tercero llamó «trombocitopenia leve» a unas
29
+ plaquetas de 190 que estaban en rango. Los tres son la misma carencia: el modelo no distinguía
30
+ «no se midió» de «se midió y salió normal».
31
  """
32
 
33
  from __future__ import annotations
 
37
  import unicodedata
38
 
39
  from ..schemas import InterpretacionClinica, PeticionInterpretacion
40
+ from .lexico import abreviaturas_presentes, nombre_clinico, sin_tildes, terminos_especificos
41
 
42
  log = logging.getLogger("morphos.ia")
43
 
 
95
  h for h in resultado.hallazgos_clave if h.analito not in fabricados
96
  ]
97
  return resultado
98
+
99
+
100
+ # --- Guarda sobre la prosa ---
101
+
102
+ # Pedir una prueba es exactamente lo que la herramienta DEBE hacer, así que una oración de
103
+ # recomendación queda exenta: sin esto, la propia frase correcta de `hipercalcemia-canino` («Se
104
+ # debe obtener un hemograma completo incluyendo plaquetas») se marcaría como invención del
105
+ # hemograma que la misma respuesta reconoce no tener. Se prefiere el falso negativo: un falso
106
+ # positivo gasta una llamada al Space y le mete al modelo una corrección que no procede.
107
+ _RECOMENDACION = re.compile(
108
+ r"\b(se recomienda|recomendable|recomiendo|se sugiere|sugiero|se aconseja|"
109
+ r"solicit\w+|obtener|obtenga|realiz\w+|efectu\w+|considerar|considere|evaluar|eval[uú]e|"
110
+ r"valorar|valore|determinar|determine|cuantific\w+|medicion|medir|mida|ampliar|amplie|"
111
+ r"complet\w+|incluir|incluya|pedir|pida|repetir|repita|monitoriz\w+|control\w+|"
112
+ r"seguimiento|descartar|descarte|confirmar|confirme|investigar|investigue|"
113
+ r"siguientes pruebas|pruebas (diagnosticas|adicionales|complementarias))\b"
114
+ )
115
+
116
+ # La prosa clínica explica MECANISMOS, y eso no es afirmar un dato del paciente: «el iCa puede
117
+ # estar alterado en presencia de alcalosis o hipoalbuminemia» habla de fisiología general, no
118
+ # dice que este gato tenga alcalosis. Sin esta exención, `hipomagnesemia-uci-felino` se marcaba
119
+ # por esa misma frase, que es correcta.
120
+ _HIPOTETICO = re.compile(
121
+ r"\b(puede|pueden|podria|podrian|pudiera|suele|suelen|si hay|si existe|si se|"
122
+ r"en presencia de|en ausencia de|en caso de|en casos de|cuando hay|cuando existe|"
123
+ r"posible|posibles|probable|probables|sospecha de|compatible con|sugiere|sugieren|"
124
+ r"sugestiv\w+|asociad\w+ a|secundari\w+ a|habitualmente|t[ií]picamente)\b"
125
+ )
126
+
127
+ # Nombrar un ESTADO fisiopatológico no es reportar un resultado de laboratorio: «indican una
128
+ # marcada deficiencia de insulina» en una cetoacidosis es la conclusión correcta, no la
129
+ # invención de una insulinemia que nadie midió. Sin esta exención, `cetoacidosis-felino-gases`
130
+ # —cuya salida el juez puntuó 1.00 en seguridad— gastaba un reintento contra el Space por una
131
+ # frase impecable.
132
+ _ESTADO_FISIOPATOLOGICO = re.compile(
133
+ r"\b(deficiencia|deficit|carencia|exceso|resistencia|insuficiencia|sobreproduccion|"
134
+ r"produccion excesiva|liberacion)\s+(de|a la|al|de la)\b"
135
+ )
136
+
137
+ # Marcadores de que la oración AFIRMA un dato, no lo pide ni lo hipotetiza.
138
+ _ASERCION = re.compile(
139
+ r"\b(muestra|muestran|presenta|presentan|se observa\w*|se aprecia\w*|se evidencia\w*|"
140
+ r"evidencia|revela|revelan|hay|existe|existen|aument\w+|disminu\w+|elevad\w+|"
141
+ r"descendid\w+|reducid\w+|increment\w+|alto|alta|altos|altas|bajo|baja|bajos|bajas|"
142
+ r"marcad\w+|sever\w+|acentuad\w+)\b"
143
+ )
144
+
145
+ # Un término que POR SÍ SOLO afirma una alteración: «trombocitopenia», «hipocalcemia»,
146
+ # «neutrofilia». Nombrar el analito («las plaquetas», «el calcio») no es afirmar nada; llamarlo
147
+ # por su alteración sí. Es lo que separa el caso del analito medido y en rango —donde sólo esto
148
+ # cuenta— del analito que nunca se midió, donde cualquier aserción vale.
149
+ _TERMINO_DE_ALTERACION = re.compile(
150
+ r"^(hiper|hipo|macro|micro|pan)\w+"
151
+ r"|(penia|citosis|filia|osis|uria|emia)$"
152
+ )
153
+ # Alteraciones cuya forma no delata el prefijo/sufijo de arriba. Se listan a mano, mismo
154
+ # criterio que `VARIANTES` en lexico.py: aquí un falso positivo es peor que un falso negativo.
155
+ _ALTERACIONES_IRREGULARES = frozenset({"regenerativa", "azotemia", "uremia", "transaminasas"})
156
+
157
+ _ORACIONES = re.compile(r"(?<=[.!?;:])\s+|\n+")
158
+
159
+
160
+ def _es_alteracion(termino: str) -> bool:
161
+ return termino in _ALTERACIONES_IRREGULARES or bool(_TERMINO_DE_ALTERACION.search(termino))
162
+
163
+
164
+ def analitos_fabricados_en_prosa(
165
+ texto: str, pet: PeticionInterpretacion
166
+ ) -> list[str]:
167
+ """Nombres clínicos de analitos que la prosa afirma sin respaldo. Vacía si no hay ninguno.
168
+
169
+ Dos casos, con umbrales distintos a propósito:
170
+
171
+ - **No se midió**: cualquier oración que lo AFIRME (verbo de constatación o término de
172
+ dirección) o que lo nombre por su alteración es invención. Ejemplo real: «La leucograma
173
+ muestra neutrofilia y linfopenia» sobre un panel de bioquímica.
174
+ - **Se midió y salió en rango**: nombrarlo es legítimo —el modelo puede decir que las
175
+ plaquetas están conservadas—; lo que no vale es llamarlo por su alteración. Ejemplo real:
176
+ «trombocitopenia leve» con `plt` = 190, dentro de rango.
177
+
178
+ Sin `analitos_medidos` no se puede distinguir «no medido» de «medido y normal», así que la
179
+ guarda se desactiva entera: es lo que manda una petición de un cliente antiguo, y marcar a
180
+ ciegas ahí generaría reintentos sobre respuestas correctas.
181
+ """
182
+ medidos = {c.strip() for c in pet.analitos_medidos if c.strip()}
183
+ if not medidos:
184
+ return []
185
+
186
+ lexico = terminos_especificos()
187
+ # `hallazgos` son los analitos FUERA de rango; los `parametros` de un patrón son los que el
188
+ # patrón involucra, estén alterados o no, así que cuentan como conocidos pero no como
189
+ # alterados. Mezclarlos absolvía el caso que motivó la guarda: en
190
+ # `hemolisis-regenerativa-canino` un patrón lista `plt` entre sus parámetros con las
191
+ # plaquetas en 190 —en rango—, y la «trombocitopenia leve» del modelo quedaba sin marcar.
192
+ alterados = {h.clave for h in pet.hallazgos}
193
+ conocidos = medidos | alterados
194
+ for patron in pet.patrones:
195
+ conocidos.update(patron.parametros)
196
+ en_rango = (conocidos - alterados) & lexico.keys()
197
+ no_medidos = lexico.keys() - conocidos
198
+
199
+ # Un término que también nombra a un analito realmente alterado no prueba nada: «azotemia»
200
+ # la sostiene un BUN alto aunque la creatinina esté en rango, y «eritrocitos» puede ser el
201
+ # RBC del hemograma y no los de la orina. Se exige que el término apunte SÓLO a lo que no
202
+ # se midió (o a lo que se midió y salió normal).
203
+ respaldados = {t for c in alterados for t in lexico.get(c, ())}
204
+
205
+ fabricados: dict[str, None] = {} # dict para deduplicar conservando el orden
206
+ for oracion in _ORACIONES.split(texto):
207
+ normalizada = sin_tildes(oracion)
208
+ if not normalizada.strip():
209
+ continue
210
+ if (
211
+ _RECOMENDACION.search(normalizada)
212
+ or _HIPOTETICO.search(normalizada)
213
+ or _ESTADO_FISIOPATOLOGICO.search(normalizada)
214
+ ):
215
+ continue
216
+ afirma = bool(_ASERCION.search(normalizada))
217
+ # Las siglas ambiguas se cotejan sobre la oración SIN normalizar: es la mayúscula lo
218
+ # único que distingue el "UN" del BUN del artículo indeterminado.
219
+ siglas = abreviaturas_presentes(oracion)
220
+ for clave in no_medidos | en_rango:
221
+ presentes = [
222
+ t for t in lexico[clave]
223
+ if t not in respaldados and re.search(rf"\b{re.escape(t)}\b", normalizada)
224
+ ]
225
+ # Una sigla nombra al analito, no lo altera: vale como mención, no como
226
+ # alteración, igual que decir «las plaquetas».
227
+ nombrado = bool(presentes) or clave in siglas
228
+ if not nombrado:
229
+ continue
230
+ if any(map(_es_alteracion, presentes)) or (clave in no_medidos and afirma):
231
+ fabricados[nombre_clinico(clave)] = None
232
+ return list(fabricados)
233
+
234
+
235
+ # --- Guarda simétrica: declarar normal lo que el motor vio alterado ---
236
+
237
+ # El copulativo es lo que separa afirmar de razonar: «la MCV aquí está normal» habla de ESTE
238
+ # paciente, mientras que «una creatinina normal no descarta ERC» enuncia un principio general y
239
+ # no debe marcarse. Por eso se exige el verbo, y no basta con que aparezca la palabra «normal».
240
+ _AFIRMA_NORMALIDAD = re.compile(
241
+ r"\b(esta|estan|es|son|se (encuentra|encuentran|mantiene|mantienen|situa|situan)|"
242
+ r"permanece\w*|resulta\w*|luce\w*|aparece\w*)\s+"
243
+ r"(dentro|en (el )?(rango|limites|intervalo)|normal\w*|conservad\w*|dentro de)"
244
+ r"|\b(dentro (de|del) (los |las )?(rango|limites|valores|intervalo)\w*|"
245
+ r"sin alteracion\w*|no muestra alteracion\w*)"
246
+ )
247
+
248
+ # «no está normal», «lejos de lo normal»: la negación invierte la frase y deja de ser una
249
+ # declaración de normalidad. Barata de comprobar y evita el falso positivo más obvio.
250
+ _NEGACION = re.compile(r"\b(no|nunca|tampoco|lejos de|salvo|excepto)\b")
251
+
252
+ # Esta guarda trocea MÁS fino que la de invención, y sólo ella. El caso real venía dentro de una
253
+ # oración larga: «…la literatura [2] menciona que la microcitosis no es típica en shunt
254
+ # portosistémico (aunque la MCV aquí está normal)», donde el «no» y el «posible» de la primera
255
+ # mitad eximían a la segunda. Una adversativa o un paréntesis abren una afirmación nueva, y aquí
256
+ # la afirmación que importa es corta y se sostiene sola. No se aplica al otro sentido de la
257
+ # guarda: allí trocear más deja frases sin el contexto que las exime y dispara falsos positivos.
258
+ _CLAUSULAS = re.compile(r"\b(aunque|si bien|mientras que|pese a que|a pesar de que)\b|[()]")
259
+
260
+
261
+ def alterados_declarados_normales(
262
+ texto: str, pet: PeticionInterpretacion
263
+ ) -> list[str]:
264
+ """Nombres clínicos de analitos ALTERADOS que la prosa declara normales.
265
+
266
+ Es la mitad que faltaba de `analitos_fabricados_en_prosa`: aquella detecta alterar lo que
267
+ está bien, y ésta declarar bien lo que está alterado. Clínicamente la segunda es la peor de
268
+ las dos —normalizar un valor patológico invita a no actuar—, y hasta ahora no la miraba
269
+ nadie. Medido el 2026-08-04 en `shunt-portosistemico-canino`: el modelo listó «microcitosis»
270
+ entre los hallazgos y tres frases después escribió «aunque la MCV aquí está normal», con un
271
+ VCM de 58 en un perro. El juez lo penalizó por la contradicción (seguridad 0.90 → 0.55).
272
+
273
+ El criterio es el mismo de todo el fichero: se prefiere el falso negativo. Sólo cuenta si
274
+ la oración nombra el analito, afirma normalidad con un copulativo y no la niega.
275
+ """
276
+ alterados = {h.clave for h in pet.hallazgos}
277
+ if not alterados:
278
+ return []
279
+
280
+ lexico = terminos_especificos()
281
+ declarados: dict[str, None] = {}
282
+ for oracion in _ORACIONES.split(texto):
283
+ # `split` con grupos devuelve también los separadores (y None por los que no casan).
284
+ for clausula in _CLAUSULAS.split(sin_tildes(oracion)):
285
+ if not clausula or not clausula.strip():
286
+ continue
287
+ if _HIPOTETICO.search(clausula) or _NEGACION.search(clausula):
288
+ continue
289
+ if not _AFIRMA_NORMALIDAD.search(clausula):
290
+ continue
291
+ for clave in alterados & lexico.keys():
292
+ if any(re.search(rf"\b{re.escape(t)}\b", clausula) for t in lexico[clave]):
293
+ declarados[nombre_clinico(clave)] = None
294
+ return list(declarados)
backend/app/ai/lexico.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Léxico de analitos: clave → términos con los que un texto clínico puede nombrarla.
2
+
3
+ Única fuente del mapeo. Vivía sólo en `evals/run_evals.py`, que lo usaba para medir la
4
+ cobertura de hallazgos sobre la prosa; ahora también lo necesita el backend en tiempo de
5
+ ejecución —`ai/coherencia.py` para detectar analitos inventados y `ai/prompt.py` para nombrar
6
+ en cristiano los que salieron en rango—, así que se ha traído aquí y `run_evals.py` lo importa.
7
+ Duplicarlo habría dejado la métrica y la guarda midiendo cosas distintas con el mismo nombre.
8
+
9
+ Se construye desde `data/valores_referencia.json`, que ya trae el nombre clínico de cada
10
+ analito ("ALT (GPT)", "Densidad (USG)"), así que el grueso del léxico no se escribe a mano.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import unicodedata
18
+ from functools import lru_cache
19
+
20
+ from ..config import RAIZ_REPO
21
+
22
+ # Palabras del nombre clínico que no identifican al analito por sí solas: "T4 total" y
23
+ # "Proteínas totales" comparten "total", y buscarla marcaría cualquiera de los dos.
24
+ _GENERICOS = {"total", "libre", "serico", "serica", "plasmatico", "urinario", "sangre"}
25
+
26
+
27
+ def sin_tildes(texto: str) -> str:
28
+ return "".join(
29
+ c for c in unicodedata.normalize("NFD", texto.lower()) if unicodedata.category(c) != "Mn"
30
+ )
31
+
32
+
33
+ # La prosa clínica casi nunca nombra el analito: nombra su alteración ("hiperfosforemia" en
34
+ # vez de "fósforo", "trombocitopenia" en vez de "plaquetas"). Estas variantes se listan a
35
+ # mano en vez de derivarlas por stemming a propósito: un prefijo de 5 letras haría que
36
+ # "hematoma" contara como hematocrito, y aquí un falso positivo es peor que un falso negativo
37
+ # —tanto en la métrica como en la guarda, donde provoca un reintento inútil—. Sólo se incluyen
38
+ # derivaciones del NOMBRE del analito, no síndromes que lo acompañan (anemia no cuenta como
39
+ # mención del hematocrito).
40
+ VARIANTES: dict[str, tuple[str, ...]] = {
41
+ "alb": ("hipoalbuminemia", "hiperalbuminemia", "albuminemia"),
42
+ "alt": ("gpt", "transaminasas", "transaminasa"),
43
+ # La grafía con UNA r es incorrecta y el modelo la usa igual: el 2026-08-04 escribió
44
+ # «hiperbilirubinemia» en `shunt-portosistemico-canino`, sobre una bilirrubina que nunca
45
+ # se midió, y la guarda de invención lo dejó pasar por esa letra. Un término de más aquí
46
+ # no puede casar con otro analito, así que el coste de admitir la falta de ortografía es
47
+ # cero y el de no admitirla ya se pagó.
48
+ "bili": ("hiperbilirrubinemia", "bilirrubinemia", "hiperbilirubinemia", "bilirubinemia"),
49
+ "bun": ("azotemia", "uremia", "urea"),
50
+ "ca_ion": ("hipercalcemia", "hipocalcemia", "calcemia", "ica"),
51
+ "calc": ("hipercalcemia", "hipocalcemia", "calcemia"),
52
+ "colest": ("hipercolesterolemia", "colesterolemia"),
53
+ "creat": ("azotemia",),
54
+ "fal": ("alp", "fosfatasa"),
55
+ "fosf": ("hiperfosfatemia", "hipofosfatemia", "hiperfosforemia", "fosfatemia", "fosforemia"),
56
+ "glob": ("hiperglobulinemia", "globulinemia", "gammapatia"),
57
+ "gluc": ("hiperglucemia", "hipoglucemia", "glucemia", "hiperglicemia"),
58
+ "hco3": ("bicarbonato",),
59
+ "neutro_abs": ("neutrofilia", "neutropenia"),
60
+ "ph_sangre": ("acidosis", "alcalosis", "acidemia", "alcalemia"),
61
+ "pli": ("cpli", "lipasa"),
62
+ "plt": ("trombocitopenia", "trombocitosis", "plaquetopenia"),
63
+ "potasio": ("hipopotasemia", "hiperpotasemia", "hipokalemia", "hiperkalemia", "kalemia"),
64
+ "prot": ("hiperproteinemia", "hipoproteinemia", "proteinemia"),
65
+ "reti": ("reticulocitosis", "regenerativa"),
66
+ "sodio": ("hiponatremia", "hipernatremia", "natremia"),
67
+ "t4_total": ("t4", "tiroxina"),
68
+ "usg": ("isostenuria", "hipostenuria"),
69
+ "vcm": ("mcv", "microcitosis", "macrocitosis"),
70
+ "wbc": ("leucocitosis", "leucopenia", "leucocitos"),
71
+ }
72
+
73
+
74
+ # Siglas cuya versión en minúsculas es una palabra corriente del castellano. No pueden entrar
75
+ # en `VARIANTES`: todo el léxico se coteja sobre texto normalizado a minúsculas, y buscar "un"
76
+ # marcaría prácticamente cualquier oración. Se cotejan aparte, respetando las mayúsculas, sobre
77
+ # el texto ORIGINAL. Motivo: el 2026-08-04, en `piometra-progesterona-canino`, el modelo escribió
78
+ # «Esta combinación (alta UN, alta CT, USG bajo) caracteriza una azotemia renal» sobre un panel
79
+ # sin BUN; "azotemia" quedaba absuelta —la creatinina sí estaba alta— y la sigla era la única
80
+ # señal de que se había inventado el dato.
81
+ ABREVIATURAS_SENSIBLES: dict[str, tuple[str, ...]] = {
82
+ "bun": ("UN", "BUN"),
83
+ }
84
+
85
+
86
+ def abreviaturas_presentes(texto: str) -> set[str]:
87
+ """Claves cuya sigla ambigua aparece, con sus mayúsculas, en el texto tal cual se escribió."""
88
+ return {
89
+ clave for clave, siglas in ABREVIATURAS_SENSIBLES.items()
90
+ if any(re.search(rf"\b{re.escape(s)}\b", texto) for s in siglas)
91
+ }
92
+
93
+
94
+ def tokens_analito(texto: str) -> set[str]:
95
+ # Mínimo 2 caracteres: hay analitos cuyo nombre entero es corto ("T4", "pH"), y con 3
96
+ # se quedaban sin ningún término con el que buscarlos.
97
+ return {
98
+ t for t in re.split(r"[^a-z0-9]+", sin_tildes(texto))
99
+ if len(t) >= 2 and t not in _GENERICOS
100
+ }
101
+
102
+
103
+ @lru_cache
104
+ def _referencias() -> dict:
105
+ ruta = RAIZ_REPO / "data" / "valores_referencia.json"
106
+ return json.loads(ruta.read_text(encoding="utf-8"))
107
+
108
+
109
+ @lru_cache
110
+ def lexico_analitos() -> dict[str, frozenset[str]]:
111
+ """clave de analito → términos con los que un texto puede referirse a él."""
112
+ lexico: dict[str, set[str]] = {}
113
+ for analitos in _referencias().values(): # canino, felino
114
+ for clave, info in analitos.items():
115
+ terminos = lexico.setdefault(clave, set())
116
+ terminos |= tokens_analito(clave)
117
+ terminos |= tokens_analito(info.get("nombre", ""))
118
+ for clave, variantes in VARIANTES.items():
119
+ lexico.setdefault(clave, set()).update(variantes)
120
+ return {clave: frozenset(t) for clave, t in lexico.items()}
121
+
122
+
123
+ @lru_cache
124
+ def nombres_clinicos() -> dict[str, str]:
125
+ """clave → nombre clínico legible ("plt" → "Plaquetas"). La clave cruda si no hay nombre."""
126
+ nombres: dict[str, str] = {}
127
+ for analitos in _referencias().values():
128
+ for clave, info in analitos.items():
129
+ if nombre := info.get("nombre"):
130
+ nombres.setdefault(clave, nombre)
131
+ return nombres
132
+
133
+
134
+ def nombre_clinico(clave: str) -> str:
135
+ return nombres_clinicos().get(clave, clave)
136
+
137
+
138
+ # Palabras que aparecen DENTRO del nombre clínico de algún analito pero que no lo identifican:
139
+ # calificadores ("directa", "ionizado", "arterial"), unidades de medida del propio nombre
140
+ # ("tiempo", "cociente", "índice") y sustantivos compartidos por varios ("proteína", "creatina").
141
+ # Sin este filtro «de» y «tiempo» —del "Tiempo de protrombina (TP)"— casan en casi cualquier
142
+ # frase clínica: medido sobre las 30 predicciones del 2026-08-04, el TP salía marcado en 27.
143
+ _NO_IDENTIFICAN = frozenset({
144
+ "tiempo", "exceso", "nivel", "basal", "serico", "serica", "urinario", "orina", "sangre",
145
+ "total", "libre", "parcial", "activada", "protrombina", "tromboplastina", "sanguineo",
146
+ "relacion", "indice", "recuento", "absoluto", "estimulacion", "supresion", "post", "pre",
147
+ "arterial", "saturacion", "cociente", "antigeno", "reactiva", "directa", "ionizado",
148
+ "pancreatica", "acidos", "biliares", "creatina", "kinasa", "cardiaca", "amiloide",
149
+ "proteina", "dimeros",
150
+ })
151
+ # Debajo de esto, un token del nombre es una abreviatura ("tp", "be", "ac", "at") que casa
152
+ # dentro de cualquier palabra o como partícula suelta. Las siglas que SÍ interesan ya entran
153
+ # por `VARIANTES`, que es una lista curada.
154
+ _LARGO_MINIMO_TERMINO = 6
155
+
156
+
157
+ @lru_cache
158
+ def terminos_especificos() -> dict[str, frozenset[str]]:
159
+ """Subconjunto del léxico con la precisión que exige BUSCAR AL REVÉS.
160
+
161
+ `claves_mencionadas` pregunta «¿aparece ESTE analito?» sobre un puñado de claves conocidas,
162
+ y ahí un token flojo apenas molesta. La guarda de invención hace lo contrario: barre los 90
163
+ analitos contra una prosa para ver cuál NO debería estar. Con el léxico completo eso es un
164
+ generador de falsos positivos, y cada falso positivo cuesta una llamada al modelo y una
165
+ corrección improcedente. Aquí sólo entran los términos curados a mano (`VARIANTES`) y las
166
+ palabras del nombre clínico lo bastante largas y específicas como para no casar por azar.
167
+ """
168
+ especificos: dict[str, frozenset[str]] = {}
169
+ for clave, terminos in lexico_analitos().items():
170
+ utiles = set(VARIANTES.get(clave, ()))
171
+ utiles |= {
172
+ t for t in terminos
173
+ if len(t) >= _LARGO_MINIMO_TERMINO and t not in _NO_IDENTIFICAN
174
+ }
175
+ if utiles:
176
+ especificos[clave] = frozenset(utiles)
177
+ return especificos
178
+
179
+
180
+ def claves_mencionadas(texto: str, claves: set[str]) -> set[str]:
181
+ """Cuáles de `claves` aparecen nombradas en el texto.
182
+
183
+ Se busca el término con límites de palabra para que "alt" no case dentro de "alteración".
184
+ """
185
+ normalizado = sin_tildes(texto)
186
+ lexico = lexico_analitos()
187
+ encontradas = set()
188
+ for clave in claves:
189
+ terminos = lexico.get(clave) or tokens_analito(clave)
190
+ if any(re.search(rf"\b{re.escape(t)}\b", normalizado) for t in terminos):
191
+ encontradas.add(clave)
192
+ return encontradas
backend/app/ai/prompt.py CHANGED
@@ -11,6 +11,7 @@ from __future__ import annotations
11
  from ..config import obtener_config
12
  from ..rag.retriever import Fragmento
13
  from ..schemas import PeticionInterpretacion
 
14
 
15
  # Cuánto texto de cada fragmento se le enseña al modelo. El recorte por presupuesto total
16
  # (config.rag_max_chars_prompt) cuenta en esta misma unidad.
@@ -25,6 +26,16 @@ Reglas estrictas:
25
  - Responde SIEMPRE en español.
26
  - Cíñete a los datos aportados (señalamiento, valores de laboratorio, patrones detectados,
27
  literatura recuperada e imágenes). No inventes valores ni hallazgos.
 
 
 
 
 
 
 
 
 
 
28
  - Cuando afirmes algo respaldado por la literatura recuperada, cítalo en el campo `citas`
29
  del diferencial correspondiente, usando el número entre corchetes con el que se te
30
  presentó el fragmento ([1], [2]…). No cites lo que no se te dio: una cita que no
@@ -50,6 +61,16 @@ ni el examen presencial del paciente.
50
  Reglas estrictas:
51
  - Responde SIEMPRE en español.
52
  - Cíñete a los datos aportados; no inventes valores ni hallazgos.
 
 
 
 
 
 
 
 
 
 
53
  - Si se te entrega literatura recuperada, marca cada afirmación que se apoye en ella con su
54
  número entre corchetes justo después de la frase ([1], [2]…). Es la ÚNICA forma de citar
55
  en esta ruta: no escribas títulos de libros ni páginas, y no uses números que no estén en
@@ -88,6 +109,37 @@ def _linea_hallazgo(h, con_gravedad: bool = True) -> str:
88
  return f" {h.nombre} ({h.clave}): {h.valor} {h.unidad} — {h.direccion.value}{etiqueta}"
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def _bloque_contexto_rag(fragmentos: list[Fragmento]) -> str:
92
  if not fragmentos:
93
  return ""
@@ -126,6 +178,7 @@ def construir_mensaje_usuario(
126
  if pet.patrones and cfg.prompt_incluir_patrones
127
  else ""
128
  )
 
129
  sin_alteraciones = not pet.hallazgos and not pet.patrones
130
 
131
  signos = f"\nSignos clínicos referidos: {pet.signos_clinicos.strip()}" if pet.signos_clinicos.strip() else ""
@@ -164,7 +217,7 @@ def construir_mensaje_usuario(
164
 
165
  return f"""\
166
  Paciente: {p.especie or 'desconocido'}, raza {p.raza or 'NE'}, edad {edad}, sexo {p.sexo or 'NE'}\
167
- {bloque_hallazgos}{bloque_patrones}{signos}{imagenes}
168
  {_bloque_contexto_rag(fragmentos)}
169
 
170
  {instruccion}"""
 
11
  from ..config import obtener_config
12
  from ..rag.retriever import Fragmento
13
  from ..schemas import PeticionInterpretacion
14
+ from .lexico import nombre_clinico
15
 
16
  # Cuánto texto de cada fragmento se le enseña al modelo. El recorte por presupuesto total
17
  # (config.rag_max_chars_prompt) cuenta en esta misma unidad.
 
26
  - Responde SIEMPRE en español.
27
  - Cíñete a los datos aportados (señalamiento, valores de laboratorio, patrones detectados,
28
  literatura recuperada e imágenes). No inventes valores ni hallazgos.
29
+ - Sólo puedes afirmar hallazgos sobre los analitos del panel que se te entrega. Un analito que
30
+ no aparece NO se ha medido: nombrarlo como resultado es un error grave. Si lo necesitas,
31
+ pídelo como siguiente prueba.
32
+ - Lo mismo con los signos clínicos: sólo puedes atribuir al paciente los que figuren en
33
+ "Signos clínicos referidos". No añadas otros para redondear un cuadro.
34
+ - Respeta la gravedad que se te indica de cada valor: no la rebajes ni la exageres al
35
+ describirla en tu texto.
36
+ - Si citas una cifra de corte de la literatura, di explícitamente dónde cae el valor real de
37
+ este paciente respecto a ella. Un umbral suelto se lee como si el paciente lo cumpliera.
38
+ - Propón sólo pruebas aplicables a la especie del paciente.
39
  - Cuando afirmes algo respaldado por la literatura recuperada, cítalo en el campo `citas`
40
  del diferencial correspondiente, usando el número entre corchetes con el que se te
41
  presentó el fragmento ([1], [2]…). No cites lo que no se te dio: una cita que no
 
61
  Reglas estrictas:
62
  - Responde SIEMPRE en español.
63
  - Cíñete a los datos aportados; no inventes valores ni hallazgos.
64
+ - Sólo puedes afirmar hallazgos sobre los analitos del panel que se te entrega. Un analito que
65
+ no aparece NO se ha medido: nombrarlo como resultado es un error grave. Si lo necesitas,
66
+ pídelo como siguiente prueba.
67
+ - Lo mismo con los signos clínicos: sólo puedes atribuir al paciente los que figuren en
68
+ "Signos clínicos referidos". No añadas otros para redondear un cuadro.
69
+ - Respeta la gravedad que se te indica de cada valor: no la rebajes ni la exageres al
70
+ describirla en tu texto.
71
+ - Si citas una cifra de corte de la literatura, di explícitamente dónde cae el valor real de
72
+ este paciente respecto a ella. Un umbral suelto se lee como si el paciente lo cumpliera.
73
+ - Propón sólo pruebas aplicables a la especie del paciente.
74
  - Si se te entrega literatura recuperada, marca cada afirmación que se apoye en ella con su
75
  número entre corchetes justo después de la frase ([1], [2]…). Es la ÚNICA forma de citar
76
  en esta ruta: no escribas títulos de libros ni páginas, y no uses números que no estén en
 
109
  return f" {h.nombre} ({h.clave}): {h.valor} {h.unidad} — {h.direccion.value}{etiqueta}"
110
 
111
 
112
+ def _bloque_panel(pet: PeticionInterpretacion) -> str:
113
+ """Qué se ha medido: los que salieron en rango, y el cierre de que no hay nada más.
114
+
115
+ `hallazgos` sólo trae lo ALTERADO, así que sin esto el modelo no puede distinguir «no se
116
+ midió» de «se midió y salió normal», y rellena el hueco. Medido el 2026-08-04 sobre el
117
+ Space: sobre un panel de calcio/fósforo/BUN/creatinina escribió «La leucograma muestra
118
+ neutrofilia y linfopenia» —la única violación de seguridad de la corrida—, y en otro caso
119
+ afirmó «aumento de reticulocitos» sin recuento reticulocitario.
120
+
121
+ Se omite ENTERO si la petición no trae `analitos_medidos` (cliente antiguo). El criterio es
122
+ el mismo que rige los otros bloques de esta función: el relleno se lee como contenido, y una
123
+ lista vacía anunciada como «panel completo» sería peor que no decir nada.
124
+ """
125
+ if not pet.analitos_medidos:
126
+ return ""
127
+ alterados = {h.clave for h in pet.hallazgos}
128
+ en_rango = [c for c in pet.analitos_medidos if c not in alterados]
129
+ bloque = ""
130
+ if en_rango:
131
+ nombres = ", ".join(nombre_clinico(c) for c in en_rango)
132
+ bloque = (
133
+ "\n\nAnalitos medidos que salieron DENTRO de rango (no los describas como "
134
+ f"alterados):\n {nombres}"
135
+ )
136
+ return bloque + (
137
+ "\n\nÉse es el panel COMPLETO que se ha realizado. Cualquier analito que no aparezca "
138
+ "arriba NO se ha determinado en este paciente: no afirmes su valor, su dirección ni "
139
+ "ningún hallazgo basado en él. Si lo consideras necesario, pídelo como siguiente prueba."
140
+ )
141
+
142
+
143
  def _bloque_contexto_rag(fragmentos: list[Fragmento]) -> str:
144
  if not fragmentos:
145
  return ""
 
178
  if pet.patrones and cfg.prompt_incluir_patrones
179
  else ""
180
  )
181
+ bloque_panel = _bloque_panel(pet)
182
  sin_alteraciones = not pet.hallazgos and not pet.patrones
183
 
184
  signos = f"\nSignos clínicos referidos: {pet.signos_clinicos.strip()}" if pet.signos_clinicos.strip() else ""
 
217
 
218
  return f"""\
219
  Paciente: {p.especie or 'desconocido'}, raza {p.raza or 'NE'}, edad {edad}, sexo {p.sexo or 'NE'}\
220
+ {bloque_hallazgos}{bloque_patrones}{bloque_panel}{signos}{imagenes}
221
  {_bloque_contexto_rag(fragmentos)}
222
 
223
  {instruccion}"""
backend/app/ai/service.py CHANGED
@@ -26,7 +26,11 @@ from ..schemas import (
26
  from .alcance import motivo_fuera_de_alcance, respuesta_fuera_de_alcance
27
  from .base import ClienteModelo, ErrorModelo
28
  from .citas import aplicar_atribucion
29
- from .coherencia import descartar_fabricados
 
 
 
 
30
  from .prescripcion import detectar_prescripcion, encuadrar
31
  from .prompt import (
32
  LARGO_FRAGMENTO_PROMPT,
@@ -133,6 +137,54 @@ def _crear_cliente(backend: str, modelo_local: str | None = None) -> ClienteMode
133
  return MedGemmaClient()
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
137
  cfg = obtener_config()
138
  backend = pet.backend or cfg.ia_backend_defecto
@@ -184,8 +236,9 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
184
  )
185
  resultado: InterpretacionClinica | None = None
186
  ultimo_error: ErrorModelo | None = None
 
187
  for intento in range(2):
188
- mensaje = construir_mensaje_usuario(pet, enviados)
189
  try:
190
  resultado = await cliente.interpretar(sistema, mensaje, pet.imagenes)
191
  # La ruta de prosa no puede rellenar los campos estructurados; el resto sí, y un
@@ -195,6 +248,26 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
195
  if vacio:
196
  resultado = None
197
  raise ErrorModelo(f"El modelo dejó '{vacio}' vacío pese a haber hallazgos.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  break
199
  except ErrorModelo as exc:
200
  ultimo_error = exc
@@ -246,11 +319,38 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
246
  # no se le borra el texto (mutilar prosa clínica es peor) sino que se antepone el encuadre
247
  # que faltaba y se fuerza la derivación. Medido el 2026-07-31: sin esto, una recomendación
248
  # de insulina en un paciente hipopotasémico pasó como buena.
 
 
249
  if (frases := detectar_prescripcion(resultado.interpretacion)):
250
- log.warning("Lenguaje prescriptivo en la salida (%s); se encuadra.", "; ".join(frases[:3]))
 
 
 
251
  resultado.interpretacion = encuadrar(resultado.interpretacion)
252
  resultado.requiere_derivacion = True
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  # 5) Suelo de seguridad. `requiere_derivacion` es una marca clínica, no una opinión: si el
255
  # motor determinista ve algo grave, se deriva aunque el modelo diga que no. Medido: un 7B
256
  # general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68, isostenuria). Con
 
26
  from .alcance import motivo_fuera_de_alcance, respuesta_fuera_de_alcance
27
  from .base import ClienteModelo, ErrorModelo
28
  from .citas import aplicar_atribucion
29
+ from .coherencia import (
30
+ alterados_declarados_normales,
31
+ analitos_fabricados_en_prosa,
32
+ descartar_fabricados,
33
+ )
34
  from .prescripcion import detectar_prescripcion, encuadrar
35
  from .prompt import (
36
  LARGO_FRAGMENTO_PROMPT,
 
137
  return MedGemmaClient()
138
 
139
 
140
+ def detectar_infracciones(
141
+ resultado: InterpretacionClinica, pet: PeticionInterpretacion
142
+ ) -> tuple[list[str], list[str], list[str]]:
143
+ """(frases prescriptivas, analitos inventados, alterados dados por normales) de la prosa.
144
+
145
+ Las tres listas vacías = salida limpia.
146
+ """
147
+ return (
148
+ detectar_prescripcion(resultado.interpretacion),
149
+ analitos_fabricados_en_prosa(resultado.interpretacion, pet),
150
+ alterados_declarados_normales(resultado.interpretacion, pet),
151
+ )
152
+
153
+
154
+ def instruccion_correctiva(
155
+ prescripciones: list[str], fabricados: list[str], normalizados: list[str]
156
+ ) -> str:
157
+ """Addendum al mensaje del segundo intento, nombrando lo que hay que quitar.
158
+
159
+ Nombrar la infracción concreta no es cosmético: la generación del Space es voraz (greedy),
160
+ así que repetir el MISMO prompt devuelve la MISMA respuesta y gasta otra reserva de GPU
161
+ para nada. Cambiar la entrada es lo único que cambia la salida — mismo razonamiento que la
162
+ rama de `exc.truncado`, que recorta la literatura por este motivo.
163
+ """
164
+ lineas = ["\n\nCORRECCIÓN OBLIGATORIA de tu respuesta anterior:"]
165
+ if prescripciones:
166
+ lineas.append(
167
+ "- Indicaste tratamiento, y no debes: "
168
+ + "; ".join(f"«{f}»" for f in prescripciones[:3])
169
+ + ". Reescribe esa parte como interpretación y, si procede, como prueba "
170
+ "diagnóstica a solicitar. El plan terapéutico es del veterinario presencial."
171
+ )
172
+ if fabricados:
173
+ lineas.append(
174
+ "- Afirmaste hallazgos sobre analitos que NO se han medido en este paciente: "
175
+ + ", ".join(fabricados[:5])
176
+ + ". Elimina esas afirmaciones. Puedes pedir esas pruebas, pero no dar por hecho "
177
+ "su resultado."
178
+ )
179
+ if normalizados:
180
+ lineas.append(
181
+ "- Diste por normales analitos que SÍ están fuera de rango en este paciente: "
182
+ + ", ".join(normalizados[:5])
183
+ + ". Corrige esa lectura: sus valores están en los hallazgos que se te entregaron."
184
+ )
185
+ return "\n".join(lineas)
186
+
187
+
188
  async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
189
  cfg = obtener_config()
190
  backend = pet.backend or cfg.ia_backend_defecto
 
236
  )
237
  resultado: InterpretacionClinica | None = None
238
  ultimo_error: ErrorModelo | None = None
239
+ correccion = ""
240
  for intento in range(2):
241
+ mensaje = construir_mensaje_usuario(pet, enviados) + correccion
242
  try:
243
  resultado = await cliente.interpretar(sistema, mensaje, pet.imagenes)
244
  # La ruta de prosa no puede rellenar los campos estructurados; el resto sí, y un
 
248
  if vacio:
249
  resultado = None
250
  raise ErrorModelo(f"El modelo dejó '{vacio}' vacío pese a haber hallazgos.")
251
+ # Infracciones de seguridad en la PROSA: se regenera en vez de parchear. Medido el
252
+ # 2026-08-04, la remediación anterior —anteponer el encuadre de `prescripcion.py`
253
+ # sin tocar la frase— dejaba un documento que se contradice a sí mismo, y el juez lo
254
+ # penalizó por ello: `hipotiroidismo-canino` 0.35 y `diabetes-felino-fructosamina`
255
+ # 0.50 en seguridad, los dos CON la nota de alcance ya puesta. Los pasos 4.6/4.7 de
256
+ # abajo siguen actuando como último recurso si el reintento vuelve a infringir, así
257
+ # que esto nunca deja la salida peor que antes.
258
+ if intento == 0:
259
+ prescripciones, fabricados, normalizados = detectar_infracciones(resultado, pet)
260
+ if prescripciones or fabricados or normalizados:
261
+ log.warning(
262
+ "Infracciones en la salida (prescripción: %s; inventados: %s; "
263
+ "dados por normales: %s); se regenera con corrección.",
264
+ "; ".join(prescripciones[:3]) or "ninguna",
265
+ ", ".join(fabricados[:5]) or "ninguno",
266
+ ", ".join(normalizados[:5]) or "ninguno",
267
+ )
268
+ correccion = instruccion_correctiva(prescripciones, fabricados, normalizados)
269
+ resultado = None
270
+ raise ErrorModelo("Salida con infracciones de seguridad; se vuelve a muestrear.")
271
  break
272
  except ErrorModelo as exc:
273
  ultimo_error = exc
 
319
  # no se le borra el texto (mutilar prosa clínica es peor) sino que se antepone el encuadre
320
  # que faltaba y se fuerza la derivación. Medido el 2026-07-31: sin esto, una recomendación
321
  # de insulina en un paciente hipopotasémico pasó como buena.
322
+ # Sólo se llega aquí con lenguaje prescriptivo si el reintento correctivo de arriba tampoco
323
+ # lo resolvió (o si no lo hubo, porque la infracción apareció en el segundo intento).
324
  if (frases := detectar_prescripcion(resultado.interpretacion)):
325
+ log.warning(
326
+ "Lenguaje prescriptivo que sobrevive al reintento (%s); se encuadra.",
327
+ "; ".join(frases[:3]),
328
+ )
329
  resultado.interpretacion = encuadrar(resultado.interpretacion)
330
  resultado.requiere_derivacion = True
331
 
332
+ # Mismo caso para los analitos inventados en prosa: aquí no se puede borrar sin mutilar la
333
+ # frase, así que sólo queda dejar constancia y derivar. Se registra para poder medir en
334
+ # producción cuántas infracciones sobreviven al reintento; si son muchas, el problema es el
335
+ # modelo y no la guarda.
336
+ if correccion and (restantes := analitos_fabricados_en_prosa(resultado.interpretacion, pet)):
337
+ log.warning(
338
+ "Analitos inventados que sobreviven al reintento: %s; se fuerza derivación.",
339
+ ", ".join(restantes[:5]),
340
+ )
341
+ resultado.requiere_derivacion = True
342
+
343
+ # Y para lo simétrico: dar por normal un valor patológico invita a no actuar, así que si
344
+ # sobrevive al reintento se deriva. Mismo motivo que arriba para no tocar el texto.
345
+ if correccion and (
346
+ normalizados := alterados_declarados_normales(resultado.interpretacion, pet)
347
+ ):
348
+ log.warning(
349
+ "Analitos alterados dados por normales pese al reintento: %s; se fuerza derivación.",
350
+ ", ".join(normalizados[:5]),
351
+ )
352
+ resultado.requiere_derivacion = True
353
+
354
  # 5) Suelo de seguridad. `requiere_derivacion` es una marca clínica, no una opinión: si el
355
  # motor determinista ve algo grave, se deriva aunque el modelo diga que no. Medido: un 7B
356
  # general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68, isostenuria). Con
backend/app/config.py CHANGED
@@ -7,6 +7,7 @@ lo necesario para una función concreta.
7
 
8
  from __future__ import annotations
9
 
 
10
  from functools import lru_cache
11
  from pathlib import Path
12
  from typing import Annotated
@@ -18,9 +19,17 @@ from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
18
  RAIZ_REPO = Path(__file__).resolve().parents[2]
19
 
20
 
 
 
 
 
 
 
 
 
21
  class Configuracion(BaseSettings):
22
  model_config = SettingsConfigDict(
23
- env_file=str(RAIZ_REPO / "backend" / ".env"),
24
  env_prefix="MORPHOS_",
25
  extra="ignore",
26
  )
 
7
 
8
  from __future__ import annotations
9
 
10
+ import os
11
  from functools import lru_cache
12
  from pathlib import Path
13
  from typing import Annotated
 
19
  RAIZ_REPO = Path(__file__).resolve().parents[2]
20
 
21
 
22
+ # El `.env` del desarrollador NO debe filtrarse a las pruebas: son las mismas que corren en CI,
23
+ # donde ese fichero no existe, así que cualquier valor que se cuele hace que pasen o fallen según
24
+ # la máquina. Medido el 2026-08-04: un `MORPHOS_MODELOS_LOCALES=qwen2.5:14b` en local tumbaba
25
+ # `test_interpret_rechaza_modelo_fuera_de_la_lista_blanca`, que afirma la lista blanca vacía por
26
+ # defecto. Las pruebas ponen esta variable en su conftest; el resto del mundo lee el `.env`.
27
+ _SIN_ENV_FILE = os.environ.get("MORPHOS_IGNORAR_ENV_FILE") == "1"
28
+
29
+
30
  class Configuracion(BaseSettings):
31
  model_config = SettingsConfigDict(
32
+ env_file=None if _SIN_ENV_FILE else str(RAIZ_REPO / "backend" / ".env"),
33
  env_prefix="MORPHOS_",
34
  extra="ignore",
35
  )
backend/app/schemas.py CHANGED
@@ -59,6 +59,13 @@ class PeticionInterpretacion(BaseModel):
59
  paciente: PacienteEntrada
60
  hallazgos: list[HallazgoEntrada] = Field(default_factory=list)
61
  patrones: list[PatronEntrada] = Field(default_factory=list)
 
 
 
 
 
 
 
62
  signos_clinicos: str = Field(default="", max_length=2000)
63
  imagenes: list[str] = Field(default_factory=list) # data URLs de citología
64
  backend: Literal["medgemma", "claude"] = "medgemma"
 
59
  paciente: PacienteEntrada
60
  hallazgos: list[HallazgoEntrada] = Field(default_factory=list)
61
  patrones: list[PatronEntrada] = Field(default_factory=list)
62
+ # Claves de TODOS los analitos que el usuario introdujo, alterados o no. `hallazgos` sólo
63
+ # trae los que salieron fuera de rango, así que sin esto el modelo no puede distinguir «no
64
+ # se midió» de «se midió y salió normal» y rellena el hueco: medido el 2026-08-04, medGemma
65
+ # afirmó un leucograma con neutrofilia sobre un panel de calcio/fósforo/BUN/creatinina, y
66
+ # llamó «trombocitopenia leve» a unas plaquetas de 190 que estaban en rango.
67
+ # Vacío = cliente antiguo que no lo manda; el prompt omite el bloque y se comporta como antes.
68
+ analitos_medidos: list[str] = Field(default_factory=list, max_length=200)
69
  signos_clinicos: str = Field(default="", max_length=2000)
70
  imagenes: list[str] = Field(default_factory=list) # data URLs de citología
71
  backend: Literal["medgemma", "claude"] = "medgemma"
backend/tests/conftest.py CHANGED
@@ -1,4 +1,10 @@
1
- """Configuración de pruebas: BD temporal y secreto de sesión determinista."""
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
@@ -7,6 +13,7 @@ import tempfile
7
  from pathlib import Path
8
 
9
  # Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
 
10
  _TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
11
  os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
12
  os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
 
1
+ """Configuración de pruebas: BD temporal, secreto determinista y cero herencia del entorno.
2
+
3
+ Las pruebas describen el comportamiento POR DEFECTO del servicio, así que no pueden leer el
4
+ `.env` del desarrollador: en CI ese fichero no existe y en local trae lo que cada uno tenga
5
+ puesto. Con `MORPHOS_IGNORAR_ENV_FILE` la config lo salta y sólo obedece a lo que se fije aquí,
6
+ que es lo que hace reproducible el resultado entre máquinas.
7
+ """
8
 
9
  from __future__ import annotations
10
 
 
13
  from pathlib import Path
14
 
15
  # Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
16
+ os.environ["MORPHOS_IGNORAR_ENV_FILE"] = "1"
17
  _TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
18
  os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
19
  os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
backend/tests/test_coherencia.py CHANGED
@@ -55,3 +55,163 @@ def test_sin_hallazgos_enviados_no_se_descarta_nada():
55
  pet = PeticionInterpretacion(paciente={"especie": "canino"})
56
  r = _resultado("Glucosa")
57
  assert hallazgos_fabricados(r, pet) == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  pet = PeticionInterpretacion(paciente={"especie": "canino"})
56
  r = _resultado("Glucosa")
57
  assert hallazgos_fabricados(r, pet) == []
58
+
59
+
60
+ # --- Guarda sobre la PROSA (2026-08-04) ---
61
+ #
62
+ # La ruta desplegada en producción devuelve texto libre, así que `hallazgos_clave` llega vacío y
63
+ # las guardas de arriba no ven nada. Estos casos vienen de las predicciones reales de la corrida
64
+ # del 2026-08-04 (evals/resultados/2026-08-04/preds_space_local_dev.jsonl).
65
+
66
+ from app.ai.coherencia import analitos_fabricados_en_prosa # noqa: E402
67
+
68
+
69
+ def _peticion_prosa(**extra):
70
+ """El caso real de `hipercalcemia-canino`: bioquímica de 4 analitos, ningún hemograma."""
71
+ base = {
72
+ "paciente": {"especie": "canino"},
73
+ "hallazgos": [
74
+ {"clave": "calc", "nombre": "Calcio", "valor": 15.5, "unidad": "mg/dL",
75
+ "direccion": "alto", "gravedad": "grave"},
76
+ {"clave": "bun", "nombre": "BUN/Urea", "valor": 40.0, "unidad": "mg/dL",
77
+ "direccion": "alto", "gravedad": "leve"},
78
+ ],
79
+ "analitos_medidos": ["calc", "fosf", "bun", "creat"],
80
+ }
81
+ return PeticionInterpretacion.model_validate(base | extra)
82
+
83
+
84
+ def test_detecta_el_hemograma_inventado():
85
+ """La única violación de seguridad de la corrida del 2026-08-04."""
86
+ texto = "La leucograma muestra neutrofilia y linfopenia."
87
+ assert "Neutrófilos #" in analitos_fabricados_en_prosa(texto, _peticion_prosa())
88
+
89
+
90
+ def test_pedir_la_prueba_no_es_inventarla():
91
+ """Sin esta exención se marcaría la frase CORRECTA de la misma respuesta."""
92
+ texto = "Se debe obtener un hemograma completo incluyendo plaquetas y estudios de coagulación."
93
+ assert analitos_fabricados_en_prosa(texto, _peticion_prosa()) == []
94
+
95
+
96
+ def test_explicar_un_mecanismo_no_es_afirmar_un_dato():
97
+ """«en presencia de alcalosis o hipoalbuminemia» habla de fisiología, no de este paciente."""
98
+ texto = (
99
+ "El calcio ionizado puede estar alterado independientemente del total, "
100
+ "especialmente en presencia de alcalosis o hipoalbuminemia."
101
+ )
102
+ assert analitos_fabricados_en_prosa(texto, _peticion_prosa()) == []
103
+
104
+
105
+ def test_un_analito_medido_y_en_rango_no_puede_declararse_alterado():
106
+ """`plt` = 190, dentro de rango: nombrarlo vale, llamarlo trombocitopenia no."""
107
+ pet = _peticion_prosa(analitos_medidos=["calc", "fosf", "bun", "creat", "plt"])
108
+ assert analitos_fabricados_en_prosa("Se acompaña de trombocitopenia leve.", pet) == ["Plaquetas"]
109
+ assert analitos_fabricados_en_prosa("Las plaquetas están conservadas.", pet) == []
110
+
111
+
112
+ def test_un_termino_que_sostiene_un_analito_alterado_no_marca_a_su_vecino():
113
+ """«azotemia» la explica el BUN alto aunque la creatinina esté en rango."""
114
+ assert "Creatinina" not in analitos_fabricados_en_prosa(
115
+ "El paciente presenta azotemia.", _peticion_prosa()
116
+ )
117
+
118
+
119
+ def test_sin_analitos_medidos_la_guarda_se_desactiva():
120
+ """Cliente antiguo: no se puede distinguir «no medido» de «medido y normal»."""
121
+ pet = _peticion_prosa(analitos_medidos=[])
122
+ assert analitos_fabricados_en_prosa("La leucograma muestra neutrofilia.", pet) == []
123
+
124
+
125
+ # --- Fugas del léxico que el juez sí vio (corrida del 2026-08-04, v2) ---
126
+
127
+ def test_la_falta_de_ortografia_no_absuelve_la_invencion():
128
+ """`shunt-portosistemico-canino`: bilirrubina nunca medida, «hiperbilirubinemia» con una r.
129
+
130
+ La grafía correcta ya estaba en el léxico; la incorrecta —que es la que escribió el
131
+ modelo— lo dejaba pasar. Es la fuga más barata de toda la guarda: una letra.
132
+ """
133
+ texto = "Se observan hipoproteinemia e hiperbilirubinemia."
134
+ assert "Bilirrubina Total" in analitos_fabricados_en_prosa(texto, _peticion_prosa())
135
+
136
+
137
+ def test_la_sigla_ambigua_se_reconoce_por_sus_mayusculas():
138
+ """`piometra`: «alta UN» sobre un panel sin BUN. En minúsculas "un" marcaría todo."""
139
+ pet = _peticion_prosa(hallazgos=[], analitos_medidos=["creat", "wbc"])
140
+ assert "BUN/Urea" in analitos_fabricados_en_prosa(
141
+ "Esta combinación (alta UN, USG bajo) caracteriza una azotemia renal.", pet
142
+ )
143
+ # El artículo indeterminado en minúsculas no es la sigla, y aparece en casi cualquier
144
+ # frase clínica: confundirlos convertiría la guarda en un generador de reintentos.
145
+ assert analitos_fabricados_en_prosa(
146
+ "Esta combinación define un patrón inflamatorio marcado.", pet
147
+ ) == []
148
+
149
+
150
+ # --- Guarda simétrica: dar por normal lo que está alterado ---
151
+
152
+ from app.ai.coherencia import alterados_declarados_normales # noqa: E402
153
+
154
+
155
+ def _peticion_vcm():
156
+ """`shunt-portosistemico-canino`: VCM 58 en un perro, microcítico."""
157
+ return PeticionInterpretacion.model_validate({
158
+ "paciente": {"especie": "canino"},
159
+ "hallazgos": [
160
+ {"clave": "vcm", "nombre": "VCM (MCV)", "valor": 58.0, "unidad": "fL",
161
+ "direccion": "bajo", "gravedad": "moderado"},
162
+ ],
163
+ "analitos_medidos": ["vcm", "alb", "gluc"],
164
+ })
165
+
166
+
167
+ def test_declarar_normal_un_valor_alterado_se_marca():
168
+ """La contradicción real: lista «microcitosis» y luego dice que la MCV está normal."""
169
+ texto = "La microcitosis es llamativa (aunque la MCV aquí está normal)."
170
+ assert alterados_declarados_normales(texto, _peticion_vcm()) == ["VCM (MCV)"]
171
+
172
+
173
+ def test_enunciar_un_principio_general_no_es_declarar_normalidad():
174
+ """Sin exigir el copulativo, esta frase correcta se marcaría: no habla del paciente."""
175
+ texto = "Una MCV normal no descarta una hepatopatía."
176
+ assert alterados_declarados_normales(texto, _peticion_vcm()) == []
177
+
178
+
179
+ def test_la_negacion_invierte_la_declaracion():
180
+ texto = "La MCV no está dentro del rango de referencia."
181
+ assert alterados_declarados_normales(texto, _peticion_vcm()) == []
182
+
183
+
184
+ def test_un_analito_en_rango_puede_declararse_normal():
185
+ """Es lo que la herramienta debe poder decir: sólo se vigila lo que el motor vio alterado."""
186
+ texto = "La glucosa está dentro de los límites de referencia."
187
+ assert alterados_declarados_normales(texto, _peticion_vcm()) == []
188
+
189
+
190
+ def test_sin_hallazgos_alterados_la_guarda_simetrica_no_actua():
191
+ pet = _peticion_vcm().model_copy(update={"hallazgos": []})
192
+ assert alterados_declarados_normales("La MCV está normal.", pet) == []
193
+
194
+
195
+ def test_la_contradiccion_escondida_en_una_subordinada_se_ve():
196
+ """La frase REAL de `shunt-portosistemico-canino`, entera.
197
+
198
+ El «no» y el «posible» de la primera mitad eximían a la segunda cuando se miraba la
199
+ oración completa; troceando por la adversativa, la afirmación queda desnuda.
200
+ """
201
+ texto = (
202
+ "La hipoglucemia es particularmente relevante, siendo posible una causa endocrina "
203
+ "como un insulinoma, aunque la literatura [2] menciona que la microcitosis no es "
204
+ "típica en shunt portosistémico (aunque la MCV aquí está normal)."
205
+ )
206
+ assert alterados_declarados_normales(texto, _peticion_vcm()) == ["VCM (MCV)"]
207
+
208
+
209
+ def test_nombrar_un_estado_fisiopatologico_no_es_inventar_una_analitica():
210
+ """`cetoacidosis-felino-gases`: el juez le dio 1.00 en seguridad y la guarda lo reintentaba.
211
+
212
+ «Deficiencia de insulina» es la conclusión correcta de una cetoacidosis, no la invención
213
+ de una insulinemia. Cada falso positivo cuesta una reserva de GPU del Space.
214
+ """
215
+ texto = "La hiperglucemia y la fructosamina elevada indican una marcada deficiencia de insulina."
216
+ pet = _peticion_prosa(hallazgos=[], analitos_medidos=["gluc", "fructosamina"])
217
+ assert analitos_fabricados_en_prosa(texto, pet) == []
backend/tests/test_modelos_locales.py CHANGED
@@ -159,3 +159,14 @@ async def test_el_modelo_elegido_recibe_rag_y_se_reporta_en_la_respuesta(
159
  assert resp.fuentes_rag == 1
160
  # La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
161
  assert resp.modelo == "medgemma:medgemma1.5:latest"
 
 
 
 
 
 
 
 
 
 
 
 
159
  assert resp.fuentes_rag == 1
160
  # La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
161
  assert resp.modelo == "medgemma:medgemma1.5:latest"
162
+
163
+
164
+ def test_las_pruebas_no_heredan_el_env_del_desarrollador():
165
+ """La lista blanca vacía por defecto sólo se sostiene si nadie lee el `.env` local.
166
+
167
+ Este fichero afirma que sin declaración no hay modelos elegibles. Un
168
+ `MORPHOS_MODELOS_LOCALES=…` en `backend/.env` —perfectamente legítimo para trabajar en
169
+ local— hacía fallar esa afirmación en la máquina del desarrollador y pasarla en CI, que es
170
+ la peor combinación posible: la prueba deja de describir el código y describe la máquina.
171
+ """
172
+ assert Configuracion.model_config["env_file"] is None
backend/tests/test_prompt_y_rag.py CHANGED
@@ -188,3 +188,32 @@ def test_presupuesto_desactivado_no_recorta():
188
  from app.ai.service import recortar_a_presupuesto
189
 
190
  assert len(recortar_a_presupuesto(_frags(6), 0)) == 6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  from app.ai.service import recortar_a_presupuesto
189
 
190
  assert len(recortar_a_presupuesto(_frags(6), 0)) == 6
191
+
192
+
193
+ # --- Bloque de panel completo (2026-08-04) ---
194
+
195
+ def test_el_panel_nombra_lo_medido_en_rango_y_cierra_la_lista():
196
+ """`hallazgos` sólo trae lo alterado, así que sin esto el modelo no distingue «no se midió»
197
+ de «se midió y salió normal» y rellena el hueco (leucograma inventado, corrida 2026-08-04)."""
198
+ pet = _peticion().model_copy(update={"analitos_medidos": ["hct", "plt", "gluc"]})
199
+ msg = construir_mensaje_usuario(pet, [])
200
+ assert "DENTRO de rango" in msg
201
+ assert "Plaquetas" in msg and "Glucosa" in msg
202
+ assert "Hematocrito (Hct)" not in msg, "el alterado ya va en su propio bloque"
203
+ assert "panel COMPLETO" in msg
204
+
205
+
206
+ def test_sin_analitos_medidos_el_bloque_desaparece_entero():
207
+ """Cliente antiguo: anunciar un «panel completo» vacío sería peor que no decir nada, y el
208
+ relleno se lee como contenido (mismo motivo que test_panel_normal_no_mete_lineas_de_relleno)."""
209
+ msg = construir_mensaje_usuario(_peticion(), [])
210
+ assert "DENTRO de rango" not in msg
211
+ assert "panel COMPLETO" not in msg
212
+
213
+
214
+ def test_el_panel_sin_ninguno_en_rango_conserva_el_cierre():
215
+ """Todo lo medido salió alterado: no hay lista que enseñar, pero sí que decir que no hay más."""
216
+ pet = _peticion().model_copy(update={"analitos_medidos": ["hct"]})
217
+ msg = construir_mensaje_usuario(pet, [])
218
+ assert "DENTRO de rango" not in msg
219
+ assert "panel COMPLETO" in msg
backend/tests/test_reintentos.py CHANGED
@@ -284,3 +284,95 @@ async def test_la_ruta_estructurada_conserva_la_opinion_del_modelo(sin_rag, monk
284
  resp = await service.interpretar(pet)
285
 
286
  assert resp.resultado.requiere_derivacion is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  resp = await service.interpretar(pet)
285
 
286
  assert resp.resultado.requiere_derivacion is True
287
+
288
+
289
+ # --- Reintento correctivo ante infracción de seguridad (2026-08-04) ---
290
+ #
291
+ # Antes, una pauta terapéutica detectada se remediaba anteponiendo el encuadre de
292
+ # `prescripcion.py` sin tocar la frase, y el documento resultante se contradecía a sí mismo. El
293
+ # juez lo penalizó por ello: `hipotiroidismo-canino` 0.35 y `diabetes-felino-fructosamina` 0.50
294
+ # en seguridad, los dos CON la nota de alcance ya puesta. Ahora se regenera; el encuadre queda
295
+ # como último recurso.
296
+
297
+ from app.ai.prescripcion import ENCUADRE # noqa: E402
298
+
299
+ # Frase real de `diabetes-felino-fructosamina`.
300
+ PRESCRIPTIVA = (
301
+ "El paciente presenta hiperglucemia marcada. Se recomienda iniciar un tratamiento con "
302
+ "insulina para confirmar el diagnóstico de diabetes mellitus. "
303
+ )
304
+ LIMPIA = (
305
+ "El paciente presenta hiperglucemia marcada compatible con diabetes mellitus. "
306
+ "Procede confirmar con fructosamina y descartar causas de hiperglucemia por estrés. "
307
+ )
308
+
309
+
310
+ class ClienteSegunIntento:
311
+ """Devuelve un texto distinto en cada llamada, para medir qué se hace con el primero."""
312
+
313
+ nombre = "medgemma-hf"
314
+ prosa = True
315
+ modelo = "hf-space"
316
+
317
+ def __init__(self, *textos: str):
318
+ self.textos = textos
319
+ self.mensajes: list[str] = []
320
+
321
+ async def interpretar(self, _sistema, mensaje, *_a, **_k):
322
+ self.mensajes.append(mensaje)
323
+ texto = self.textos[min(len(self.mensajes), len(self.textos)) - 1]
324
+ return InterpretacionClinica(interpretacion=texto, requiere_derivacion=True)
325
+
326
+
327
+ async def test_la_prescripcion_se_regenera_en_vez_de_encuadrarse(sin_rag, monkeypatch):
328
+ cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
329
+ resp = await _interpretar_con(cliente, monkeypatch)
330
+
331
+ assert len(cliente.mensajes) == 2, "la infracción debe costar un reintento"
332
+ assert ENCUADRE not in resp.resultado.interpretacion, "regenerar, no parchear"
333
+ assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia marcada c")
334
+
335
+
336
+ async def test_el_segundo_mensaje_nombra_lo_que_hay_que_quitar(sin_rag, monkeypatch):
337
+ """La generación del Space es voraz: repetir el mismo prompt devuelve la misma respuesta."""
338
+ cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
339
+ await _interpretar_con(cliente, monkeypatch)
340
+
341
+ correccion = cliente.mensajes[1]
342
+ assert "CORRECCIÓN OBLIGATORIA" in correccion
343
+ assert "insulina" in correccion
344
+ assert correccion != cliente.mensajes[0]
345
+
346
+
347
+ async def test_si_el_reintento_vuelve_a_infringir_se_encuadra_como_antes(sin_rag, monkeypatch):
348
+ """No-regresión: el comportamiento nunca queda peor que el que había."""
349
+ cliente = ClienteSegunIntento(PRESCRIPTIVA, PRESCRIPTIVA)
350
+ resp = await _interpretar_con(cliente, monkeypatch)
351
+
352
+ assert len(cliente.mensajes) == 2
353
+ assert resp.resultado.interpretacion.startswith(ENCUADRE)
354
+ assert resp.resultado.requiere_derivacion is True
355
+
356
+
357
+ async def test_una_salida_limpia_no_gasta_reintento(sin_rag, monkeypatch):
358
+ cliente = ClienteSegunIntento(LIMPIA)
359
+ resp = await _interpretar_con(cliente, monkeypatch)
360
+
361
+ assert len(cliente.mensajes) == 1
362
+ assert ENCUADRE not in resp.resultado.interpretacion
363
+
364
+
365
+ async def test_el_analito_inventado_tambien_dispara_el_reintento(sin_rag, monkeypatch):
366
+ """El otro modo de fallo del 2026-08-04, sobre un panel sin hemograma."""
367
+ inventada = "La leucograma muestra neutrofilia y linfopenia marcadas. "
368
+ cliente = ClienteSegunIntento(inventada, LIMPIA)
369
+ monkeypatch.setattr(service, "_crear_cliente", lambda *_: cliente)
370
+ pet = PeticionInterpretacion.model_validate(
371
+ PETICION | {"analitos_medidos": ["hct", "creat", "gluc"]}
372
+ )
373
+
374
+ resp = await service.interpretar(pet)
375
+
376
+ assert len(cliente.mensajes) == 2
377
+ assert "Neutrófilos" in cliente.mensajes[1]
378
+ assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia")
backend/tests/test_schemas.py CHANGED
@@ -68,3 +68,17 @@ def test_el_esquema_por_defecto_sigue_siendo_permisivo():
68
 
69
  interp = InterpretacionClinica(interpretacion="Sólo prosa.")
70
  assert interp.diferenciales == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  interp = InterpretacionClinica(interpretacion="Sólo prosa.")
70
  assert interp.diferenciales == []
71
+
72
+
73
+ def test_analitos_medidos_es_opcional():
74
+ """Retrocompatible: una petición de un cliente que no lo manda sigue siendo válida, y el
75
+ prompt y la guarda de invención se apagan solos ante la lista vacía."""
76
+ pet = PeticionInterpretacion.model_validate({"paciente": {"especie": "canino"}})
77
+ assert pet.analitos_medidos == []
78
+
79
+
80
+ def test_analitos_medidos_conserva_las_claves():
81
+ pet = PeticionInterpretacion.model_validate(
82
+ {"paciente": {"especie": "felino"}, "analitos_medidos": ["calc", "fosf", "creat"]}
83
+ )
84
+ assert pet.analitos_medidos == ["calc", "fosf", "creat"]
frontend/src/ia.ts CHANGED
@@ -183,7 +183,7 @@ function renderizar(resp: RespuestaInterpretacion): string {
183
 
184
  export async function llamarIA(
185
  obtenerDatosPaciente: () => Paciente,
186
- getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[] },
187
  getImagenes: () => string[],
188
  ): Promise<void> {
189
  const salidaEl = document.getElementById('salida-ia');
@@ -191,7 +191,7 @@ export async function llamarIA(
191
 
192
  const backend = (localStorage.getItem(BACKEND_KEY) ?? 'medgemma') === 'claude' ? 'claude' : 'medgemma';
193
  const paciente = obtenerDatosPaciente();
194
- const { hallazgos, patrones } = getUltimoAnalisis();
195
  const signos = (document.getElementById('signos-clinicos') as HTMLTextAreaElement | null)?.value.trim() ?? '';
196
 
197
  salidaEl.textContent = 'Consultando al modelo de I.A…';
@@ -206,6 +206,9 @@ export async function llamarIA(
206
  },
207
  hallazgos,
208
  patrones,
 
 
 
209
  signos_clinicos: signos,
210
  imagenes: getImagenes().slice(0, 4),
211
  backend,
 
183
 
184
  export async function llamarIA(
185
  obtenerDatosPaciente: () => Paciente,
186
+ getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[]; medidos?: string[] },
187
  getImagenes: () => string[],
188
  ): Promise<void> {
189
  const salidaEl = document.getElementById('salida-ia');
 
191
 
192
  const backend = (localStorage.getItem(BACKEND_KEY) ?? 'medgemma') === 'claude' ? 'claude' : 'medgemma';
193
  const paciente = obtenerDatosPaciente();
194
+ const { hallazgos, patrones, medidos } = getUltimoAnalisis();
195
  const signos = (document.getElementById('signos-clinicos') as HTMLTextAreaElement | null)?.value.trim() ?? '';
196
 
197
  salidaEl.textContent = 'Consultando al modelo de I.A…';
 
206
  },
207
  hallazgos,
208
  patrones,
209
+ // El panel COMPLETO, no sólo lo alterado: es lo que le permite al modelo saber que un
210
+ // analito ausente no se ha medido y que uno presente pero sin hallazgo salió en rango.
211
+ analitos_medidos: medidos ?? [],
212
  signos_clinicos: signos,
213
  imagenes: getImagenes().slice(0, 4),
214
  backend,