Решение касается выхода сервиса актуальности (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 для
_historyversioning. 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 Возможно тот же паттерн ляжет и на это. Эта развилка — за пределами текущего скоупа.
Связано
- biomarker-actuality-service — сервис, чей выход обсуждается
- fhir-condition — кандидат A
- fhir-clinical-impression — кандидат B
- fhir-observation — кандидат C
- fhir-composition — кандидат D (обёртка)
- fhir-resource-categories — для оси 3 (отделение служебных записей)
- biomarker-actuality-thresholds — старый прямолинейный TTL-подход, поглощается v3 (но decision о persistence актуален и для него)
- zero-extensions-fhir — почему extension’ы не хочется
- phi-in-fhir-not-sql — где PHI должна жить (если выводы содержат PHI)
- staged-output-fhir-storage — соседняя decision: куда складывать staged-analysis rich-output; те же оси
- biomarker-actuality-integration — общая сшивка пайплайна, частью которой это решение является
- document-deletion-strategy — что происходит с sub-extension stub’ом если Observation удалена — draft
- actuality-class-ui-rendering — отдельный concern (UI rendering, не storage) — draft
- FHIR R4 spec —
Observation,Condition,ClinicalImpression,Composition
Сноски
-
Созвон Ильдара с Артуром, 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