File size: 26,254 Bytes
70e641d
 
 
 
 
 
 
 
 
1339cdc
61cd0db
7dba92c
70e641d
 
6ab7946
70e641d
 
6ab7946
70e641d
61cd0db
 
70e641d
 
 
b90d0d2
 
 
1339cdc
 
 
 
 
b90d0d2
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
7dba92c
 
 
 
 
 
 
 
70e641d
 
7dba92c
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61cd0db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
b90d0d2
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
bf9f7d1
 
 
 
6ab7946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
bf9f7d1
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
6ab7946
 
 
 
 
bf9f7d1
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
5118fc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
a5d73ff
 
 
 
70e641d
 
 
 
 
 
 
 
6ab7946
 
 
70e641d
 
61cd0db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
61cd0db
70e641d
6ab7946
 
 
 
 
 
 
70e641d
6ab7946
 
 
 
 
 
 
 
 
70e641d
 
6ab7946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1339cdc
 
 
 
 
 
 
 
 
 
61cd0db
 
1339cdc
61cd0db
 
 
 
 
 
 
1339cdc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61cd0db
 
 
 
 
 
 
 
 
 
 
 
 
 
5118fc3
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61cd0db
70e641d
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
"""Configuración central del backend.

Todos los secretos y rutas se leen de variables de entorno (o de un .env fuera del
webroot). No hay credenciales por defecto: el servicio falla de forma segura si falta
lo necesario para una función concreta.
"""

from __future__ import annotations

import hmac
import logging
import os
from functools import lru_cache
from pathlib import Path
from typing import Annotated

from pydantic import AliasChoices, Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

log = logging.getLogger(__name__)

# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
RAIZ_REPO = Path(__file__).resolve().parents[2]

# Punto de montaje del almacenamiento persistente en HF Spaces.
VOLUMEN_PERSISTENTE = Path("/data")

# Clínica a la que pertenecen los dispositivos y usuarios que no declaran otra. Un despliegue de
# una sola clínica —el caso normal— se queda entero aquí y no nota el cambio; el aislamiento
# aparece en cuanto se declaran tenants distintos.
TENANT_POR_DEFECTO = "principal"


def _ruta_db_por_defecto() -> Path:
    """Volumen persistente si lo hay; si no, `instance/` (efímero) con aviso al arrancar.

    En HF Spaces `instance/` se pierde en cada reinicio y con él TODAS las cuentas. Si el Space
    tiene almacenamiento persistente contratado, se monta en /data y ahí sí sobreviven. Se elige
    solo en vez de exigir configuración porque el fallo del defecto anterior era silencioso: la
    app arrancaba igual y el problema sólo se veía cuando los usuarios ya no podían entrar.
    Siempre se puede forzar con MORPHOS_DB_PATH.
    """
    if VOLUMEN_PERSISTENTE.is_dir() and os.access(VOLUMEN_PERSISTENTE, os.W_OK):
        return VOLUMEN_PERSISTENTE / "morphos.db"
    return RAIZ_REPO / "instance" / "morphos.db"


# El `.env` del desarrollador NO debe filtrarse a las pruebas: son las mismas que corren en CI,
# donde ese fichero no existe, así que cualquier valor que se cuele hace que pasen o fallen según
# la máquina. Medido el 2026-08-04: un `MORPHOS_MODELOS_LOCALES=qwen2.5:14b` en local tumbaba
# `test_interpret_rechaza_modelo_fuera_de_la_lista_blanca`, que afirma la lista blanca vacía por
# defecto. Las pruebas ponen esta variable en su conftest; el resto del mundo lee el `.env`.
_SIN_ENV_FILE = os.environ.get("MORPHOS_IGNORAR_ENV_FILE") == "1"


