Решение 2026-02-14 формулировалось как «ровно один custom extension». По факту к 2026-05 их около десятка (см. «Текущее состояние»). Принцип, который остался в силе: каждое новое поле сначала ищет стандартное место в R4, custom extension — последний резорт; «zero / один» — недостижимая формулировка, цель — минимум и явная оправданность каждого.

Контекст

Пайплайн BloodGPT генерирует структурированный AI-вывод: TestOverview (общий narrative + sections), PanelOverview (на каждую панель), FollowUpRecommendations (когда пересдать), ParameterTrends (тренды по параметрам), isHealthy (флаг отклонения), parameterDetails (текст-комментарий на параметр), requiresDoctorPreparation (нужна ли подготовка перед врачом). Нужно положить всё это в FHIR-модель, при этом FHIR-сервер (Google Healthcare API, см. google-healthcare-api) хочет видеть стандартный R4.

Рассматривали

  • Custom resource types — изобретать собственные типа BloodGPTAnalysis. Не FHIR-совместимо, Google Healthcare API такие ресурсы не примет.
  • Extensions на DiagnosticReport — складывать всё в extension[] на стандартном ресурсе. Размывает семантику, мешает Patient/$everything агрегировать содержательно.
  • Composition + CarePlan + Observation с custom extensions для AI-полей — initial Claude-предложение. Каждое наше поле (isHealthy, parameterDetails, requiresDoctorPreparation, и т.д.) → отдельный custom extension с URL http://bloodgpt.com/fhir/StructureDefinition/.... Ильдар подсветил что isHealthy уже стандартизирован как Observation.interpretation (H/L/N из v3-ObservationInterpretation ValueSet).
  • Composition + CarePlan + Observation на чистом R4 — пересмотренный вариант. Все наши AI-поля ложатся на стандартные FHIR-конструкции.

Выбрали: Composition + CarePlan + Observation на чистом R4

Маппинг наших полей на стандартный FHIR:

Наше полеFHIR-стандартCustom?
TestOverview (narrative + sections)Composition с nested section[]нет
PanelOverviewsub-section в Compositionнет
FollowUpRecommendations (включая досдачу)CarePlan.activity[] с kind=ServiceRequestнет
ParameterTrends (исторические значения)Observation с _sort=date (запрос исторических Observations)нет
parameterTrendAnalysis (AI-текст про тренд)Composition Section “Trends” → sub-section per parameter (custom section-type code)нет (см. ниже)
isHealthyObservation.interpretation (H/L/N)нет
parameterDetailsObservation.note[].textнет
requiresDoctorPreparationextension на Compositionда, единственный

На момент решения (2026-02) это давало один custom extension — requiresDoctorPreparation на Composition (стандартного FHIR-аналога для “пациенту нужна особая подготовка перед визитом к врачу” нет). Цель формулировалась как «zero, в крайнем случае один». По факту набор с тех пор вырос — см. «Текущее состояние» ниже. Принцип остался: каждое новое AI-поле сначала ищет стандартное FHIR-место, extension только когда стандартного аналога нет.

Добавлена пятая секция в Composition, с sub-sections на каждый параметр, в которой хранится trendAnalysis (AI-текст про тренд биомаркера). Используются custom section-type коды (bloodgpt.com/fhir/CodeSystem/section-typetrends, trend-analysis). Это не extension — это standard FHIR pattern (Composition.section с custom code, как и остальные секции). Принцип “минимизация custom extensions” не нарушен; скорее подтверждён выбором “section с custom code” вместо “extension с typed value”.

Почему

  • Стандартные поля FHIR работают в любом FHIR-клиенте без нашей schema. Patient/$everything корректно агрегирует.
  • Observation.interpretation — официальный механизм статуса параметра, существует именно под этот use case (см. google-healthcare-api по interpretation ValueSet).
  • Observation.note[] поддерживает authorReference — есть кому атрибутировать AI-комментарий (см. authorship-organization-not-device).
  • CarePlan.activity с kind=ServiceRequest нативно описывает “досдай Ferritin через месяц” — ровно наш use case.
  • Composition.section[] поддерживает nested sub-sections, 1:1 ложится на нашу TestOverview→PanelOverview иерархию.

Graceful degradation — это не про «работает / не работает»

Важный нюанс, который часто упускают: extensions сами по себе не ломают interop. Если downstream consumer не знает наш формальный profile (или мы вообще без profile живём, как сейчас), он всё равно прочитает наши ресурсы — потому что они остаются валидным FHIR R4.

Generic FHIR-клиент при чтении Composition с нашим requires-doctor-preparation extension’ом увидит:

  • Стандартные поля (status, type, subject, sections) — полная семантика, работает в любом FHIR-парсере.
  • extension[] — видит как «unknown extension с URL X и valueBoolean true». FHIR-стандарт ОБЯЗЫВАЕТ любой FHIR-клиент уметь парсить extension array, даже если не понимает конкретный URL.

Это forward compatibility built into protocol — аналог «extra fields в JSON response» в обычных API. Можно добавлять extensions, не ломая существующих consumers.

