Решение касается выхода сервиса актуальности (PatientValidityProfile): для каждого наблюдения — finalStatus ∈ {representative / with_caveat / outdated / lifelong / error}, плюс contextSignals[], routeTrace, pastShelf, плюс human-readable reasoning от LLM.

Контекст

Сервис на каждый запуск выдаёт массив классификаций per observation. На сегодня выход ephemeral: gate перед summary-генератором, классификатор отрабатывает → результат уходит в downstream-пайплайн → процесс завершается. Persistence пока нет.

Возможные мотивы persistence:

  • Audit trail — врачу через неделю нужно увидеть «почему этот биомаркер на дашборде помечен как устаревший»
  • Re-use без повторного LLM-вызова — следующий запуск пайплайна на том же пациенте через час; LLM-вызов стоит и токенов, и времени
  • FHIR-export для клиентов / внешних консьюмеров — если когда-то надо отдать «помеченные» данные наружу

Сегодня всё это либо некритично (ephemeral хватает), либо нет такого потребителя. Открытое — нужен ли persistence вообще (если нет — этот decision закрывается «делаем ephemeral» и остальные оси не имеют значения).

Эта страница описывает оси выбора, если решим персистить.

Ось 1 — нужен ли persistence

ВариантКогда подходит
Ephemeral, не сохраняемgate один раз → summary тут же генерится → данные сервиса дальше не нужны. Re-run = пересчитать.
Persistence в FHIRнужен audit trail и/или re-use, и/или внешний экспорт
Persistence в Postgres (Prisma)внутренний кэш для re-use, без претензий на «клиническую запись»

phi-in-fhir-not-sql (PHI должна быть в FHIR, не в Postgres) — если выводы содержат PHI (биомаркер + значение + диагноз), Postgres-вариант под вопросом. Артур формулировал выводы как «над-PHI», не сами PHI.1

Ось 2 — FHIR shape (если персистим в FHIR)

Вариант A — Condition per observation

Один Condition-ресурс на каждую запись классификации:

Condition {
  code: { text: "biomarker-actuality" }
  subject: Patient/...
  evidence: [Observation/<биомаркер-id>]     // на какой замер опирается
  clinicalStatus: "active" | "inactive" | "resolved"   // active = representative; inactive = устарел
  onsetDateTime: <дата замера>
  abatementDateTime: <дата устаревания>      // horizon полки
  note: [{ text: "Caveat: T2D active" }]      // contextSignals
}

[[../domain/fhir-condition|Condition.abatementDateTime]] — реальное поле FHIR R4 (не extension), «дата завершения состояния». clinicalStatus — стандартная enum (active/recurrence/relapse/inactive/remission/resolved).

За:

  • FHIR-запрос «какие биомаркеры актуальны» становится естественным: Condition?code=biomarker-actuality&clinical-status=active&patient=...
  • Re-use существующих FHIR-механизмов вместо изобретения extension’ов (zero-extensions-fhir)
  • Дашборд врача получает «активные/устаревшие состояния» однородным списком — клинические диагнозы и наши «service-Condition» лежат рядом

Против:

  • Возможная семантическая натяжка. Condition в FHIR spec — «clinical condition, problem, diagnosis, or other event/situation» — формально это шире чем «заболевание», но в практике downstream-консьюмеры (внешние FHIR-системы, аналитика) обычно интерпретируют как болезнь/диагноз. Внешний потребитель, увидев «active Condition: biomarker-actuality», может решить что у пациента диагноз с таким названием. Сегодня внешних потребителей нет, но стратегия разделения «служебные vs клинические» нужна — это общий вопрос, решённый в fhir-resource-origin-and-lifecycle (meta.tag с origin-кодом).
  • Куда положить reasoning — общий для всех вариантов A/B/C/D, см. Открытые вопросы.

Вариант B — ClinicalImpression per observation

fhir-clinical-impression — FHIR-ресурс для «суждения agent’а о состоянии пациента на момент времени». Классификатор актуальности делает узкое подмножество такого суждения — «насколько этот замер репрезентативен сейчас» — что попадает под определение CI.

За:

  • Точно та семантика, для которой ClinicalImpression и создавался: «вот моё суждение по такому-то поводу на эту дату». Классификатор актуальности делает именно это (только с очень узким scope’ом — «valid / outdated»).
  • Уже используется в системе для результатов параметр-анализа (biomarker-analysis-pipeline rich-output). Там CI несёт клинические выводы; формально наш «replazentative/outdated» — тоже клиническое суждение (про репрезентативность замера), хотя и узкое. Паттерн знакомый.

Против:

  • У ClinicalImpression нет clinicalStatus / abatementDateTime — он точечный (effectiveDateTime), а не «состояние во времени». «Устаревание» приходится выражать через более новый CI, перебивающий старый (по subject + code). Или через extension для TTL — мы стараемся избегать.
  • Carry-over: стоит зафиксировать общее разделение FHIR-ресурсов на «точечные» (Observation / ClinicalImpression / Procedure) и «длящиеся» (Condition / MedicationStatement / AllergyIntolerance) на отдельной обзорной странице — пригодится для аналогичных decisions.

  • Запрос «активные CI» становится не-тривиальным: нужно найти самый последний CI для каждой пары (patient, biomarker) и проверить его effectiveDateTime + horizon. На стороне клиента, не FHIR-search.

