morphos / INTEGRACION_ANALIZADORES.md
Jose Salazar
Actualizar documentación al stack nuevo
ce79810
|
Raw
History Blame Contribute Delete
23.4 kB

Integración de analizadores de laboratorio en Morphos

Plan de integración para que los equipos de laboratorio envíen resultados directamente a Morphos. Alcance actual: Abaxis (VetScan), Scil/Horiba y Bionote Vcheck V200.

Estado de implementación

  • Fase 0 (contratos): backend/app/schemas_lab.py, backend/app/lab/mapeo.py + data/lab_mapeos/generico.json (387 códigos) + 33 pruebas.
  • Fase 1 (MVP extremo-a-extremo): auth de dispositivo (security/device.py), almacén TTL (lab/almacen.py), endpoints POST /api/lab/ingesta + GET /api/lab/resultados (routers/lab.py) + 6 pruebas; frontend lab-import.ts + form-inject.ts + campo de ID de muestra en index.html + 5 pruebas; puente bridge/ (transportes MLLP/serie, adaptadores HL7 v2 y ASTM, reenviador con spool) + 8 pruebas. Verificado extremo-a-extremo: HL7 del puente → validación + mapeo del backend (p. ej. GLU 5.0 mmol/L → gluc 90.08).
  • Fase 2 (endurecimiento): cola GET /api/lab/pendientes + UI "Ver resultados recibidos"; persistencia SQLite opcional (lab_persistir, tabla resultados_lab, recarga al arrancar); no_mapeados mostrados en el toast de importación; re-drenado periódico del spool en el puente (spool_reintento_s). +3 pruebas backend.
  • Fase 3 (listo para enchufar): adaptadores por fabricante bridge/bridge/adaptadores/{abaxis,horiba,bionote}.py + registro que selecciona el parser por fabricante; tablas data/lab_mapeos/{abaxis,horiba,bionote}.json sembradas con los códigos reales de cada panel; configuración multi-equipo (MORPHOS_BRIDGE_INSTRUMENTOS) para varias máquinas en un puente. +3 pruebas backend, +5 puente. Pendiente sólo con hardware: confirmar códigos OBX-3/R-3 y parámetros serie con una captura real de cada equipo (ajuste = editar el JSON del fabricante).

Contexto

Hoy el veterinario teclea los valores a mano (o importa un PDF). El objetivo es que los analizadores físicos alimenten los resultados directamente en la app. Los equipos objetivo (este alcance) son Abaxis (VetScan), Scil/Horiba y Bionote Vcheck V200. Decisiones del usuario: topología nube + puente local, emparejamiento por ID de muestra/accesión, sólo entrada de resultados (inbound).

IDEXX queda explícitamente fuera de alcance por ahora (su ruta propietaria vía VetLab Station requeriría middleware/carpeta-drop y acceso al equipo real) — pero la capa de adaptadores se mantiene pluggable por fabricante para poder añadir IDEXX u otros más adelante sin rediseñar.

Dos realidades condicionan el diseño:

  1. Dos familias de protocolo entre los tres equipos. Abaxis VetScan y Scil/Horiba emiten ASTM E1381/E1394 (LIS2-A2) sobre RS-232 / USB-serial (el Abaxis VS2 admite ASCII Text / XML / ASTM: se configura a ASTM, el formato que consume astm_generico.py; ASCII/XML quedan como respaldo si un sitio no puede activar ASTM). El Bionote Vcheck V200 es estándar, no propietario: habla HL7 v2.6 PCD-01 (perfil IHE Patient Care Device "Communicate PCD Data", es decir ORU^R01 sobre MLLP/TCP) y POCT1-A (protocolo XML de punto de atención, CLSI POCT1-A2). Por tanto el puente necesita dos transportes: serial (pyserial) para el ASTM de Abaxis/Horiba, y mllp (TCP) para el HL7 v2.6 de Bionote. Un parser de registros ASTM se comparte entre Abaxis/Horiba; el adaptador HL7 v2 (hl7v2.py) cubre Bionote con un manejo del perfil PCD-01 — HL7 v2 ya no es sólo "genérico/futuro", lo exige un equipo nombrado. (POCT1-A queda como ruta alternativa de Bionote, XML, diferida salvo que se prefiera sobre HL7.) Se necesita una capa de adaptadores por analizador que normalice a un modelo de resultado canónico.
  2. Morphos se despliega en HF Spaces (un único puerto HTTP, sin alcance a la LAN, disco efímero). El backend nunca debe abrir un listener TCP/MLLP crudo. En su lugar, un pequeño puente local corre en la LAN de la clínica, habla el protocolo nativo de cada analizador y reenvía los resultados normalizados al backend por HTTPS autenticado.

