morphos / backend /app /config.py
Jose Salazar
Allow list para modelos locales configurables por el usuario
6ab7946
Raw
History Blame Contribute Delete
17.2 kB
"""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
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
# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
RAIZ_REPO = Path(__file__).resolve().parents[2]
class Configuracion(BaseSettings):
model_config = SettingsConfigDict(
env_file=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)
# --- Base de datos (usuarios). Ruta fuera del webroot. ---
db_path: Path = Field(default=RAIZ_REPO / "instance" / "morphos.db")
mysql_dsn: str = Field(default="") # si se define, se usa en vez de SQLite
mysql_user: str = Field(default="")
mysql_password: str = Field(default="")
# --- 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)
# --- 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)
@field_validator("lab_api_keys", "modelos_locales", 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 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()
return cfg