Пайплайн, который превращает свободный медицинский текст (анамнез, диагнозы, лекарства, аллергии, процедуры, family history, симптомы, vital signs) — в типизированные FHIR R4 ресурсы. Это наш конвертер «клинический нарратив → структурированный patient state», и заодно — единственная formal-ish граница в команде между тем, что попадает в Condition vs Observation vs прочие ресурсы.

Source-of-truth — engineering docs в репо: docs/NARRATIVE_TO_FHIR.md (общий пайплайн), docs/BG-1342-MIGRATION-PLAYBOOK.md (FHIR-шейпы per resource), packages/analysis-core/src/prompts/narrative_to_entities.md (правила извлечения). Эта страница — обзор и нюансы; за деталями шейпов и параметров запуска — туда.

Не для лаб-таблиц

Жёсткая граница: narrative-to-fhir не обрабатывает строки лабораторной таблицы. Те идут через другой пайплайн (services/image-recognition/ + save-fhir-preliminary.function.ts). Defense in depth — в коде стоит фильтр category !== "laboratory", и оркестратор логирует WARN если LLM попыталась вытащить лаб-row из narrative (drift промпта).

В observations[] сюда попадают только prose-mentioned показатели: BP в тексте «BP 122/77», BMI / вес / рост из клинической записи, imaging findings, physical exam, survey-шкалы (PHQ-9 и т.д.).

Три фазы пайплайна

NarrativeBlock[]
   │
[1] extractEntitiesFromNarrative — multimodal LLM (текст + опционально base64-страницы PNG) →
   │   typed JSON через Zod (ExtractedMedicalData)
   ↓
ExtractedMedicalData { conditions, medications, allergies, procedures, family_history, symptoms, observations }
   │
[2a] SNOMED coding — quick-lookup table (~120 терминов) + LLM fallback на промахи
   │
[2b] FHIR builders — отдельный билдер на тип:
       Condition / MedicationStatement / AllergyIntolerance / Procedure /
       FamilyMemberHistory / Observation (не-lab)
   ↓
FhirResourceContainer[] { resource_type, resource_id, fhir_json, snomed_code?, snomed_display? }

Полные сигнатуры функций, schema’ы, гранулярность retries — в engineering-doc’ах (ссылки в lede).

Концепт → FHIR resource: правила маппинга

Это правила, которые LLM применяет в Phase 1. Не все границы жёсткие — некоторые серые зоны разрешает сама модель (см. дальше). Базовая таблица:

Концепт из narrativeFHIR resourceКатегория / уточнение
Диагноз, хроническое заболевание, синдром (sepsis, DKA), problem-list (alcohol use disorder)[[../domain/fhir-condition|Condition]]clinical_status ∈ {active, resolved}, verification_status ∈ {confirmed, provisional, unconfirmed}
Препарат (текущий / прошлый / planned)[[../domain/fhir-medication-statement|MedicationStatement]]status ∈ {active, stopped, intended, unknown}
Аллергия / непереносимость[[../domain/fhir-allergy-intolerance|AllergyIntolerance]]category ∈ {food, medication, environment}
Хирургическая или диагностическая процедура (колоноскопия, эндоскопия)[[../domain/fhir-procedure|Procedure]]status = completed (для исторических); date обязательно если есть
Family history (родственники + их условия)FamilyMemberHistoryrelation через V3RoleCode (FTH/MTH/BRO/SIS/…)
Симптом (current complaint: fatigue, digestive issues, weight change)[[../domain/fhir-observation|Observation]]category = exam; value_string если qualitative
Vital signs (BP, BMI, HR, рост, вес из текста)Observationcategory = vital-signs
Imaging finding из прозыObservationcategory = imaging
Survey / risk score (PHQ-9, MOCA)Observationcategory = survey
Social history (smoking status, alcohol use по US Core)Observationcategory = social-history
Past illness, помеченное «resolved»Condition (не Procedure)clinical_status = resolved
Lab-значение, упомянутое в прозе (НЕ из таблицы)Observationcategory = laboratory — редкое исключение; lab-таблицы идут другим пайплайном

Из этого видно главное — Observation это широкое ведро, и категория (vital-signs / social-history / exam / survey / imaging / laboratory) несёт ровно столько же смысла, сколько resource type. Поэтому пара (resource_type, category) — это минимальная единица маппинга, а не resource_type сам по себе.1

Серые зоны — где LLM решает

