Как одно непереданное заключение КТ убило человека, и почему я написал систему, которая не даёт этому повториться
В мае 2023 года женщину привезли в приёмный покой БСМП с подозрением на инсульт. Провели КТ мозга. Рентгенолог посмотрел снимки, но не составил заключение и не передал его неврологу. Невролог, не дождавшись описания, сам интерпретировал снимки — и ошибся. Поставил другой диагноз. Вместо экстренного лечения пациентку отправили в ОКБ. Она скончалась через 12 дней, так и не придя в сознание.
Росздравнадзор и судмедэксперты установили: если бы инсульт распознали вовремя, женщину можно было спасти. Муж получил 150 000 рублей компенсации. Они были вместе 41 год.
Это не уникальный случай. По данным исследований, alert fatigue (усталость от ложных тревог) приводит к тому, что врачи игнорируют 80–90% алертов от существующих систем поддержки принятия решений. А систем, которые умеют молчать и сигнализировать только при реальной угрозе — в open-source на русском языке нет вообще.
Я решил что может быть смогу помочь данному явлению, стать более редким.
Что такое MedSentinel
MedSentinel — это open-source система фонового мониторинга критических состояний пациентов. Она подключается к существующей МИС «сбоку» (через FHIR, HL7, REST API или даже CSV-экспорт), подтягивает данные пациента (историю, диагнозы, анализы, назначения), и в фоне анализирует их через LLM.
Ключевое: система молчит, пока всё в порядке. Никаких подсказок. Никаких алертов на каждое повышенное давление. Бабушка с гипертонией, которая приходит 47-й раз с одними жалобами — не повод для тревоги. Но если та же бабушка поступает с внезапным нарушением речи — это уже другая история.
Сигнал возникает только при реальной угрозе жизни, которую врач мог не заметить или недооценить.
Где работает:
Скорая помощь (фельдшер на вызове)
Приёмный покой (врач осматривает поступившего)
Любое отделение с электронной картой
Код: github.com/gogi2305/medsentinel Лицензия: Apache 2.0 Стек: Python 3.10+, asyncio, httpx, pydantic, YAML-конфигурация LLM: Ollama (локально, данные не покидают больницу) или любой облачный API через прокси с обезличиванием
Архитектура: три слоя
Система состоит из трёх независимых слоёв, каждый из которых решает свою задачу.
┌─────────────────────────────────────────────────────┐ │ МИС / EHR (существующая база) │ └──────────────────────┬──────────────────────────────┘ │ FHIR / HL7 / REST / CSV ▼ ┌──────────────────────────────────────────────────────┐ │ КОННЕКТОР (плагин под конкретную МИС) │ │ BaseConnector → get_patient_context() │ └──────────────────────┬───────────────────────────────┘ │ PatientContext ▼ ┌──────────────────────────────────────────────────────┐ │ СЛОЙ 1: ТИХИЙ СТОРОЖ (автоматически, всегда) │ │ Analyzer → LLM → критично? → Alert или None │ └───────┬──────────────────────────────┬───────────────┘ │ Alert │ Alert ▼ ▼ ┌───────────────────┐ ┌───────────────────────────────┐ │ Notifier │ │ СЛОЙ 2: ДИСПЕТЧЕР │ │ (врачу/фельдшеру) │ │ Dispatcher → дежурному │ │ email/webhook/log │ │ специалисту по профилю │ └───────────────────┘ └───────────────────────────────┘ ┌──────────────────────────────────────────────────────┐ │ СЛОЙ 3: КОНСИЛИУМ (по запросу врача) │ │ Consilium → LLM выбирает специалистов из 19 │ │ → каждый высказывается → резюме │ └──────────────────────────────────────────────────────┘
Слой 1 — Тихий сторож
Ядро всей системы. Работает автоматически, в фоне, при каждом обновлении данных пациента.
Вот как выглядит главный анализатор (core/analyzer.py):
class Analyzer: def __init__(self, llm: BaseLLM, config: Config): self.llm = llm self.severity_threshold = Severity(config.alert.severity_threshold) async def analyze(self, context: PatientContext) -> Alert | None: prompt = context.to_prompt() response = await self.llm.generate( system_prompt=SYSTEM_PROMPT, user_prompt=prompt, ) alert = parse_llm_response(response, patient_id=context.patient_id) if alert is None: return None # Молчим # Фильтр по порогу: если severity ниже настроенного — пропускаем severity_order = [Severity.MEDIUM, Severity.HIGH, Severity.CRITICAL] if severity_order.index(alert.severity) < severity_order.index(self.severity_threshold): return None return alert
Вся логика «что считать критичным» сосредоточена в системном промпте. Это сознательное решение: промпт можно менять без переобучения модели, а врачи-эксперты могут его редактировать без навыков программирования.
Фрагмент системного промпта:
ПРАВИЛА: 1. Ты НЕ ставишь диагнозов. Ты только СИГНАЛИЗИРУЕШЬ о критическом. 2. Молчи (severity: "none"), если: - Состояние стабильное, хроническое, контролируемое - Жалобы типичные для данного пациента 3. Сигнализируй ТОЛЬКО если: - Признаки инсульта (гемипарез + дизартрия + факторы риска) - Признаки инфаркта (боль в груди + критический тропонин) - Признаки сепсиса (лихорадка + тахикардия + гипотензия + лейкоцитоз) - Результаты обследований противоречат действиям врача - Пропущен критический шаг УЧИТЫВАЙ КОНТЕКСТ: - Повышенное давление у гипертоника — НЕ критично - Частые обращения с одними жалобами — НЕ критично - НО: новые симптомы на фоне хронического — МОЖЕТ быть критично
LLM отвечает строго в JSON:
{ "severity": "critical", "title": "Признаки ишемического инсульта", "explanation": "Гемипарез + дизартрия у пациентки с фибрилляцией предсердий. Заключение КТ не поступило.", "recommended_actions": ["Срочная консультация невролога", "Запросить заключение рентгенолога"], "time_window": "Терапевтическое окно 4 часа", "confidence": 0.92, "routing_specialty": "невролог" }
Или, если всё нормально: {"severity": "none"}. Система молчит.
Слой 2 — Диспетчер
Когда Слой 1 генерирует алерт, диспетчер автоматически решает, кому его отправить. На скорой это критически важно: пока фельдшер ещё в пути, дежурный невролог в сосудистом центре уже получает данные и готовится.
class Dispatcher: def determine_specialty(self, alert: Alert) -> str: """Определить нужного специалиста по содержанию алерта.""" # Сначала — из ответа LLM (поле routing_specialty) if alert.routing_specialty: return alert.routing_specialty # Fallback: поиск по ключевым словам text = f"{alert.title} {alert.explanation}".lower() for specialty, keywords in ROUTING_RULES.items(): if any(kw in text for kw in keywords): return specialty return "реаниматолог" # универсальный fallback async def dispatch(self, alert, patient_context_summary): specialty = self.determine_specialty(alert) on_duty = self.find_on_duty(specialty) for specialist in on_duty: await self._send_to_specialist(specialist, payload) return RoutingDecision( target_specialty=specialty, target_facility=on_duty[0].facility, specialists_notified=[s.name for s in on_duty], )
Маппинг специальностей к ключевым словам:
ROUTING_RULES = { "невролог": ["инсульт", "онмк", "гемипарез", "дизартрия", "парез", "кома"], "кардиолог": ["инфаркт", "окс", "stemi", "тропонин", "аритмия"], "хирург": ["кровотечение", "перитонит", "ранение", "перфорация"], "реаниматолог": ["сепсис", "шок", "остановка дыхания", "анафилаксия"], # ... }
Список дежурных специалистов настраивается в config.yaml:
dispatch: enabled: true specialists: - name: "Дежурный невролог" specialty: невролог facility: "Сосудистый центр БСМП" contact: "https://mis.bsmp.local/api/alerts" contact_type: webhook
Слой 3 — Консилиум
Запускается только по запросу врача. 19 специалистов в реестре — от невролога до неонатолога. Система не использует фиксированный набор: сначала LLM анализирует случай и определяет, кого позвать.
AGENT_REGISTRY = { "невролог": AgentProfile(name="Др. Невролог", specialty="невролог", style="осторожный, ориентирован на доказательную медицину"), "терапевт": AgentProfile(name="Др. Терапевт", specialty="терапевт", style="практичный, ищет простые решения"), "травматолог": AgentProfile(name="Др. Травматолог", specialty="травматолог", style="оценивает переломы, иммобилизацию, риск жировой эмболии"), "акушер": AgentProfile(name="Др. Акушер-гинеколог", specialty="акушер-гинеколог", style="беременность, эклампсия, HELLP-синдром, влияние лекарств на плод"), # ... ещё 15 специалистов }
Для политравмы система позовёт хирурга + травматолога + нейрохирурга + анестезиолога + реаниматолога. Для инсульта — невролога + кардиолога. Для беременной — акушера + гинеколога + неонатолога.
Каждый агент получает свой системный промпт с «характером» и отвечает структурированно:
{ "assessment": "Оценка ситуации с моей точки зрения", "agree_with_doctor": "С чем согласен в текущем плане", "disagree_with_doctor": "С чем не согласен и почему", "proposed_plan": "Моё предложение по лечению", "simpler_alternative": "Есть ли более простой подход?", "risks": "Какие риски я вижу" }
Контекст пациента: откуда берутся данные
Система подключается к МИС через плагины-коннекторы. Каждый коннектор реализует интерфейс BaseConnector:
class BaseConnector(ABC): @abstractmethod async def get_patient_context(self, patient_id: str) -> PatientContext: """Получить полный контекст пациента из МИС.""" ...
PatientContext — это всё, что система знает о пациенте:
@dataclass class PatientContext: patient_id: str age: int sex: str setting: str # "скорая" / "приёмный покой" / "стационар" chronic_diagnoses: list[Diagnosis] # из базы current_medications: list[Medication] # из базы allergies: list[str] # из базы recent_visits: list[Visit] # из базы recent_labs: list[LabResult] # из базы current_complaints: str # от врача current_vitals: dict[str, Any] # от врача current_examination: str # от врача current_imaging: str # от врача current_doctor_assessment: str # что думает врач
Метод to_prompt() превращает всё это в текст для LLM:
УСЛОВИЯ: приёмный покой ПАЦИЕНТ: Ж, 62 лет ХРОНИЧЕСКИЕ ЗАБОЛЕВАНИЯ: Фибрилляция предсердий (I48); Эссенциальная гипертензия (I10) ТЕКУЩИЕ ПРЕПАРАТЫ: Варфарин 5 мг 1 раз в день АЛЛЕРГИИ: Пенициллин ТЕКУЩИЕ ЖАЛОБЫ: Внезапная слабость в левой руке, нарушение речи. Симптомы 1.5 часа назад. ВИТАЛЬНЫЕ ПОКАЗАТЕЛИ: АД: 180/100, ЧСС: 92 аритмичный, SpO2: 96% ОСМОТР: Левосторонний гемипарез. Дизартрия. Сглаженность левой носогубной складки. ДАННЫЕ ВИЗУАЛИЗАЦИИ: КТ выполнена. Заключение рентгенолога НЕ ПОСТУПИЛО. ОЦЕНКА ВРАЧА: Гипертонический криз. План: перевод в ОКБ.
LLM видит всю картину — включая то, что фибрилляция предсердий + гемипарез + дизартрия = кардиоэмболический инсульт, а не гипертонический криз. И что заключение КТ отсутствует. И что перевод в ОКБ — потеря времени.
Приватность: данные не покидают больницу
По умолчанию MedSentinel использует Ollama — локальную LLM. Модель запускается на сервере больницы, данные никуда не уходят.
Но если нужна более мощная модель (Claude, GPT, DeepSeek), есть прокси с обезличиванием:
def _anonymize(text: str) -> str: """Обезличивание перед отправкой в облако.""" # ФИО → [ФИО] text = re.sub(r"[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+", "[ФИО]", text) # Телефоны → [ТЕЛЕФОН] text = re.sub(r"[\+]?[78][\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}", "[ТЕЛЕФОН]", text) # СНИЛС → [СНИЛС] text = re.sub(r"\b\d{3}-\d{3}-\d{3}\s?\d{2}\b", "[СНИЛС]", text) # Медицинские данные СОХРАНЯЮТСЯ — они нужны для анализа return text
ФИО, телефоны, СНИЛС, паспортные данные, email, адреса — удаляются. Диагнозы, симптомы, препараты, результаты анализов — остаются, потому что без них анализ невозможен.
Выбор backend — за пользователем:
llm: provider: ollama # локально, данные в больнице # provider: api_proxy # облако, с обезличиванием # anonymize: true
Демо: 4 кейса из реальной практики
В репозитории — 4 демонстрационных случая в формате JSON. Каждый — из реальной клинической практики (обезличенный).
Кейс 1: Инсульт (пропущенный). Женщина 62 лет, фибрилляция предсердий, гемипарез, дизартрия. Заключение КТ не поступило. Врач ставит «гипертонический криз». Система должна: АЛЕРТ.
Кейс 2: Бабушка-паникёрша. 74 года, гипертония + тревожное расстройство, 5-е обращение за 2 месяца с одинаковыми жалобами. Давление 152/88, осмотр без патологии. Система должна: МОЛЧАТЬ.
Кейс 3: Сепсис (недооценённый). Мужчина 45 лет, диабет, незаживающая рана стопы. Лейкоциты 18.7, СРБ 186, лактат 4.1, прокальцитонин 8.5. АД 88/55. Врач: «инфицированная рана, усилить антибиотик». Система должна: АЛЕРТ (сепсис, шок).
Кейс 4: Скорая. Мужчина 58 лет, фибрилляция предсердий, FAST+, GCS 13. Фельдшер: «гипертонический криз, везу в ЦРБ». Система должна: АЛЕРТ + маршрутизация в сосудистый центр.
Запуск:
# Без LLM — покажет данные и что будет отправлено python examples/run_demo.py --no-llm # С Ollama — полный цикл ollama pull llama3.1:8b python examples/run_demo.py python examples/ambulance_demo.py # скорая с маршрутизацией
Почему LLM, а не классический ML
Классический подход — обучить RandomForest или XGBoost на размеченных данных. Это хорошо для узких задач (предсказание сепсиса по виталам), но не работает для нашей задачи, потому что:
Контекст. Одни и те же показатели могут быть нормой для одного пациента и катастрофой для другого. Давление 180/100 у гипертоника — рабочее. У беременной — преэклампсия. ML-модель без огромного контекстного окна это не различит.
Свободный текст. Записи врачей — неструктурированный текст на русском. «Сглаженность левой носогубной складки» — это инсульт. Классический ML не умеет это читать.
Объяснимость. Врачу нужно не «вероятность 0.87», а «Гемипарез + дизартрия + фибрилляция предсердий в анамнезе → возможен кардиоэмболический инсульт». LLM генерирует объяснение на человеческом языке.
Данные. Для обучения ML нужны тысячи размеченных случаев с исходами. На русском языке таких открытых датасетов нет. LLM работает zero-shot — без дообучения.
Промпт вместо переобучения. Когда обновляются клинические рекомендации, достаточно поправить текст промпта, а не переобучать модель.
Это не значит, что LLM идеальна. Она может галлюцинировать, пропускать критичное, срабатывать ложно. Именно поэтому в промпте жёстко прописаны правила, а порог severity_threshold настраивается.
Ограничения (честно)
Качество локальных моделей. Llama 8B или Mistral 7B значительно слабее GPT-4 или Claude в медицинском reasoning. Могут пропустить сложный случай.
Нет валидации на клинических данных. Пока это гипотеза, а не доказанный инструмент. Нужны ретроспективные прогоны на реальных случаях с известными исходами.
Ложное чувство безопасности. Если система стоит и молчит — врач может подсознательно расслабиться. Поэтому в README жирно: «НЕ полагайтесь как на единственный инструмент контроля».
Не сертифицирована как SaMD. Для клинического использования в РФ нужна регистрация в Росздравнадзоре. Сейчас это исследовательский / образовательный инструмент.
Интеграция с МИС. В российских больницах зоопарк систем без нормальных API. Коннекторы нужно писать под каждую МИС отдельно. Ядро универсальное, но плагины — работа сообщества.
Аналоги
В СНГ открытых аналогов нет. Есть коммерческие СППВР (СберМедИИ ТОП-3, ЕМИАС СППВР, Webiomed, Lexema-Medicine) — все закрытые, платные, без фонового мониторинга.
В мире ближайший — AI-TEW-Framework (npj Digital Medicine, март 2026): tiered early warning с LLM-фильтром. Код на GitHub, но нет маршрутизации и консилиума.
MedSentinel занимает пустую нишу: open-source + фоновый мониторинг + маршрутизация + LLM + русский язык.
Структура репозитория
medsentinel/ ├── core/ │ ├── analyzer.py # Тихий сторож (Слой 1) │ ├── context.py # Сборщик контекста пациента │ ├── alert.py # Модель алерта + парсер ответа LLM │ ├── dispatcher.py # Диспетчер маршрутизации (Слой 2) │ └── consilium.py # Мультиагентный консилиум, 19 специалистов (Слой 3) ├── connectors/ │ ├── base.py # Интерфейс коннектора │ ├── fhir.py # FHIR/HL7 (заглушка) │ └── csv_demo.py # Демо-коннектор ├── llm/ │ ├── base.py # Интерфейс LLM-провайдера │ ├── ollama.py # Локальная модель │ └── api_proxy.py # Облачный API с обезличиванием ├── notifiers/ │ ├── email.py, webhook.py, log.py ├── pipeline.py # Полный конвейер ├── config.py # Конфигурация ├── demo_data/ # 4 демо-кейса (JSON) ├── examples/ # Скрипты запуска └── tests/ # Тесты
28 файлов Python, 2500+ строк, 4 демо-кейса, 3 вида документации.
Как помочь
Проект на ранней стадии. Нужны:
Врачи — для описания реальных случаев, валидации логики промптов, определения критериев критичности.
Разработчики — для написания коннекторов к конкретным МИС (ЕМИАС, 1С:Медицина, qMS, OpenMRS).
Исследователи — для валидации на клинических данных, замеров чувствительности и специфичности.
Код: github.com/gogi2305/medsentinel Лицензия: Apache 2.0
Если эта система когда-нибудь спасёт хотя бы одну жизнь или убережёт хотя бы одного врача от несправедливого обвинения — значит, уже не зря.