TTL для ClinicalImpression — открытое

Реально ли «более новый CI перебивает старый» работает в нашем случае: производительность search’а по «последнему CI для пары», конфликты update-on-rerun, что делает downstream если видит два CI с близкими датами. Альтернатива — TTL через extension, которой мы предпочитаем избегать.

Вариант C — Observation со специальным code

Каждая классификация — самостоятельный Observation:

Observation {
  code: { text: "biomarker-actuality:Hemoglobin" }
  subject: Patient/...
  effectiveDateTime: <дата>
  valueCodeableConcept: { text: "representative" | "outdated" | ... }
  derivedFrom: [Observation/<биомаркер-id>]
  note: [{ text: "..." }]
}

За:

  • Проще всего семантически: «на дату X классификатор намерил у этого биомаркера значение validity = Y». Это буквально и есть observation.
  • Однородность: все «измерения» — Observation-ы.

Против:

  • Раздувает Observation-таблицу: на каждое реальное лабораторное наблюдение прибавляется второе «meta-наблюдение».
  • Семантика «состояние длится» теряется — Observation точечный, нет понятия «active until».
  • Никакого прямого конфликта с narrative-to-fhir pipeline здесь нет (он про другую задачу), но нужно явно поставить category != laboratory чтобы lab-фильтры в других местах не подобрали эти meta-Observation как реальные лабы. См. также fhir-resource-origin-and-lifecycle про общую стратегию пометки служебных ресурсов.

Вариант D — Composition-обёртка над набором Condition/Observation-записей

Один Composition на пациента (per запуск классификатора), у которого есть один общий event.period.end или аналогичный TTL-поле — «весь snapshot актуален до даты Y». Внутри Composition.section[] — ссылки на сами биомаркер-Observation-записи + per-section enum актуальности. Это снимает «N мелких Condition-записей» в пользу одного агрегата.

За:

  • Один артефакт = один snapshot актуальности пациента на момент времени
  • Уже используем Composition для overview (fhir-composition) — паттерн знакомый
  • Один TTL на агрегат — проще читать чем N разных abatementDateTime

Против:

  • Гранулярность теряется: per-observation TTL разный (acute = 30d, metabolic = 90d, lifelong = ∞). Один общий TTL на Composition либо вынуждает брать минимум (часто инвалидирует слишком рано), либо игнорирует различие. Решение — внутрь section[] класть и per-section TTL, тогда «общий» TTL становится формальным wrapper’ом.
  • При повторном запуске старый Composition надо либо обновлять (PUT с тем же identifier), либо создавать новый и пометить старый как superseded. Иначе они будут копиться. Та же дилемма решена в health-report-versioning-model для нашего patient summary — стратегия переиспользуется.

Вариант E — гибрид: Condition для статуса + Observation для reasoning

Этот вариант схлопывается с Вариантом A: в A Condition.evidence уже ссылается на исходный Observation-биомаркер, а текст reasoning всё равно куда-то надо положить (note[] на Condition / отдельный DocumentReference / на сам Observation как note[]). Эта развилка — общая для всех вариантов (см. Открытые вопросы). Самостоятельным вариантом не остаётся.

Вариант F — один patient-level ClinicalImpression + sub-extensions per биомаркер (де-факто выбрано в коде)

Это то, что фактически работает сегодня (без формального закрытия decision-page — отсюда status: draft). Implementation в apps/analysis-worker/src/inngest/functions/health-report.function.ts:951-980 + services/biomarker-analysis-writer.ts.

Shape: один ClinicalImpression на пациента (subject = Patient/X), внутри extension[] — массив bloodgpt-parameter-analysis sub-extensions, по одной на каждый биомаркер. Каждая sub-extension несёт:

parameterName, fhirObservationId, loincCode, value, unit, referenceRange,
interpretation, testDate, isHealthy, actualityStatus, shelfClassification,
clinicalInterpretation (prose от Reasoner+Writer; "" для likely_outdated stub),
foundContext[], missingContext[], referenceContext[], additionalWorkup[],
citations[], whatAdditionalDataWouldClarify

Conditional PUT по identifier (один CI = один resource id на пациента), versionId увеличивается на каждый run, _history хранит все версии — механизм описан в health-report-versioning-model.

За:

  • Atomic write всех verdict’ов за один FHIR-request — нет race condition «часть biomarker’ов сохранилась, часть нет».
  • Single resource id stable для _history versioning. Snapshot navigation между запусками работает через Composition.event[0].period.start = asOfDate правило (см. health-report-versioning-model).
  • Один CI = один pointer для downstream-консьюмеров (PDF, exports, audit). Не пересылают «какой именно CI» — всегда один.
  • Совместное хранение «настоящих» analysis-entries (с prose) и stub’ов (без prose) — один writer-code-path, отличаются только пустотой полей.