La clave de unión en toda la app es la clave de analito (atributo name del input == clave en data/valores_referencia.json == clave de entrada del motor de análisis; 90 claves, idénticas para canino/felino). frontend/src/pdf-parser.ts ya codifica el vocabulario de alias + conversión de unidades y la ruta de inyección en el formulario (aplicarAFormularioevaluar()); lo reutilizamos.

LAN de la clínica (bridge/)             HF Spaces (FastAPI, un puerto HTTP)
 analizadores → adaptadores → normaliza --HTTPS--> POST /api/lab/ingesta  (API key equipo)
   (HL7 MLLP / ASTM / serial)   Bearer key         GET  /api/lab/resultados (cookie sesión)
                                                    almacén TTL en proceso (muestra_id → mapeado)
                                              navegador consulta por ID de muestra → inyecta → evaluar()

Dos zonas de confianza, dos mecanismos de auth: puente→backend usa una API key de equipo (nunca la cookie del navegador); navegador→backend mantiene la cookie de sesión

  • CSRF existentes. El mapeo código→analito y la conversión de unidades ocurren en el backend (una única fuente de verdad, compartida por todo formato de entrada, testeable en pytest).

Alcance de este plan

Las fases 0–1 (el MVP: contratos + extremo-a-extremo HL7 v2 / ASTM genérico → match por ID de muestra → autorrelleno del formulario) están totalmente especificadas. Los adaptadores por fabricante (fase 3) están estructurados pero cada uno depende de una captura del equipo real, que no puede hacerse sólo desde código.


Backend

1. Modelos canónicos — nuevo backend/app/schemas_lab.py

