На каждой второй конференции по медицинскому AI звучит один и тот же сценарий: «Дообучим мультимодальную модель, скормим ей DICOM, и она сама выдаст диагноз». На практике, когда этот скрипт пытается попасть в реальную клинику, начинаются неожиданности. OOM на GPU, врачи не понимают, где галлюцинация модели, а где финальный отчёт, двухгигабайтные NIfTI-исследования рвут таймауты балансировщика.
Я какое-то время тоже думала, что главное — это модель. А потом пересмотрела собственный код. У меня уже есть MRI Second Opinion. Но это не нейросеть. Это контур с доменной моделью, конвейером приёма данных, циклом обработки, обязательным врачебным рецензированием, финализацией и отдельным репозиторием с открытым кодом. В медицинском IT модель — не главная проблема. Главная проблема — чтобы между входом и выходом ничего не потерялось и не сломалось.
Если разложить конвейер в одну строку, он выглядит так:
Python-ingestion принимает DICOM-метаданные и создаёт кейс.
TypeScript-оркестратор инициализирует MriSecondOpinionCase.
Python-обработчик асинхронно считает черновик в voxel-backed или metadata-fallback режиме.
Safety policy переводит кейс в AwaitingReview.
Врач проводит human review и финализирует решение.
6. Система доставляет структурированный отчёт дальше по контуру.
Почему это инженерная задача, а не задача data science
Со стороны всё просто: есть МРТ-снимок, есть заключение, нужен второй взгляд. Но как только из этого делаешь не разовый подвиг, а воспроизводимый сервис — сразу вылезает инфраструктура.
Типичная ловушка при работе с медицинским AI — мышление категориями скрипта в Jupyter Notebook. Загрузили NIfTI, выдали JSON, победа. Но если этот скрипт падает посреди процесса, половина кейсов виснет в статусе «В работе». Если модель галлюцинирует опухоль, и черновик улетает пациенту на почту — это уже не техническая проблема.
Нужно принимать исследования из разных источников. Вытаскивать метаданные. Понимать, хватает ли входных данных для осмысленного разбора. Не терять клинический вопрос. Отличать черновик модели от финального врачебного решения. Фиксировать статус кейса, журнал аудита, ошибки доставки, повторные попытки, время ожидания рецензирования.
МРТ-рецензирование — это весь путь от приёма до доставки. Модель где-то посередине.
Реальный DICOM против идеального NIfTI
В туториалах по data science всегда дают красиво нарезанный мозг в формате NIfTI. В реальной жизни PACS больницы может отдать архив, внутри которого файлы без расширений, метаданные зашиты в кастомные приватные теги вендора, а сжатие картинки — древний JPEG 2000 Lossless, от которого половина питоновских библиотек падает в segfault.
Первой границей контура стал ingestion worker. Он жёстко детерминирован: распаковал, отмыл от мусора, сформировал строгий объект-значение ImagingStudyRef с проверкой формата DICOM UID по правилам кодирования из PS3.5 §9.1 и только потом пустил в стерильную зону TypeScript-оркестратора.
А если снимки побиты или пришли неполные? Система не падает с 500-й ошибкой. Python worker использует metadata-fallback режим: работает по метаданным без объёмных данных. Если клинический вопрос не удаётся вывести из заголовков DICOM, worker не притворяется врачом — ставит безопасную заглушку вроде «Routine second opinion review requested» и оставляет кейс в контуре, где смысл подтвердит человек.
Нужна точность: ingestion worker и delivery worker зарегистрированы в DI-контейнере, но по умолчанию выключены флагом функциональности. Это подготовленный контур, а не часть рабочей среды по умолчанию.
Человек в контуре: инварианты FDA в TypeScript
Многие проекты заявляют: «Мы только помогаем врачу». Но на уровне кода эта граница часто условна — кнопка в интерфейсе, которую можно обойти.
Проект позиционируется не как Software as a Medical Device (SaMD), который ставит диагнозы сам, а строго как Clinical Decision Support (CDS). В логике FDA CDS guidance и section 520(o)(1)(E) FD&C Act конечное решение должно оставаться за лицом с медицинской квалификацией, а система не должна подменять клиническое суждение. Наш корневой агрегат в коде — это прямая трансляция границы врачебного рецензирования в TypeScript. Состояние AwaitingReview — жёсткая архитектурная граница clinician review, а не декоративная кнопка в интерфейсе.
Код выбросит DomainInvariantViolationError, если попытаться сделать finalize() без строгой отметки о врачебном рецензировании. А если есть неразрешённые флаги безопасности — заблокирует доставку. Модель не может принять решение сама — только подготовить черновик. Финальное слово за врачом, и машина состояний это гарантирует.
GenerateMriSecondOpinionUseCase прогоняет каждый результат inference через IClinicalSafetyPolicy — rule-based policy с флагами low confidence, significant disagreement, insufficient data и stat escalation, собранную с опорой на ACR Practice Parameters for Communication of Diagnostic Imaging Findings (Res. 11, 2020) и clinician-review boundary из FDA CDS guidance, — до перевода в ожидание рецензирования. Черновик модели и итоговое заключение — не одна строчка в разных местах интерфейса: при рецензировании врач может обновить оценку, и агрегат зафиксирует отдельно и решение врача (с обязательной ролью — Neuroradiologist, Attending Physician), и финальную версию заключения.
Почему оркестрацию и вычисления пришлось развести
В открытом МРТ-контуре вычислительный тракт намеренно ограничен: обработчик честно работает в metadata-fallback и voxel-backed режимах, а уровень управления не притворяется клиническим движком вычислений.
На практике сервисы часто падают из-за OOM на GPU или сетевых таймаутов на длинных вычислениях. Разделить вычисления и управление — было естественным решением.
TypeScript-оркестратор мгновенно отдаёт HTTP 200, фиксирует события через Event Sourcing + Outbox, а дальше грязную, непредсказуемую GPU-работу забирает асинхронный Python-обработчик. Ограниченный вычислительный контур доказывает главное: завтра я заменю ограниченный адаптер на полноценный мультимодальный движок, а контур оркестрации даже не моргнёт. Контракты на месте: порт, DI-токен, политика безопасности, граница рецензирования. Когда появится адаптер промышленного уровня, он встанет в те же пазы без перестройки вокруг.
Клиническая интероперабельность и цикл обратной связи
Медицинская система, которая выдаёт свой кастомный JSON и на этом останавливается, — плохо совместима с реальной клиникой. Итоговый структурированный отчёт маппится на оболочку HL7 FHIR R4 DiagnosticReport (входные метаданные исследования представлены через ImagingStudyRef — доменное значение, отражающее ключевые метаданные исследования и проверяющее формат UID по PS3.5 §9.1), а уровень экспорта держит DICOM SR (Comprehensive SR, SOP Class 1.2.840.10008.5.1.4.1.1.88.33) как стык для структурированных отчётов по визуализации. Пока это JSON-оболочки, а не бинарный DICOM Part-10 и не FHIR XML, но семантический фундамент под будущую интеграцию с МИС/ЭМК уже заложен. В доменной модели RadiologyReportSummary поддерживает кодирование через RadLex (RSNA) и SNOMED CT.
Зачем столько инфраструктуры, если вычислительная часть ограничена?
Потому что конвейер решает проблему dataset shift — явления, когда производительность модели деградирует при смене протоколов МРТ, обновлении оборудования или переходе между вендорами (Zech et al., «Variable generalization performance of a deep learning model to detect pneumonia in chest radiographs», PLOS Medicine, 2018; Glocker et al., «Machine Learning with Multi-Site Imaging Data», 2019). В клинической практике это называют distribution shift или model drift. Процесс финализации чётко фиксирует разницу между исходным ответом AI и финальной правкой врача (поле humanReview.modifications). Это уже нормальная заготовка под цикл обратной связи: врачебные правки можно использовать для валидации будущей модели и, позже, для дообучения.
Метрики, где каждая секунда — это чьё-то здоровье
В e-commerce мониторят RPS и время ответа корзины. В MRI Second Opinion метрики имеют другой вес.
Восемь Prometheus-инструментов уже объявлены в коде: counter и histogram для приёма, вычислений и доставки, плюс два gauge для очереди рецензирования — mri_second_opinion_cases_awaiting_review (скольких кейсов ждёт врач, по уровню срочности) и mri_second_opinion_review_wait_started_at_unix (когда самый старый кейс попал в ожидание). Если пациент с подозрением на инсульт висит в AwaitingReview дольше регламента — это потенциальное оповещение уровня жизни и смерти. Наблюдаемость закладывалась в фундамент до первого релиза, а не «когда-нибудь потом в спринте 14». Полная интеграция с Grafana ждёт развёртывания в промышленную среду.
Отдельный репозиторий
Рядом с основным монорепозиторием живёт отдельный репозиторий MRI.
Это самостоятельный продуктовый контур, не экспорт кусочка доменной логики: свой README, Dockerfile, docker-compose, лицензия, SECURITY.md, CONTRIBUTING.md, исходники, публичный workbench и плотно покрытый тестами код. Последний прогон (зафиксирован в release-validation-packet) — 176 зелёных тестов из 177 (1 skipped, 0 failing), включая честные проверки state machine, PostgreSQL-интеграции и объектно-скопированной авторизации.
README подаёт проект как clinician-in-the-loop MRI second-opinion workflow system. Не «AI, который читает МРТ».
Что он умеет
Для раннего публичного релиза — много.
Жизненный цикл кейса. 9 состояний: INGESTING → QC_REJECTED, SUBMITTED → AWAITING_REVIEW → REVIEWED → FINALIZED → DELIVERY_PENDING → DELIVERED / DELIVERY_FAILED. Из них 4 управляются доменным агрегатом в основном репозитории, остальные 5 — контуром приёма и доставки в самостоятельном репозитории. Состояние кейса, задачи вычислений, задачи доставки, логика повторов и операционные журналы — всё это живёт в коде, не на слайде.
Разделение управления и вычислений. TypeScript API отвечает за оркестрацию, машину состояний, валидацию, управление задачами, проверки доступности, метрики, экспорты и рабочее место рецензента. Python-обработчик — за обработку исследования, диспетчеризацию с арендой задач, обратные вызовы и обработку ошибок. Когда таймауты, вычисления и бизнес-логика живут в одном процессе, стабильность страдает первой.
Обязательное врачебное рецензирование. Модель генерирует черновик, врач принимает решение, система гарантирует эту границу на уровне кода. Если врачебное рецензирование нарисовано только на слайде — это одно. Если оно встроено в машину состояний, API и финализацию — другая степень серьёзности.
Хранение состояния. SQLite для локального режима, PostgreSQL для промышленной эксплуатации.
Ограниченные вычисления. Python-обработчик поддерживает два режима: voxel-backed (NIfTI-файл загружен и распарсен) и metadata-fallback (нет объёмных данных — работаем по метаданным). Система не притворяется, что у неё всегда идеальный набор данных.
Почему я не называю это «AI, который читает МРТ»
Research Use Only. Это не медицинское изделие, не автономная диагностическая система и не замена врачу.
README говорит прямо: «This repository is not an autonomous diagnostic system. It is a clinician-in-the-loop workflow layer that enforces human review before finalization or delivery.»
Никакого регуляторного разрешения у проекта нет, и этот текст не должен читаться так, будто оно есть.
Дизайн-документы позиционируют MRI Second Opinion как Clinical Decision Support. Границы жёсткие:
итоговое решение остаётся у врача;
низкая уверенность или критический флаг безопасности блокируют доставку до разрешения;
сырые снимки не утекают в состояние агрегата —
ImagingStudyRefхранит только метаданные, пиксельные данные в доменную модель не попадают;система ориентирована на врача, проверяема и строится от безопасности.
Даже хорошие вычисления модели не отменяют границу рецензирования. Без этой границы слишком легко соскользнуть в «модель сейчас всё сама дочитает».
Следующая волна
Orthanc, OHIF, 3D Slicer фигурируют в проектной документации как границы интеграции: Orthanc — граница DICOM по умолчанию, OHIF — веб-просмотрщик, 3D Slicer — экспертная рабочая станция. Прямо сейчас они не сидят в рабочей среде. Текущий конвейер самодостаточен.
Проект уже существует, но не закончен. Следующие блоки:
хранилище и фундамент приёма данных;
более сильный вычислительный адаптер поверх текущего ограниченного контура (с возможностью A/B-тестирования моделей через фабрику адаптеров);
материализация серий DICOM;
интеграция с DICOMweb/PACS — первым шагом адаптер к Orthanc по WADO-RS для получения метаданных по
studyInstanceUid;наблюдаемость на переходах машины состояний и операционные SLA;
автоматизация доставки после финализации;
авторизация на уровне объекта — частично реализована: tenant-scoped изоляция (x-tenant-id) и reviewer-scoped мутации уже работают; подписанные JWT-маркированные tenant-токены — следующий шаг.
Вот что стоит на ногах, вот где следующая граница.
Что в итоге показывает код
Код последовательно показывает одну вещь.
Самая дорогая часть MRI second opinion — не вызвать модель. Самая дорогая часть — сделать так, чтобы кейс корректно зашёл, входные данные были валидны, клинический вопрос не потерялся, черновик не стал псевдодиагнозом, врачебный review остался обязательным, финальный артефакт можно было отдать дальше, и вся цепочка была наблюдаемой.
Модель важна. Но она внутри этой системы, а не вместо неё.
Проект полностью открыт. Если вы строите медицинские конвейеры, пишете на TypeScript или Python, или просто устали от хайпа вокруг «заменит ли AI рентгенолога» — приходите!
Если вам ближе инженерная часть и хочется помочь со следующей волной — от DICOMweb/Orthanc до delivery automation и наблюдаемости на переходах машины состояний, — в репозитории точно найдётся куда приложить руки.
Два вопроса для обсуждения:
Как вы разводите тяжёлый inference и бизнес-логику в своих проектах?
Сталкивались ли вы с дрейфом моделей при смене оборудования в клинике?