Condition vs Observation:social-history — формально обе трактовки могут быть легальны для одной и той же сущности. У нас жёсткого правила нет, и LLM в Phase 1 принимает решение по контексту (что чаще — это повод для разметки экспертом и закрепления правила).

Известные кандидаты на серую зону (Никита, обсуждение в треде1):

  • Беременность. По US Core 6.1+ статус беременности из чек-листа / чата идёт в Observation с category = social-history, а не в Condition. Но осложнения беременности (гестационный диабет, преэклампсия) — это Condition. Граница тонкая.
  • Менопауза. Тоже легально и как Condition (problem-list entry), и как Observation:social-history (физиологический статус). У нас неоднозначно.
  • Ожирение / underweight. Аналогично — может быть и Condition (clinical problem), и Observation:vital-signs (через BMI).

Что НЕ идёт в Condition (правило, которое в команде звучит чаще всего):

  • Преаналитические факторы (fasting, recent meal, post-exercise) — это не состояние пациента, а контекст замера; не моделируются как Condition вообще
  • Демографические факторы (age, sex как standalone) — уже кодируются в reference-range selection / Patient ресурсе
  • Transient behaviors, которые не попадают в структурированные записи

Где применяется

  • Миграция narrative-полей из .NET — Stage 2 в активном .NET → новая система cutover (использует imageScope: "none", чистый текст). Подробности run-flow — в BG-1342-MIGRATION-PLAYBOOK.
  • Multimodal recognition в b2b-api / b2c-dashboard upload-flow — пайплайн вызывается на текстовых блоках + page-images recognition’а.
  • Source-of-truth для разметки графа. Артур договорился размечать связи в biomarker-graph через FHIR resource type — вместо своих ad-hoc лейблов; теперь маппинг этой страницы (Condition / Observation:vital-signs / Observation:social-history / …) можно переиспользовать как канон.1

Открытые вопросы

  • Хардкодить ли серые зоны (беременность → social-history; менопауза → ?), или продолжать давать LLM принимать решение по контексту. Trade-off: hard-rule даёт детерминизм, но фиксирует один из двух легальных вариантов на стороне FHIR; LLM-decides гибкое, но создаёт inconsistency между прогонами.
  • Cross-app contract. narrative-to-fhir сегодня живёт в @repo/analysis-core и используется в b2b-api upload-flow + миграции; если появится третий потребитель (расширенная b2c-форма / external import) — нужен публичный API или версия контракта.
  • Confidence-threshold для SNOMED. Сегодня coding с confidence: 0.3 проходит как есть. Для миграции стоит добавить cutoff — иначе в FHIR попадают мусорные коды.
  • Idempotency. resource_id — UUID на каждый прогон; для рерана миграции нужны deterministic ids (хэш(patient + blocks)). Stage 2 в BG-1342 уже это делает; ad-hoc запуски — нет.
  • Расширенная карта маппинга — стоит, чтобы и Артур (разметчик графа), и LLM-пайплайны, и человеческие ревьюеры читали один артефакт. Эта таблица — кандидат на роль такого канона; альтернатива — отдельная страница для системного промпта narrative_to_entities.md.

Связано

Концептуальные границы — на каждой FHIR resource page: fhir-condition, fhir-observation, fhir-medication-statement, fhir-allergy-intolerance, fhir-procedure. Категории внутри Observation/Condition — fhir-resource-categories.

Сшивки и решения: fhir-resource-origin-and-lifecycle — как помечать AI-generated vs user-uploaded; storing-biomarker-actuality — где Condition-like модель для actuality предлагается; biomarker-graph — куда уходит размеченный граф; medical-expert-loop — как Катя помогает закрывать серые зоны на конкретных кейсах; recognition — upstream-пайплайн, который кормит narrative-to-fhir текстовыми блоками.

  • Engineering-doc’и в репо (см. lede) — source-of-truth для шейпов и параметров запуска.

Сноски

  1. Slack thread в #fhir (Артур + Никита + Ильдар, 2026-05-12) — https://realaicorp.slack.com/archives/C094G7UG82J/p1778507499494019?thread_ts=1778507499.494019&cid=C094G7UG82J — обсуждение «Condition или нет» как границы разметки графа; нюансы US Core про беременность / менопаузу / ожирение; пара (resource_type, category) как минимальная единица маппинга; HH/LL критические значения out of scope (только N/H/L/A). 2 3