class Configuracion(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=None if _SIN_ENV_FILE else str(RAIZ_REPO / "backend" / ".env"),
        env_prefix="MORPHOS_",
        extra="ignore",
    )

    # --- Entorno ---
    entorno: str = Field(default="dev", description="dev | prod")

    # --- CORS / orígenes permitidos (bloqueado, no '*') ---
    origenes_permitidos: list[str] = Field(
        default_factory=lambda: ["http://localhost:8000", "http://127.0.0.1:8000"]
    )

    # --- Sesión ---
    session_secret: str = Field(default="")  # obligatorio en prod; validado al arrancar
    cookie_secure: bool = Field(default=False)  # True en prod (HTTPS)
    session_max_age_s: int = Field(default=60 * 60 * 8)

    # --- Alta de cuentas ---
    # El alta era ABIERTA: cualquiera podía POSTear /api/auth/registro y alcanzar
    # /api/interpret, que gasta cuota de ZeroGPU compartida y, por la ruta Claude, dinero real.
    # El techo por usuario (`limite_interpret_usuario`) protege una identidad que costaba una
    # petición HTTP acuñar, así que no era un techo.
    #
    # Por defecto CERRADA con lista blanca de emails. La lista (y no un simple booleano) es
    # deliberada: `instance/` es efímero en Spaces, así que las cuentas desaparecen en cada
    # reinicio. Con `registro_abierto=False` y sin lista, tras un reinicio no habría forma de
    # crear ninguna cuenta y la app quedaría inservible; con lista, los aprobados se vuelven a
    # dar de alta solos. Cuando los usuarios vivan en almacenamiento persistente, la lista pasa
    # a ser sólo el control de admisión.
    registro_abierto: bool = Field(default=False)
    registro_allowlist: Annotated[list[str], NoDecode] = Field(default_factory=list)

    # --- Base de datos (usuarios). Ruta fuera del webroot. ---
    # Se prefiere el volumen persistente si existe (ver `_ruta_db_por_defecto`). `instance/` NO
    # sobrevive a un reinicio en Spaces: allí cada rebuild se llevaba por delante las cuentas,
    # los hashes y el historial de throttling, y los usuarios tenían que volver a registrarse.
    db_path: Path = Field(default_factory=lambda: _ruta_db_por_defecto())

    # NO hay soporte de MySQL. Existían `mysql_dsn`/`mysql_user`/`mysql_password` con el
    # comentario «si se define, se usa en vez de SQLite», pero NADA en el código los leía: quien
    # los configurara seguiría sobre SQLite sin enterarse. Se eliminan en vez de dejarlos: una
    # opción de configuración que miente es peor que no tenerla. Para sacar los usuarios de
    # SQLite, el camino es un volumen persistente (MORPHOS_DB_PATH) o portar `db.py`.

    # --- Ruta IA por defecto y proveedores ---
    ia_backend_defecto: str = Field(default="medgemma")  # medgemma | claude

    # medGemma auto-alojado. Por defecto se usa el HF Space (Gradio) donde está alojado
    # medGemma; si se vacía `hf_space_url`, la ruta 'medgemma' cae a Ollama en `medgemma_base_url`.
    medgemma_base_url: str = Field(default="http://localhost:11434")
    medgemma_model: str = Field(default="medgemma:latest")
    # La PRIMERA petición a Ollama carga el modelo en memoria, y eso domina el tiempo: un 14B
    # cuantizado tarda minutos en frío y luego responde en segundos. Con 120 s la primera
    # llamada se caía por timeout y las evals lo veían como "no se pudo conectar".
    medgemma_timeout_s: int = Field(default=300)
    # Modelos locales que el usuario puede ELEGIR desde la UI. Lista blanca cerrada: vacía por
    # defecto, lo que deja el selector oculto y el comportamiento de siempre (la ruta 'medgemma'
    # la decide el servidor). Formato de cada entrada: `nombre[=prosa|=estructurado]`.
    #
    #   MORPHOS_MODELOS_LOCALES="medgemma1.5:latest, qwen2.5:7b=prosa"
    #
    # Por qué una lista blanca y no un campo de texto libre: el nombre viaja del navegador al
    # servidor y de ahí a Ollama, así que un campo libre deja al cliente decidir qué pesos se
    # descargan en la máquina que aloja el servicio. Y por eso NO hay campo de URL: la base_url
    # se queda en `medgemma_base_url`, del lado servidor. Aceptar una URL del cliente convierte
    # /api/interpret en un SSRF (el servidor haría peticiones a donde diga el navegador).
    #
    # El sufijo declara si el modelo sabe emitir salida ESTRUCTURADA (decodificación restringida
    # por JSON Schema) o hay que pedirle prosa y envolverla. No se infiere: qwen2.5:7b acepta el
    # `format` de Ollama y devuelve JSON válido con `hallazgos_clave`, `diferenciales` y
    # `siguientes_pruebas` VACÍOS, que valida el esquema y deja al veterinario sin lo que vino a
    # buscar. Por defecto se asume `estructurado`, que es lo que hace medGemma.
    #
    # Sólo tiene sentido donde el servicio tiene un Ollama alcanzable: "local" es local al
    # SERVIDOR, no al navegador. En el HF Space se deja vacía.
    # `NoDecode`: sin él, la fuente de entorno intenta json.loads() del valor ANTES de que corra
    # `_dividir_lista` y la forma separada por comas revienta el arranque con SettingsError.
    modelos_locales: Annotated[list[str], NoDecode] = Field(default_factory=list)
    hf_space_url: str = Field(default="https://blackmistcode-morphos-medgemma.hf.space/gradio_api")
    # Salida ESTRUCTURADA del Space: se le manda el JSON Schema de InterpretacionClinica y el
    # Space restringe la decodificación a producirlo (como `format` en Ollama). Es la corrección
    # de raíz de la ruta de prosa —de ella salen los campos estructurados vacíos, la cobertura
    # medida sobre texto, la atribución reconstruida a mano y buena parte de la fragilidad al
    # prompt—, pero exige que el Space tenga `lm-format-enforcer` y activa el salto de
    # razonamiento (la restricción aplica desde el primer token). OFF hasta medirlo contra la
    # puerta: cambia de golpe el system prompt, el contrato del cliente y cómo se mide la
    # cobertura, así que no entra sin A/B.
    hf_space_estructurado: bool = Field(default=False)
    # Acepta tanto MORPHOS_HF_API_KEY como el HF_API_KEY sin prefijo (convención heredada
    # del proxy PHP), para no obligar a renombrar la variable en .env.
    hf_api_key: str = Field(
        default="",
        validation_alias=AliasChoices("MORPHOS_HF_API_KEY", "HF_API_KEY"),
    )

    # Claude (ruta híbrida opcional + juez de evals).
    # Opus 5 es el modelo por defecto recomendado. NO usar Fable 5 aquí: (a) cuesta el doble
    # ($10/$50 vs $5/$25 por millón de tokens), (b) exige retención de datos de 30 días — no
    # está disponible con retención cero, lo que choca con el posicionamiento de privacidad de
    # esta app, y (c) sus clasificadores de seguridad apuntan a biología de investigación y
    # pueden dar falsos positivos en trabajo clínico/biológico benigno, devolviendo
    # `stop_reason="refusal"` en una interpretación veterinaria legítima.
    anthropic_api_key: str = Field(default="")
    claude_model: str = Field(default="claude-opus-5")

    # --- RAG ---
    # Fuera de cualquier directorio servido: contiene fragmentos de texto de los libros
    # con licencia y no debe ser descargable. Se hornea de sólo lectura en la imagen.
    rag_index_dir: Path = Field(default=RAIZ_REPO / "instance" / "rag_index")
    # Repos privados del Hub. El índice (~70 MB) se publica y se descarga en la build de Docker;
    # los libros con licencia (~226 MB) NUNCA entran ni al repo git ni a la imagen: sólo se leen
    # al reingerir. Ambos deben ser privados: el índice contiene el texto de los libros troceado.
    rag_index_repo: str = Field(default="blackmistcode/morphos-rag-index")
    rag_books_repo: str = Field(default="blackmistcode/morphos-books")
    rag_embed_model: str = Field(default="BAAI/bge-m3")
    rag_top_k: int = Field(default=6)
    # Techo de literatura que se INCLUYE EN EL PROMPT de la ruta de prosa (HF Space), en
    # caracteres. No limita la recuperación (el reranking sigue eligiendo entre `rag_top_k`),
    # sólo cuánto se le enseña al modelo.
    #
    # Por qué existe: medGemma 1.5 razona antes de responder y el Space reparte un único
    # presupuesto de 2048 tokens entre ese razonamiento —que descarta— y la respuesta. Cuanta
    # más literatura entra, más largo es el razonamiento y menos presupuesto queda: con 6
    # fragmentos (~3.600 caracteres) la respuesta se cortaba a mitad de frase en ~220 tokens,
    # con 2 salía completa en ~950. Medido contra el Space el 2026-07-27.
    #
    # No se aplica a las rutas con salida estructurada (Ollama por defecto, Claude): ahí el
    # razonamiento va desactivado o no comparte presupuesto con la respuesta, y más contexto
    # sólo mejora la fundamentación. Sí se aplica a un modelo local declarado `prosa` en
    # `modelos_locales`: es el mismo modo de fallo (un modelo pequeño razonando en voz alta
    # dentro del mismo presupuesto de generación), aunque no se haya medido caso por caso.
    rag_max_chars_prompt: int = Field(default=1800)
    rag_habilitado: bool = Field(default=True)
    # Idioma de la consulta de recuperación. "en" (por defecto) traduce el vocabulario clínico
    # controlado a inglés: el A/B con juez LLM mostró mejor precisión y, sobre todo, mejor
    # rango del primer fragmento relevante (MRR 0.92→1.0) frente a "es" cross-lingual, porque
    # empareja consulta↔corpus (inglés). "es" mantiene el comportamiento cross-lingual con
    # bge-m3. El índice es independiente del idioma de consulta (se traduce en tiempo de query).
    rag_query_lang: str = Field(default="en")
    # Tier 2 — recuperación híbrida + reranking. Se recupera un pozo de candidatos por
    # búsqueda densa (vector) y léxica (BM25/FTS), se fusiona con RRF y se reordena con un
    # cross-encoder multilingüe hasta `rag_top_k`. Degrada con elegancia: sin índice FTS →
    # sólo vectorial; sin el reranker → orden RRF. `bge-reranker-v2-m3` es multilingüe, así
    # que reordena bien aunque la consulta vaya en español y el corpus en inglés.
    rag_hibrido: bool = Field(default=True)
    rag_rerank: bool = Field(default=True)
    rag_candidatos: int = Field(default=30)  # tamaño del pozo antes de reordenar
    rag_reranker_model: str = Field(default="BAAI/bge-reranker-v2-m3")
    # Multi-consulta: en vez de concatenar todos los patrones y hallazgos en UNA cadena —que
    # se embebe en un único vector donde "anemia regenerativa ; azotemia ; hipoalbuminemia" no
    # es ninguno de los tres—, se lanza una consulta por patrón más una agregada de hallazgos
    # y se fusionan por rango con RRF. El pozo de candidatos TOTAL no crece (se reparte entre
    # las consultas), así que el coste de reranking es el mismo. Sin llamadas a ningún modelo
    # generativo: la descomposición la da el motor determinista, que ya sabe qué patrones hay.
    #
    # OFF por defecto: medido el 2026-07-31 con `run_retrieval_eval.py --multiconsulta` sobre
    # los 17 casos dorados, EMPEORA — precision@k 0.81→0.50 y MRR 0.91→0.86, con hit_rate
    # intacto (0.94). Salvedad grande: el único juez disponible sin coste era el heurístico de
    # solape de palabras, que favorece a la consulta concatenada (lleva descripción + analitos
    # + signos, así que sus fragmentos comparten vocabulario con el diagnóstico esperado por
    # construcción) frente a consultas de un solo analito, que traen pasajes mecanísticos con
    # menos solape léxico. Inspeccionados a mano, varios de esos fragmentos eran mejores
    # (p. ej. «Na:K ratio < 27 is diagnostic of hypoadrenocorticism» donde la consulta única
    # traía una tabla de caso). Volver a medir con un juez LLM local (`ollama pull` de un
    # modelo generativo, gratis) antes de decidir; hasta entonces no se cambia el defecto.
    rag_multiconsulta: bool = Field(default=False)
    rag_max_consultas: int = Field(default=4)
    # Cuota de diversidad: preferencia (no límite duro) de fragmentos por libro, para no gastar
    # el presupuesto del prompt en varias páginas del mismo capítulo. Si no hay material de
    # otras fuentes, se rellena igualmente hasta `rag_top_k`. 0 la desactiva.
    rag_max_por_libro: int = Field(default=2)
    # Suelo de relevancia sobre la puntuación del cross-encoder: por debajo, el fragmento se
    # descarta en vez de rellenar `rag_top_k`. Un fragmento flojo gasta presupuesto de prompt e
    # invita a una cita que parece respaldo sin serlo. Por defecto None = desactivado: la escala
    # del reranker son logits sin calibrar y fijar un umbral a ojo puede vaciar la recuperación.
    # Calibrar con `evals/run_retrieval_eval.py` (mirar los scores de los juzgados relevantes)
    # antes de ponerle valor. Sólo se aplica cuando el reranker corrió.
    rag_score_minimo: float | None = Field(default=None)
    # Tier 3 (opcional, OFF por defecto; activar sólo si el A/B de evals muestra que Tier 2
    # se queda corto) — "contextual retrieval" estilo Anthropic: en la ingesta se antepone a
    # cada fragmento una frase de contexto generada con Claude ANTES de embeber (se almacena
    # el texto original; se embebe el enriquecido). Coste: una llamada a Claude por fragmento.
    rag_contextual: bool = Field(default=False)

    # --- Composición del prompt ---
    # Si los patrones del motor determinista se le enseñan al modelo. Ponerlo en False NO los
    # quita de la petición: se siguen usando para construir la consulta de recuperación
    # (`construir_consulta`) y para el suelo de derivación (`_derivacion_obligatoria`), que no
    # dependen del modelo. Sólo deja de mostrárselos, bajo la hipótesis de que un modelo
    # clínico ya deduce la correlación a partir de los valores alterados. Es una hipótesis
    # medible: A/B con `run_evals.py` antes de cambiar el valor por defecto.
    prompt_incluir_patrones: bool = Field(default=True)
    # Si cada hallazgo lleva su etiqueta de gravedad (leve/moderado/grave) en el prompt. La duda
    # es razonable: la gravedad es un JUICIO del motor, no un dato de laboratorio, y medido el
    # 2026-07-31 una sola palabra la mueve entera —cambiar 'moderado' por 'grave' en el Hct de
    # `imha-canino` hizo que el modelo dejara de nombrar la IMHA y alucinara analitos—. La
    # dirección (alto/bajo) sí es objetiva y se mantiene siempre. A/B con `run_evals.py` antes de
    # cambiar el valor por defecto.
    prompt_incluir_gravedad: bool = Field(default=True)

    # --- Límites de subida (citologías) ---
    max_imagenes: int = Field(default=4)
    max_bytes_imagen: int = Field(default=6 * 1024 * 1024)

    # --- Proxy inverso ---
    # Saltos de proxy DE CONFIANZA delante de la app. 0 = no confiar en `X-Forwarded-For`.
    #
    # Por qué existe: el limitador usaba `request.client.host`, que detrás de un proxy (HF
    # Spaces, cualquier CDN) es la dirección del PROXY, no la del cliente. Con eso,
    # `limite_login` (5/minute) y `limite_papers` dejaban de ser por IP y pasaban a ser
    # GLOBALES: a la vez un bypass (fuerza bruta desde muchas IPs no se limitaba por IP) y una
    # auto-denegación de servicio (un cliente ruidoso agotaba el login de todos).
    #
    # Se declara el número de saltos en vez de leer la cabecera a ciegas porque `X-Forwarded-For`
    # la pone el cliente: confiar en ella sin más permite falsificar la IP y saltarse cualquier
    # límite poniendo una distinta en cada petición. Cada proxy AÑADE la dirección de su par, así
    # que con N saltos de confianza el cliente real es el elemento -N de la lista; todo lo que
    # haya a la izquierda lo escribió alguien no confiable y se descarta.
    #
    # En HF Spaces detrás de su router: 1.
    proxy_saltos_confiables: int = Field(default=0)

    # --- Rate limiting ---
    limite_interpret: str = Field(default="10/minute")
    # Techo por USUARIO además del de IP. La cuota de ZeroGPU es por cuenta y compartida entre
    # todos los veterinarios que usan la instancia pública: sin este límite, uno solo puede
    # agotar la capacidad del día. Ajustar según la cuota real del plan.
    limite_interpret_usuario: str = Field(default="20/hour")
    limite_login: str = Field(default="5/minute")
    limite_papers: str = Field(default="30/minute")
    limite_lab_ingesta: str = Field(default="120/minute")  # el analizador puede enviar en ráfaga
    limite_lab_consulta: str = Field(default="60/minute")

    # --- Integración de analizadores de laboratorio ---
    # Claves de API de los puentes locales (dispositivos headless). Autoriza /api/lab/ingesta.
    # Si está vacía, la ingesta queda DESHABILITADA (falla cerrado con 503). Acepta lista JSON
    # o cadena separada por comas en MORPHOS_LAB_API_KEYS (`NoDecode`, ver `modelos_locales`:
    # sin él la forma con comas fallaba al arrancar pese a estar documentada).
    lab_api_keys: Annotated[list[str], NoDecode] = Field(default_factory=list)
    # Persistencia opcional de resultados en SQLite (sólo útil con volumen persistente).
    lab_persistir: bool = Field(default=False)
    # Cola de muestras recibidas (`GET /api/lab/pendientes`). DESACTIVADA por defecto: enumera
    # TODAS las muestras del almacén —que no está segmentado por clínica ni por usuario— y cada
    # `muestra_id` que devuelve abre `GET /api/lab/resultados`, o sea el panel completo de
    # analitos más las pistas de paciente (nombre de la mascota, raza, sexo). Cualquier sesión
    # la podía llamar.
    #
    # Apagarla NO cierra el agujero y no hay que venderlo así: el `muestra_id` lo pone el
    # analizador (el puente sólo lo recorta) y suele ser un correlativo corto, así que
    # `/api/lab/resultados` sigue siendo enumerable a fuerza bruta dentro de
    # `limite_lab_consulta`. Lo que se elimina es el volcado en UNA petición. El cierre real es
    # atar cada resultado a un tenant y filtrar por la sesión (ver ARCHITECTURE_REVIEW §2.1).
    #
    # Se enciende en despliegues de una sola clínica, donde el conjunto de sesiones es el
    # personal invitado. El frontend oculta el botón si el endpoint responde 404.
    lab_pendientes_habilitado: bool = Field(default=False)

    @field_validator("lab_api_keys", "modelos_locales", "registro_allowlist", mode="before")
    @classmethod
    def _dividir_lista(cls, v):
        """Acepta lista JSON o cadena separada por comas.

        El decodificado JSON lo hacía antes la fuente de entorno, pero se ejecutaba ANTES que
        este validador y hacía fallar el arranque con la forma de comas (que es la documentada).
        Con `NoDecode` el valor llega crudo y se decide aquí: JSON si lo parece, comas si no.
        """
        if isinstance(v, str):
            crudo = v.strip()
            if crudo.startswith("["):
                import json

                try:
                    return json.loads(crudo)
                except json.JSONDecodeError:
                    pass
            return [k.strip() for k in crudo.split(",") if k.strip()]
        return v

    def modelos_locales_permitidos(self) -> dict[str, bool]:
        """Lista blanca parseada: nombre del modelo → si hay que pedirle PROSA.

        Se separa por '=' y no por ':' porque el nombre de un modelo de Ollama ya lleva ':'
        (`medgemma1.5:latest`). Un sufijo desconocido se trata como `estructurado`, que es el
        valor por defecto; no se lanza, para que una errata en el .env no impida arrancar el
        servicio entero por un selector opcional.
        """
        permitidos: dict[str, bool] = {}
        for entrada in self.modelos_locales:
            nombre, _, modo = entrada.partition("=")
            nombre = nombre.strip()
            if nombre:
                permitidos[nombre] = modo.strip().lower() == "prosa"
        return permitidos

    def _allowlist_con_tenant(self) -> dict[str, str]:
        """email → tenant. Formato `email` o `email=tenant`; sin sufijo, TENANT_POR_DEFECTO."""
        mapa: dict[str, str] = {}
        for entrada in self.registro_allowlist:
            email, _, tenant = entrada.partition("=")
            email = email.strip().lower()
            if email:
                mapa[email] = tenant.strip() or TENANT_POR_DEFECTO
        return mapa

    def emails_registro_permitidos(self) -> set[str]:
        """Allowlist normalizada (minúsculas, sin espacios) para comparar con el email entrante."""
        return set(self._allowlist_con_tenant())

    def registro_permitido(self, email: str) -> bool:
        """Si este email puede darse de alta."""
        if self.registro_abierto:
            return True
        return email.strip().lower() in self.emails_registro_permitidos()

    def tenant_de_email(self, email: str) -> str:
        """Clínica a la que pertenece un email al darse de alta.

        Con el alta abierta (desarrollo) todo el mundo cae en el tenant por defecto: no hay
        ninguna declaración de la que deducir otra cosa.
        """
        return self._allowlist_con_tenant().get(email.strip().lower(), TENANT_POR_DEFECTO)

    def tenant_de_clave_dispositivo(self, token: str) -> str | None:
        """Tenant dueño de esta API key de dispositivo, o None si no es válida.

        Recorre TODAS las claves con `compare_digest` en vez de indexar un diccionario: un
        lookup por hash sobre un secreto filtra por tiempo si coincide el prefijo, y esta
        comparación es la única barrera de la ingesta.
        """
        encontrado: str | None = None
        for entrada in self.lab_api_keys:
            tenant, sep, clave = entrada.partition(":")
            if not sep:
                tenant, clave = TENANT_POR_DEFECTO, entrada
            if hmac.compare_digest(token, clave.strip()):
                encontrado = tenant.strip() or TENANT_POR_DEFECTO
        return encontrado

    def avisar_de_configuracion(self) -> None:
        """Avisos de arranque que no justifican fallar, pero sí que se vean en el log."""
        if self.registro_abierto:
            log.warning(
                "MORPHOS_REGISTRO_ABIERTO=true: cualquiera puede crear una cuenta y gastar "
                "cuota de modelo. Sólo para desarrollo local."
            )
        elif not self.emails_registro_permitidos():
            # El caso que deja la instancia inservible tras un reinicio con `instance/` efímero.
            log.warning(
                "Alta de cuentas cerrada y MORPHOS_REGISTRO_ALLOWLIST vacía: nadie puede "
                "registrarse. Si la base de usuarios está vacía, nadie podrá entrar."
            )

        if self.entorno == "prod" and self.proxy_saltos_confiables <= 0:
            # Silencioso y caro: los límites siguen "funcionando", sólo que compartidos por todo
            # el mundo, así que no se nota hasta que alguien agota el login de los demás.
            log.warning(
                "MORPHOS_PROXY_SALTOS_CONFIABLES=0 en producción: si hay un proxy delante "
                "(HF Spaces lo tiene), los límites por IP son en realidad GLOBALES. Declara "
                "cuántos saltos de confianza hay."
            )

    def validar_prod(self) -> None:
        """Requisitos que sólo aplican en producción; falla cerrado si faltan."""
        if self.entorno != "prod":
            return
        faltantes = []
        if len(self.session_secret) < 32:
            faltantes.append("MORPHOS_SESSION_SECRET (>=32 chars)")
        if not self.cookie_secure:
            faltantes.append("MORPHOS_COOKIE_SECURE=true")
        if faltantes:
            raise RuntimeError(
                "Configuración de producción incompleta: " + ", ".join(faltantes)
            )


@lru_cache
def obtener_config() -> Configuracion:
    cfg = Configuracion()
    cfg.validar_prod()
    cfg.avisar_de_configuracion()
    return cfg