Против:

  • Opaque для FHIR search. Нельзя ClinicalImpression?clinical-status=active или фильтровать по конкретному биомаркеру через стандартный FHIR query — нужно тянуть весь CI и парсить sub-extensions клиентом. Внешний consumer без custom parser’а видит «один CI с какими-то extensions» и не может ничего извлечь.
  • Нет point-in-time semantics на уровне ресурса. effectiveDateTime один на весь CI (latest asOfDate), но verdict’ы внутри computed относительно asOfDate parent CI’s versionId. Per-biomarker дата verdict’а не сохраняется — sub-extension несёт только testDate (когда Observation измерена), не «когда классификатор оценивал». Восстанавливается через _history всего CI.
  • Audit trail тяжелее. Изменение verdict’а одного биомаркера = новая versionId всего CI. В _history сложно проследить какое именно поле изменилось — diff’ать sub-extensions внутри CI.
  • Невидимо downstream-консьюмерам. Внешние FHIR-системы (Google Healthcare API export, integration через fhir-gravity), не знающие про BloodGPT-specific sub-extension URL, видят «один ClinicalImpression» — не могут раскрутить per-biomarker без custom parser. Стандартные FHIR-tools (HAPI explorer, Inferno test suite) их игнорируют.
  • Расходится с буквой Варианта B спеки. Variant B изначально подразумевал «один CI per observation» — наш F отличается тем, что внутрь одного CI положены ВСЕ биомаркеры. Это самостоятельный design choice, не реализация B.

Ось 3 — отделение служебных vs клинических — уже решено

Это общий вопрос для всех AI-generated FHIR ресурсов, не только для актуальности. Уже зафиксирован в fhir-resource-origin-and-lifecycle: используем meta.tag с system: bloodgpt.com/fhir/CodeSystem/origin и code ai-generated (плюс user-uploaded / external-data для других классов). Что бы мы ни выбрали из Вариантов A–D — meta.tag = ai-generated обязателен.

Дополнительно по category / code.coding[].system — это сужающие фильтры внутри AI-generated класса; решаются в момент имплементации, не отдельным архитектурным выбором.

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

  • Нужен ли persistence вообще? Variant F уже persist’ит в FHIR — этот вопрос для F снят (persistим). Но если решим вернуться к A/B/C/D (например, чтобы получить queryable shape) — вопрос «зачем именно сохраняем» становится релевантным.
  • Стратегия при удалении Observation. Sub-extension в Variant F несёт fhirObservationId указывающий на Observation. Если она удалена — dangling reference. См. document-deletion-strategy (отдельная decision-page) для cascade-стратегии.
  • Стоит ли мигрировать к queryable shape (A или B-original). Variant F работает но opaque. Если у нас появится consumer которому нужно найти все актуальные биомаркеры пациента через FHIR search — F не справится без custom layer. Триггер для пересмотра.
  • Где живёт reasoning-текст (LLM-объяснение «почему такой статус») — общий для всех вариантов. Сейчас в Variant F prose от Reasoner+Writer лежит в clinicalInterpretation поле sub-extension, для outdated stub поле пустое. Реальные классификатор-reasoning’и (почему именно outdated) не сохраняются.
  • Применимо ли это к самим диагнозам пациента. Артур упоминал: «глюкоза за сегодня со связанным холестерином 10 лет назад — связь уже не валидна».1 Возможно тот же паттерн ляжет и на это. Эта развилка — за пределами текущего скоупа.

Связано

Сноски

  1. Созвон Ильдара с Артуром, 2026-05-12, https://github.com/Realai-plus/meeting-digests/blob/main/data/digest/2026/05/2026-05-12T11%3A45%3A00.000Z_%D0%A1%D0%B5%D1%80%D0%B2%D0%B8%D1%81_%D0%B2%D0%B0%D0%BB%D0%B8%D0%B4%D0%BD%D0%BE%D1%81%D1%82%D0%B8-%D1%80%D0%B5%D0%BF%D1%80%D0%B5%D0%B7%D0%B5%D0%BD%D1%82%D0%B0%D1%82%D0%B8%D0%B2%D0%BD%D0%BE%D1%81%D1%82%D0%B8-%D1%80%D0%B5%D0%BB%D0%B5%D0%B2%D0%B0%D0%BD%D1%82%D0%BD%D0%BE%D1%81%D1%82%D0%B8_%D0%B1%D0%B8%D0%BE%D0%BC%D0%B0%D1%80%D0%BA%D0%B5%D1%80%D0%BE%D0%B2_01KRE02872CTM2V6CEERAQ8EVD.md — обсуждение «нужно ли сохранять выводы классификатора», «observation мы превращаем в condition», composition-обёртка или per-observation один кондишн, согласованное переименование сервиса на «актуальность». 2