Spaces:
Running
Running
| """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) | |
| 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) | |
| ) | |
| def obtener_config() -> Configuracion: | |
| cfg = Configuracion() | |
| cfg.validar_prod() | |
| return cfg | |