Mantener los esquemas de lab fuera del ya enfocado schemas.py. Identificadores en español, mismas convenciones str,Enum/Literal/Field/@field_validator que schemas.py.

  • ObservacionAnalizador: codigo_prueba: str (código/nombre de prueba del fabricante, crudo, sin mapear), valor: str (se mantiene como string en la ingesta — preserva "<0.1", ">1000", "NEG", "+++"), unidad: str = "", rango_referencia, bandera (flag H/L de anormalidad, sólo informativo). @field_validator recorta + acota longitud.
  • PacientePistas: opcionales libres nombre_mascota, especie_texto, raza, sexo, edad_texto (para prerrellenar pt-*, nunca por encima de lo que teclea el veterinario).
  • ResultadoAnalizador (una corrida de un instrumento): muestra_id: str (min_length=1, max_length=128 — la clave de match), instrumento_id, instrumento_modelo, fabricante, pistas_paciente: Optional[PacientePistas], observaciones: list[ObservacionAnalizador], momento: datetime, recibido_en (default_factory ahora), formato_origen: Literal["hl7v2","astm","json","manual"].
  • Extensibilidad hacia salida (outbound): añadir DireccionMensaje(str,Enum) {entrada,salida}
    • direccion = entrada en el envelope, y un stub comentado OrdenTrabajo — reserva el namespace /api/lab/* y el esquema de almacén para órdenes de trabajo sin construirlas.
  • Modelos de respuesta para el navegador: ValorAnalito {clave, valor, unidad, valor_original, unidad_original, es_semicuantitativo} y ResultadoMapeado {muestra_id, instrumento_id, momento, analitos: dict[str, ValorAnalito], paciente: PacientePistas|None, no_mapeados: list[str]}. Esto es exactamente lo que inyecta el frontend.

2. Capa de mapeo — nuevo paquete backend/app/lab/ + datos data/lab_mapeos/

Dirigida por datos, portada desde frontend/src/pdf-parser.ts; las claves de salida deben ser un subconjunto de las 90 claves canónicas (+ los nombres de select semicuantitativos uri-*).

  • backend/app/lab/mapeo.py:
    • mapear_observacion(obs, tabla): empareja codigo_prueba contra la tabla del fabricante (código exacto primero, luego alias) → clave canónica (+ opcional claveConv).
    • convertir_unidad(clave, claveConv, valor_num, unidad): port a Python de aplicarConversion + CONVERSIONES_UNIDADES (pdf-parser.ts:107-186) — factores idénticos para que importación por PDF y por analizador coincidan.
    • parsear_valor(valor_str): port de extraerValorYUnidad/parsearSemiCuantitativo (maneja "<0.1", decimales con coma, "NEG", "+++"→valores de opción uri-*); descarta no-finitos / <= 0 como el original.
    • mapear_resultado(res) -> ResultadoMapeado: recorre observaciones, dedup primer-match-gana, deriva % del diferencial de leucocitos desde absolutos + % de reticulocitos cuando sólo hay absolutos (port del bloque de derivación en parsearTextoLab), recoge códigos desconocidos en no_mapeados.
  • data/lab_mapeos/generico.json — tabla canónica sembrada de LOINC + las abreviaturas ya presentes en las regex de DEFS_ANALITOS. Forma: { "clave": {"codigos": ["GLU","GLUC"], "unidad_defecto": "mg/dL", "claveConv": "..."} }.
  • data/lab_mapeos/{abaxis,horiba,bionote}.json — overrides por fabricante. El fabricante se selecciona por fabricante/instrumento_modelo; desconocido → generico.json; código sin match → genérico → no_mapeados (degradación elegante, refleja la filosofía RAG). Añadir un equipo (incluido un futuro IDEXX) = soltar un nuevo JSON, no editar código. (Datos de referencia no-PII; se cargan del lado del servidor vía RAIZ_REPO / "data" / "lab_mapeos".)

3. Almacén de resultados — nuevo backend/app/lab/almacen.py

Almacén TTL en proceso, no SQLite, para el MVP. Justificación: el disco de HF Spaces es efímero, instance/ se reconstruye al reiniciar, el Dockerfile corre un único worker de uvicorn, y los resultados son de vida corta (se emparejan en minutos). Singleton AlmacenResultados con guardar, obtener, pendientes, _barrer (TTL ~24 h + tope LRU), threading.Lock. La clave es muestra_id.strip().lower() normalizada en lectura y escritura. Importado como singleton de módulo por routers/lab.py; barrido periódico opcional vía una tarea asyncio en _lifespan (main.py ya lo señala como el sitio para tareas de fondo).

  • Durabilidad opcional (fase 2, tras lab_persistir=False): añadir una tabla resultados_lab al _ESQUEMA de db.py y doble-escritura vía el patrón _conexion() existente. OFF por defecto — sólo tiene sentido con un volumen persistente. Documentar el trade-off.

4. Endpoints — nuevo backend/app/routers/lab.py, registrar en main.py

Registrar como los demás: from .routers import auth, interpret, lab, papers + app.include_router(lab.router, prefix="/api").

  • POST /api/lab/ingesta (puente → backend). Firma como interpret.py: request: Request primero, cuerpo: ResultadoAnalizador, _disp = Depends(verificar_dispositivo) (nuevo; §Seguridad). Sin usuario_actual/verificar_csrf (puente headless, sin cookie). @limiter.limit(obtener_config().limite_lab_ingesta) (p. ej. "120/minute", ráfagas). Ejecuta mapear_resultadoalmacen.guardar → devuelve {ok, muestra_id, analitos_mapeados, no_mapeados}. Rechaza observaciones vacías/desmesuradas con HTTPException(422, "…español").
  • GET /api/lab/resultados (navegador → backend). request: Request, muestra: str = Query(..., min_length=1, max_length=128), _sesion = Depends(usuario_actual), @limiter.limit(...limite_lab_consulta) (p. ej. "60/minute"). Devuelve el ResultadoMapeado más reciente para muestra o 404 "No hay resultados para esa muestra todavía."
  • GET /api/lab/pendientes (opcional, fase 2, auth de sesión): recientes sin reclamar {muestra_id, instrumento_id, momento} para una UI de cola de selección.

Polling, no SSE: el stack es request/response, HF Spaces complica el SSE de larga vida, y la UX es "arma un ID de muestra, espera segundos." El frontend consulta GET …?muestra=<id> cada ~3 s durante ~90 s; 404 = seguir esperando, 200 = aplicar. Cero infra de servidor nueva. (SSE anotado como optimización futura.)

5. Seguridad — backend/app/config.py, backend/app/security/

  • Nueva dependencia verificar_dispositivo(request, authorization: Header) en authz.py (o hermano security/device.py): hmac.compare_digest de tiempo constante de un token Bearer contra las keys configuradas (misma disciplina que verificar_password).
  • Añadidos en config.py: lab_api_keys: list[str] (desde MORPHOS_LAB_API_KEYS; soporta rotación/por-clínica), limite_lab_ingesta, limite_lab_consulta, lab_persistir. validar_prod() exige ≥1 key cuando la ingesta de lab está activada. La ingesta es sin-cookie/sin-CSRF por diseño — la API key es la auth, sólo por HTTPS.
  • No aflojar CORS ni CSP. El puente es servidor-a-servidor (CORS gobierna navegadores); el navegador sólo llama a /api/lab/* del mismo origen con la cookie existente, así que connect-src 'self' ya lo cubre — sin nueva cabecera de navegador, sin cambio de preflight.
  • Los valores del analizador son datos, nunca instrucciones: pueblan inputs numéricos y llegan al modelo sólo como los mismos hallazgos estructurados que ya produce el motor determinista. Acotar todas las longitudes de string en schemas_lab.py.

Frontend — nuevo frontend/src/lab-import.ts, ediciones a index.html + main.ts

Reflejar pdf-parser.ts exactamente (clave de unión = name de analito; evaluar() es el disparador reactivo). El mapeo es del lado del servidor, así que el lado TS es delgado.

  • frontend/src/lab-import.ts exporta inicializarImportLab(evaluar):
    • Input de ID de muestra + botón "Importar del analizador"; un lector de código de barras simplemente teclea en el campo + Enter. Al armar, consulta GET /api/lab/resultados?muestra=<id> con fetch(..., {credentials:'include'}) cada 3 s hasta ~90 s.
    • En 200: inyecta ResultadoMapeado.analitos reusando la lógica de inyección — document.querySelector('[name="${clave}"]').value = valor para números, match de opción para selects uri-*, luego evaluar(); aplica las pistas paciente a pt-* como aplicarPacienteAFormulario. Refactor pequeño recomendado: extraer aplicarAFormulario + aplicarPacienteAFormulario de pdf-parser.ts a un frontend/src/form-inject.ts compartido e importar desde ambos, evitando duplicación. Reusar mostrarToast para "N valores importados del analizador."
    • Marcar los campos importados con un resaltado transitorio para que el veterinario vea qué cambió; el toast debe indicar que los valores se autorrellenaron y deben verificarse (refuerza la postura de seguridad existente requiere_derivacion).
  • index.html: añadir un campo de ID de muestra + control de importación en el panel de paciente (#panel-paciente, junto a pt-*), siguiendo la convención existente .btn-importar-pdf/data-panel. Sin cambio de CSP.
  • frontend/src/main.ts: importar y llamar inicializarImportLab(evaluar) junto a la línea existente inicializarParserPdf(evaluar) (~L169), pasando la misma referencia evaluar.

Puente local — nuevo bridge/ de nivel superior (proyecto uv separado)

Python + uv para coincidir con el tooling del repo y reusar las formas canónicas; su propio bridge/pyproject.toml para que las deps de serial/HL7 nunca inflen la imagen de HF Spaces.

bridge/
  pyproject.toml            # uv: httpx, pyserial, python-hl7 (o hl7apy); pytest grupo dev
  bridge/config.py          # BridgeConfig(BaseSettings) env_prefix MORPHOS_BRIDGE_: morphos_url, api_key, instrumentos[]
  bridge/modelo.py          # forma canónica ResultadoAnalizador
  bridge/transporte/
      serial.py             # lector pyserial → tramas crudas (Abaxis/Horiba ASTM)             [FASE 1]
      mllp.py               # servidor MLLP TCP (\x0b..\x1c\x0d) → tramas crudas (Bionote HL7) [FASE 1]
  bridge/adaptadores/
      base.py               # AdaptadorBase: generador async → yields ResultadoAnalizador
      astm_generico.py      # ASTM E1381/E1394 (STX/ETX/checksum, registros H/P/O/R/L)         [FASE 1]
      hl7v2.py              # parsea ORU^R01 (v2.6 PCD-01) → canónico; requerido por Bionote    [FASE 1]
      registro.py           # fabricante → parser (selección automática)                        [HECHO]
      abaxis.py             # VetScan sobre astm_generico (fabricante=abaxis)                    [HECHO]
      horiba.py             # Scil/Horiba sobre astm_generico (fabricante=horiba)                [HECHO]
      bionote.py            # Vcheck V200 sobre hl7v2 (fabricante=bionote, PCD-01)               [HECHO]
  bridge/normalizador.py    # salida del adaptador → ResultadoAnalizador validado (+ pistas de paciente)
  bridge/reenviador.py      # httpx Bearer POST /api/lab/ingesta, backoff exp, cola spool en disco, idempotencia mensaje_id
  bridge/main.py            # supervisa adaptadores configurados → normalizador → reenviador
  bridge/tests/  bridge/README.md   # cableado por analizador + checklist de validación
  • Separar transporte (serial / MLLP — cómo llegan los bytes) de adaptador (ASTM / HL7 / fabricante — cómo las tramas se vuelven un ResultadoAnalizador), de modo que Abaxis/Horiba/Bionote reusen transporte/serial.py y difieran sólo en el parseo. Cada adaptador es un generador async que emite ResultadoAnalizador.
  • reenviador.py es el núcleo de fiabilidad: spool local para no perder nada cuando HF esté brevemente inalcanzable; reintentos idempotentes vía mensaje_id de cliente (el almacén es último-gana por muestra_id de todos modos).
  • Cada uno de Abaxis / Horiba / Bionote necesita su propio adaptador + validación en sitio contra el instrumento físico, pero el esfuerzo difiere: Abaxis y Horiba (ASTM sobre serie) requieren confirmar parámetros serie — baud/paridad/framing — y el layout de registros (y en el Abaxis VS2, seleccionar la salida ASTM frente a sus modos ASCII/XML); Bionote (HL7 v2.6 PCD-01 sobre MLLP) sólo requiere confirmar el transporte (puerto MLLP/IP), el juego de identificadores OBX-3 del perfil PCD-01 (a menudo LOINC o nomenclatura IEEE 11073/MDC) y las unidades — no hay que revertir ningún formato propietario. El README incluye un checklist por analizador (capturar 3–5 corridas reales, confirmar transporte + campo de ID de muestra + unidades, diff al JSON del fabricante). Abaxis y Horiba caen ambos en astm_generico.py; Bionote se apoya en hl7v2.py con un fino mapeo del perfil PCD-01.

Entrega por fases

  • Fase 0 — Contratos (sólo backend, totalmente testeable): schemas_lab.py, data/lab_mapeos/generico.json, lab/mapeo.py + tests unitarios.
  • Fase 1 — MVP extremo-a-extremo: auth de equipo + config keys; routers/lab.py; lab/almacen.py; registrar en main.py; frontend lab-import.ts + campo de ID de muestra
    • cableado en main.ts; esqueleto del puente con transporte/serial.py + transporte/mllp.py + adaptadores/astm_generico.py + adaptadores/hl7v2.py + reenviador.py. Entregable: una máquina ASTM genérico (serial) o HL7 v2 → match por ID de muestra → autorrelleno.
  • Fase 2 — Endurecimiento: durabilidad del spool, UI de cola /api/lab/pendientes, flag opcional de persistencia SQLite, exponer no_mapeados al veterinario.
  • Fase 3 — Adaptadores por fabricante: abaxis.py, horiba.py, bionote.py + sus JSON, cada uno condicionado a captura del equipo real. (IDEXX diferido, fuera de alcance.)

Riesgos

  1. Dialectos ASTM de Abaxis/Horiba (el mayor, ahora reducido). Quitar IDEXX elimina el riesgo de middleware propietario, y confirmar que Bionote es HL7 v2.6 PCD-01 estándar (no propietario) elimina el riesgo de revertir un formato de línea. Riesgo residual principal: los dialectos ASTM de Abaxis/Horiba varían en layout de registro y parámetros serie (baud/paridad), y necesitan una captura del equipo real para cerrarse. Para Bionote el riesgo es menor y acotado: confirmar el transporte (puerto MLLP) y el juego de códigos OBX-3 del perfil PCD-01. El JSON dirigido por datos + la separación transporte/adaptador contienen el radio de impacto, pero el framing/las unidades aún se validan con capturas físicas.
  2. Efimeridad de HF Spaces: el almacén en proceso pierde resultados al reiniciar. Aceptable (resultados de vida corta + reenviables desde el spool del puente) pero debe documentarse; no prometer historial durable sin un volumen persistente.
  3. Disciplina de ID de muestra: el match depende de que el mismo ID esté en la máquina y en la UI; el escaneo de código de barras mitiga typos; normalización centralizada en almacen.py.
  4. Suposición de un solo worker: el almacén en proceso sólo es correcto con un worker de uvicorn (actualmente cierto). Si se añaden workers, mover el almacén a SQLite/caché compartida — señalarlo en almacen.py.
  5. Deriva en conversión de unidades: los factores viven en pdf-parser.ts y en mapeo.py — mitigar con tests de paridad.

Verificación

  • pytest backend (TestClient(app) + monkeypatch, reflejando backend/tests/test_api.py):
    • test_lab_mapeo.py: código de fabricante → clave canónica; conversiones de unidad aseverando paridad con los factores de pdf-parser.ts (p. ej. glucosa mmol/L→mg/dL ×18.016, creatinina µmol/L ÷88.4); semicuant "+++"→"+++"; derivación del diferencial de leucocitos; no_mapeados.
    • test_lab_ingesta.py: ingesta con Bearer key válida → 200 + almacenado; key errónea/sin key → 401; ingesta y luego GET /api/lab/resultados?muestra=… con cookie de sesión → analitos mapeados; consulta sin sesión → 401; muestra desconocida → 404.
    • Fixtures: tramas HL7 ORU^R01 (incl. una v2.6 PCD-01 estilo Bionote) + ASTM crudas y sus dicts canónicos esperados en backend/tests/fixtures/lab/.
  • pytest puente (bridge/tests/): fixture HL7/ASTM cruda → ResultadoAnalizador normalizado; lógica de reintento/idempotencia del reenviador con transporte httpx mockeado.
  • Vitest frontend (frontend/src/lab-import.test.ts, fixture de formulario jsdom como los tests del motor): un JSON ResultadoMapeado fija los inputs [name=…] + selects uri-* correctos y llama evaluar una vez.
  • Extremo-a-extremo manual (MVP): correr backend (make dev) + una instancia del puente local; enviar una muestra HL7/ASTM capturada a través del puente; en la UI teclear el ID de muestra y confirmar que el formulario se autorrellena, los campos se resaltan y el análisis se re-ejecuta. Comandos: make backend-test, make frontend-test, make frontend-build.
  • Fuera de alcance automatizado (explícito): validación con equipo real de Abaxis/Horiba/Bionote — la suite automatizada usa fixtures capturadas; la corrección final de parámetros serie/campo/unidad/ID de muestra sólo se confirma contra los instrumentos físicos en la LAN de la clínica.