Поэтому решение «минимизировать extensions» — не про «иначе сломается». Это про:

  • Понятность. Каждый extension — это URL, по которому consumer должен пойти прочитать что мы там подразумевали. Чем меньше таких URL’ов — тем меньше документации читать.
  • Стандартная семантика поверх кастомной. Если можно положить «нездоровый» в Observation.interpretation (стандартный механизм с known semantics) — это лучше чем класть в свой extension, даже если генерация «бесплатна» в обоих случаях. Consumer без profile knowledge получит больше пользы от стандартного поля.
  • Discoverability через Patient/$everything. Стандартные поля попадают в standard aggregations; custom extensions требуют от consumer-а знания «откуда вытаскивать».

То есть extensions — это trade-off между удобством на нашей стороне и понятностью на стороне consumer-а. Решение про их минимизацию зависит от того насколько мы хотим быть понятными для downstream. До тех пор пока единственный consumer — это мы сами (B2C-приложение BloodGPT), extensions можно добавлять свободно. Когда появляется второй consumer (партнёр / EHR-интеграция) — каждый extension становится payload-ом «прочитайте docs»; стандартные поля — нет.

Подробнее про interop-механику и роль meta.profilefhir-profiling.

Ильдар: «isHealthy/parameterDetails → Extensions на Observation. а isHealthy может быть стандартный interpretation в observation» (триггер пересмотра)

Claude (после пересмотра): «Получается ноль custom extensions — всё ложится на стандартный FHIR R4»

Текущее состояние (2026-05): extensions > 1, цель — минимум

Формулировка «ровно один extension» устарела. По мере роста pipeline (V2.5 rich-output, recognition→FHIR, follow-up tiers, trends, patient-summary) набор custom extensions расширился. На 2026-05 в коде используются как минимум:

  • requires-doctor-preparation — Composition; нужна ли подготовка перед визитом. Стандартного аналога нет.
  • bloodgpt-parameter-analysis — ClinicalImpression, repeating; V2.5 per-parameter rich output (clinicalInterpretation, whatAdditionalDataWouldClarify, и т.д.). Стандартного места для structured per-param AI-разбора нет.
  • source-diagnostic-report — Composition (patient-summary); на данных какого DiagnosticReport собрано summary.
  • original-name — Observation; имя параметра как в документе до нормализации.
  • extraction-source — Observation/др.; откуда извлечён ресурс при распознавании (легче, чем полноценный Provenance на каждый Observation).
  • trending-group-id — Observation; группировка биомаркеров в один тренд.
  • urgent-test-tier — CarePlan.activity / ServiceRequest; tier срочности досдачи.
  • activity-priority — CarePlan.activity; приоритет (кандидат на замену стандартным ServiceRequest.priority).
  • current-symptoms, health-status — self-reported данные пациента; место в стандартном ресурсе под вопросом.

Плюс две URL-конвенции одновременно: http://bloodgpt.com/fhir/StructureDefinition/... и http://bloodgpt.com/fhir/extension/....

Действующий принцип, а не запрет:

  • Сначала ищем стандартную R4-конструкцию (Observation.interpretation / .note[] / .referenceRange[], Composition.section с custom section-type code, CarePlan.activity с kind=ServiceRequest, Provenance). Custom section-type коды в собственном CodeSystem — НЕ считаются extension’ом.
  • Extension — когда стандартного места нет (requires-doctor-preparation, bloodgpt-parameter-analysis) или когда стандартный ресурс был бы непропорционально тяжёл.
  • Каждый существующий extension — кандидат на ревизию: убрать / заменить стандартом / схлопнуть.

Carry-over: полная инвентаризация extensions в коде (grep StructureDefinition/, grep fhir/extension/), по каждому — решение оставить/заменить/схлопнуть; унифицировать две URL-конвенции в одну.

Следствия

  • В кодовой базе: fhir-composition-builder.ts и fhir-careplan-builder.ts строят resources преимущественно на стандартном R4; custom extensions ограничены и каждый явно оправдан (см. «Текущее состояние»).
  • AI enrichment (text-комментарии + interpretation H/L/N) добавляется отдельным шагом после save (см. ai-enrichment-separate-step) через Observation.note[] и Observation.interpretation.
  • Внешние FHIR-клиенты (включая будущие интеграции с госпитальными EHR) могут читать наши данные стандартным FHIR-парсером.
  • При появлении новых AI-полей — сначала ищем стандартное FHIR-место, extension только как последний резорт. (Пример 2026-05: per-observation lab-комментарии (inline_interpretation из распознавания) → Observation.note[], conditional-range блоки → Observation.referenceRange[].text — без extension. См. recognize-per-observation-comments.)
  • LLM-summary /home/i/JOBS/BloodGPT/specs/decisions/023-fhir-ai-content-architecture.md (frontmatter “Принято: Вариант C”) — не canonical ADR, а LLM-эксперимент; tactical confirmation Variant C сделан Ильдаром в начале session 065369a0 (2026-03-14, “давай пока оставим вариант C”) и через реализацию в PR #158.

Связано

Источники

Источники: 1 2 3.

Сноски

  1. HL7 v3 ObservationInterpretation ValueSet, accessed 2026-05-17, https://terminology.hl7.org/CodeSystem-v3-ObservationInterpretation.html.

  2. FHIR R4 Composition, accessed 2026-05-17, https://hl7.org/fhir/R4/composition.html.

  3. FHIR R4 CarePlan, accessed 2026-05-17, https://hl7.org/fhir/R4/careplan.html.