Spaces:
Sleeping
Sleeping
| """Modelos Pydantic de la integración de analizadores de laboratorio. | |
| Se mantienen fuera de schemas.py (enfocado en la interpretación IA) porque son un | |
| contrato distinto: lo que el puente local (bridge/) envía por HTTPS a /api/lab/ingesta | |
| tras leer un analizador, y lo que el navegador consulta en /api/lab/resultados. | |
| Flujo: el puente normaliza la salida nativa del equipo (ASTM de Abaxis/Horiba, HL7 v2.6 | |
| PCD-01 de Bionote) a `ResultadoAnalizador` con los códigos de prueba EN CRUDO; el backend | |
| los mapea a las claves canónicas de la app (las mismas de valores_referencia.json) en | |
| `ResultadoMapeado`, que es lo único que el frontend inyecta en el formulario. | |
| """ | |
| from __future__ import annotations | |
| from datetime import UTC, datetime | |
| from enum import StrEnum | |
| from typing import Literal | |
| from pydantic import BaseModel, Field, field_validator | |
| class DireccionMensaje(StrEnum): | |
| """Sentido del mensaje. Sólo `entrada` (resultados) está implementado; `salida` | |
| (órdenes de trabajo hacia el analizador) queda reservado para no rehacer el esquema.""" | |
| entrada = "entrada" | |
| salida = "salida" | |
| class PacientePistas(BaseModel): | |
| """Pistas de paciente que el analizador pueda acarrear. Se usan para prerrellenar | |
| los campos `pt-*`, NUNCA por encima de lo que teclea el veterinario. Texto libre.""" | |
| nombre_mascota: str | None = Field(default=None, max_length=120) | |
| especie_texto: str | None = Field(default=None, max_length=60) | |
| raza: str | None = Field(default=None, max_length=60) | |
| sexo: str | None = Field(default=None, max_length=30) | |
| edad_texto: str | None = Field(default=None, max_length=30) | |
| class ObservacionAnalizador(BaseModel): | |
| """Una observación (analito) tal como la reporta el equipo, SIN mapear. | |
| `valor` se mantiene como string en la ingesta: los analizadores mandan `"12.3"`, | |
| `"<0.1"`, `">1000"`, `"NEG"`, `"+++"`… La coerción numérica y la conversión de | |
| unidades ocurren en la capa de mapeo (lab/mapeo.py), no aquí. | |
| """ | |
| codigo_prueba: str = Field(min_length=1, max_length=64) | |
| valor: str = Field(max_length=128) | |
| unidad: str = Field(default="", max_length=32) | |
| rango_referencia: str | None = Field(default=None, max_length=64) | |
| bandera: str | None = Field(default=None, max_length=16) # H/L/HH/A del instrumento | |
| def _recortar(cls, v: str) -> str: | |
| return v.strip() | |
| class ResultadoAnalizador(BaseModel): | |
| """Una corrida de un instrumento: el envelope que envía el puente a /api/lab/ingesta.""" | |
| muestra_id: str = Field(min_length=1, max_length=128) # clave de emparejamiento | |
| instrumento_id: str = Field(min_length=1, max_length=64) | |
| instrumento_modelo: str | None = Field(default=None, max_length=64) | |
| fabricante: str | None = Field(default=None, max_length=64) # selecciona la tabla de mapeo | |
| pistas_paciente: PacientePistas | None = None | |
| observaciones: list[ObservacionAnalizador] = Field(min_length=1, max_length=200) | |
| momento: datetime # timestamp del resultado en el equipo | |
| recibido_en: datetime = Field(default_factory=lambda: datetime.now(UTC)) | |
| formato_origen: Literal["hl7v2", "astm", "json", "manual"] = "json" | |
| direccion: DireccionMensaje = DireccionMensaje.entrada | |
| def _recortar(cls, v: str) -> str: | |
| return v.strip() | |
| # --- Salida mapeada (lo que consume el navegador / el frontend inyecta) --- | |
| class ValorAnalito(BaseModel): | |
| """Un analito ya mapeado a la clave canónica de la app, con la unidad nativa aplicada.""" | |
| clave: str # clave canónica (== atributo `name` del input == clave de valores_referencia.json) | |
| valor: float | str # número para analitos numéricos; string para semicuantitativos (uri-*) | |
| unidad: str = "" # unidad nativa de la app tras la conversión | |
| valor_original: str = "" # lo que reportó el equipo, para trazabilidad | |
| unidad_original: str = "" | |
| es_semicuantitativo: bool = False | |
| class ResultadoMapeado(BaseModel): | |
| """Resultado listo para el frontend: analitos por clave canónica + no reconocidos.""" | |
| # Clínica dueña del resultado. Lo pone el SERVIDOR a partir de la API key del dispositivo | |
| # (`verificar_dispositivo`), nunca el cuerpo de la ingesta: si el puente pudiera declararlo, | |
| # mentir en un campo bastaría para escribir en la clínica de otro. | |
| tenant: str = "" | |
| muestra_id: str | |
| instrumento_id: str | |
| momento: datetime | |
| analitos: dict[str, ValorAnalito] = Field(default_factory=dict) | |
| paciente: PacientePistas | None = None | |
| no_mapeados: list[str] = Field(default_factory=list) # códigos de prueba sin correspondencia | |
| class RespuestaIngesta(BaseModel): | |
| """Respuesta al puente tras una ingesta correcta.""" | |
| ok: bool = True | |
| muestra_id: str | |
| analitos_mapeados: int | |
| no_mapeados: list[str] = Field(default_factory=list) | |
| class ResumenPendiente(BaseModel): | |
| """Fila de la cola de resultados recibidos (sin el detalle de analitos).""" | |
| muestra_id: str | |
| instrumento_id: str | |
| momento: datetime | |
| analitos: int | |
| no_mapeados: int | |