Spaces:
Sleeping
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), endpointsPOST /api/lab/ingesta+GET /api/lab/resultados(routers/lab.py) + 6 pruebas; frontendlab-import.ts+form-inject.ts+ campo de ID de muestra enindex.html+ 5 pruebas; puentebridge/(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, tablaresultados_lab, recarga al arrancar);no_mapeadosmostrados 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 porfabricante; tablasdata/lab_mapeos/{abaxis,horiba,bionote}.jsonsembradas 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:
- 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, ymllp(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. - 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
(aplicarAFormulario → evaluar()); 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_validatorrecorta + acota longitud.PacientePistas: opcionales libresnombre_mascota,especie_texto,raza,sexo,edad_texto(para prerrellenarpt-*, 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 = entradaen el envelope, y un stub comentadoOrdenTrabajo— 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}yResultadoMapeado {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): emparejacodigo_pruebacontra la tabla del fabricante (código exacto primero, luego alias) →clavecanónica (+ opcionalclaveConv).convertir_unidad(clave, claveConv, valor_num, unidad): port a Python deaplicarConversion+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 deextraerValorYUnidad/parsearSemiCuantitativo(maneja"<0.1", decimales con coma,"NEG","+++"→valores de opciónuri-*); descarta no-finitos /<= 0como 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 enparsearTextoLab), recoge códigos desconocidos enno_mapeados.
data/lab_mapeos/generico.json— tabla canónica sembrada de LOINC + las abreviaturas ya presentes en las regex deDEFS_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 porfabricante/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íaRAIZ_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 tablaresultados_labal_ESQUEMAdedb.pyy 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 comointerpret.py:request: Requestprimero,cuerpo: ResultadoAnalizador,_disp = Depends(verificar_dispositivo)(nuevo; §Seguridad). Sinusuario_actual/verificar_csrf(puente headless, sin cookie).@limiter.limit(obtener_config().limite_lab_ingesta)(p. ej."120/minute", ráfagas). Ejecutamapear_resultado→almacen.guardar→ devuelve{ok, muestra_id, analitos_mapeados, no_mapeados}. Rechazaobservacionesvacías/desmesuradas conHTTPException(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 elResultadoMapeadomás reciente paramuestrao404"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)enauthz.py(o hermanosecurity/device.py):hmac.compare_digestde tiempo constante de un token Bearer contra las keys configuradas (misma disciplina queverificar_password). - Añadidos en
config.py:lab_api_keys: list[str](desdeMORPHOS_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í queconnect-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
hallazgosestructurados que ya produce el motor determinista. Acotar todas las longitudes de string enschemas_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.tsexportainicializarImportLab(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>confetch(..., {credentials:'include'})cada 3 s hasta ~90 s. - En 200: inyecta
ResultadoMapeado.analitosreusando la lógica de inyección —document.querySelector('[name="${clave}"]').value = valorpara números, match de opción para selectsuri-*, luegoevaluar(); aplica las pistaspacienteapt-*comoaplicarPacienteAFormulario. Refactor pequeño recomendado: extraeraplicarAFormulario+aplicarPacienteAFormulariodepdf-parser.tsa unfrontend/src/form-inject.tscompartido e importar desde ambos, evitando duplicación. ReusarmostrarToastpara "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).
- 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
index.html: añadir un campo de ID de muestra + control de importación en el panel de paciente (#panel-paciente, junto apt-*), siguiendo la convención existente.btn-importar-pdf/data-panel. Sin cambio de CSP.frontend/src/main.ts: importar y llamarinicializarImportLab(evaluar)junto a la línea existenteinicializarParserPdf(evaluar)(~L169), pasando la misma referenciaevaluar.
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 reusentransporte/serial.pyy difieran sólo en el parseo. Cada adaptador es un generador async que emiteResultadoAnalizador. reenviador.pyes el núcleo de fiabilidad: spool local para no perder nada cuando HF esté brevemente inalcanzable; reintentos idempotentes víamensaje_idde cliente (el almacén es último-gana pormuestra_idde 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 enhl7v2.pycon 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 enmain.py; frontendlab-import.ts+ campo de ID de muestra- cableado en
main.ts; esqueleto del puente contransporte/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.
- cableado en
- Fase 2 — Endurecimiento: durabilidad del spool, UI de cola
/api/lab/pendientes, flag opcional de persistencia SQLite, exponerno_mapeadosal 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
- 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.
- 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.
- 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. - 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. - Deriva en conversión de unidades: los factores viven en
pdf-parser.tsy enmapeo.py— mitigar con tests de paridad.
Verificación
- pytest backend (
TestClient(app)+monkeypatch, reflejandobackend/tests/test_api.py):test_lab_mapeo.py: código de fabricante → clave canónica; conversiones de unidad aseverando paridad con los factores depdf-parser.ts(p. ej. glucosammol/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 luegoGET /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 →ResultadoAnalizadornormalizado; lógica de reintento/idempotencia delreenviadorcon transporte httpx mockeado. - Vitest frontend (
frontend/src/lab-import.test.ts, fixture de formulario jsdom como los tests del motor): un JSONResultadoMapeadofija los inputs[name=…]+ selectsuri-*correctos y llamaevaluaruna 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.