Когда LLM-шаг производит вывод per-record (анализ на каждый параметр, классификация на каждое наблюдение), результат надо привязать обратно к конкретному FHIR-ресурсу, из которого этот record пришёл — обычно это Observation (записать AI-добавку на него, сослаться на него в Composition/ClinicalImpression, сматчить при импорте), но в принципе и к другим ресурсам. Эта страница — про то, как это делается у нас и почему.

Принцип: LLM никогда не видит и не генерирует FHIR id

FHIR Observation.id — длинный непрозрачный uuid. Просить LLM скопировать его verbatim из входа в выход — ненадёжно: длинные непрозрачные строки модель в принципе может перепутать (соображение скорее гипотетическое — проверять не на чем, мы id в промпт всё равно не кладём). Поэтому id живёт только в коде, с обеих сторон моста; в промпт он не идёт; LLM возвращает в выводе маленький content-derived ключ, а код по этому ключу делает join обратно к ресурсу (и к его id).

Разделение ответственности:

  • анализ (что значит значение / валидно ли оно / какой драйвер) — работа LLM;
  • служебная привязка (из какого Observation это пришло, куда писать результат назад) — чистый код;
  • мост между ними — маленький content-derived ключ (индекс или имя — см. два варианта ниже).

«Маленький ключ» сейчас бывает двух видов (так сложилось исторически; возможно, в перспективе — решение свести к одному, см. «Открытые вопросы»):

Вариант A — позиционный индекс (validity-классификатор)

validity-classifier (feat/v2-5, Артур):

  • Код держит упорядоченный input-список наблюдений [obs0, obs1, …].
  • buildUserMessage (packages/analysis-core/src/agents/validity-classifier/classifier.mastra.ts) печатает в промпт только клинический контент с маленьким индексом: [0] analyte: "TSH" | value: 2.1 mIU/L (ref: 0.4-4.0) | date: 2026-02-10, [1] ….
  • LLM возвращает обратно observationIndex: 0 — дешёвый 0/1/2-integer. Schema-описание поля (ClassificationSchemaV2.observationIndex в classifier.types.ts): «Copy this integer EXACTLY from the corresponding input row… The caller uses this to map your classification back to the source observation.»
  • Runtime-валидатор validateClassifierOutput (classifier.runtime-validate.ts) делает индекс-схему надёжной: duplicate_observation_index, extra_observation_index (не в 0..N-1), missing_observation_index (что-то из входа не вернулось), hardRaise если строк больше чем входных наблюдений. На успехе сортирует выход по observationIndex. По analyte — только warning analyte_echo_drift, не rejection, не join-ключ.
  • Надёжность индекса обеспечивает не модель, а валидатор: при reject — один retry с подсказкой, не помогло — строка finalStatus: "unknown" (детали retry-цикла — на validity-classifier § «Один батч»; здесь важно лишь, что join-ключ верифицируется кодом, а не на честном слове модели).
  • classifyPatient (validity-classifier.service.ts) реаттачит batch-offset (observationIndex: offset + c.observationIndex — батч был 0..7, в профиле — глобальный индекс).
  • Join: код делает output[i] ↔ inputList[output[i].observationIndex].

Подводный камень validity-классификатора: LatestObservation (его входной тип) сейчас несёт только analyte/value/unit/date/refRangeне несёт fhirObservationId. То есть индекс маппит обратно к input-элементу, у которого нет FHIR-id. При порте — добавить fhirObservationId в LatestObservation, протащить через buildValidityInput (брать id «победившего» latest-per-analyte наблюдения) рядом, не в промпт; источник id уже есть — V2Observation.fhirObservationId (см. вариант B / ниже).

Вариант B — имя параметра + carry-alongside (per-параметр-анализ)

biomarker-analysis-pipeline / parameter-analysis.service.ts (feat/v2-5-on-staging):

  • fhirObservationId выкидывается из промпта перед генерацией: диагностик/ретривер деструктурируют const { fhirObservationId: _foid, loincClass: _lc, standardizedName: _sn, ...rest } = obs («модели служебные id видеть не надо») — в agents/parameter-analysis/{diagnostician,retriever}.agent.ts.
  • В выводе LLM ссылается на parameterName (имя аналита), не на индекс — это и есть «маленький ключ» для варианта B.
  • Дальше код собирает связь сам: (1) по parameterName из LLM-вывода находит входное наблюдение с тем же именем (obsMatch); (2) берёт у него obsMatch.fhirObservationId; (3) кладёт этот id рядом с LLM-результатом в V2EnrichedParameter / V2SingleParameterAnalysis (pickIdentityFields копирует 8 «identity»-полей наблюдения, включая fhirObservationId). Для нормальных (не отклонённых) значений LLM вообще не участвует — buildEnrichedNormals просто копирует obs.fhirObservationId.

ParameterAnalysis.fhirObservationId — канонический линк, где он живёт

В нашем стеке fhirObservationId — устоявшийся канонический линк per-параметр-анализа на исходный Observation:

  • Источник: buildV2PatientContextFromFhir(fhir, patientId) ставит V2Observation.fhirObservationId = obs.id (packages/analysis-core/src/services/patient-context-from-fhir.ts). Комментарий в коде: «чтобы per-параметр-анализ мог сослаться на исходный Observation без re-match по LOINC/имени».
  • Хранение: ParameterAnalysis.fhirObservationIdTEXT NOT NULL, индексирован (ParameterAnalysis_fhirObservationId_idx), есть с самой первой миграции (20250903071214_init). Bridge (dualWriteEnrichmentDraftFromV25 / enrichment-parameter-source.ts) пишет "" когда не смог сматчить анализ к Observation — пустая строка = unmatched. Запись — через prisma-batch.ts.
  • Потребители:
    • enrich-fhir-observations.function.ts.filter(p => p.parameterDetails && p.fhirObservationId), потом fhirClient.getObservation(a.fhirObservationId) и PUT’ит AI-коммент (note[] + interpretation) назад на этот самый Observation.
    • fhir-clinical-impression-builder.ts — кладёт extension { url: "fhirObservationId", valueString: analysis.fhirObservationId } на item rich-output ClinicalImpression; fhir-clinical-impression-parser.ts читает обратно.
    • Миграционные скрипты (Phase A/B импорт из .NET, apps/b2c-dashboard/scripts/migrate-build-ndjson.ts + packages/analysis-core/src/lib/fhir/migration-ids.ts) — ParameterAnalysis.fhirObservationId обязан совпадать с id импортированного Observation.

Общий инвариант / что переносить

  • LLM не трогает FHIR id; код несёт его рядом; join — по маленькому content-ключу (индекс / имя).
  • Прокидка id ничего не стоит LLM — это работа кода.
  • Валидатор делает индекс-схему надёжной (вариант A). Имя-схема (вариант B) валидируется неявно (matched-by-name; unmatched → ""). Кандидат позаимствовать: если per-record LLM→FHIR-джойн станет частым паттерном, runtime-валидатор на join-ключе (как у validity) стоит обобщить, а не валидировать имя «неявно».
  • При порте validity-классификатора в наш стек: добавить fhirObservationId в LatestObservation, протащить через buildValidityInput рядом (не в промпт), реаттачить по индексу после генерации; дальше линковать validity-результат тем же способом, что и per-параметр-анализ. Где именно сохраняется (extension / note[] / Composition / вообще нигде) — это уже сшивка, biomarker-actuality-integration (ведущий план — ephemeral, тогда линк нужен только в памяти).

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

  • Унифицировать ли два варианта (индекс vs имя) или они так и останутся каждый в своём пайплайне? Пока — концептуально один и тот же инвариант, две формы; нет вопроса «который победит».
  • Есть ли устоявшиеся практики для «join LLM-вывода обратно к структурированному источнику» помимо positional-index / name-key — нагуглить, сверить с тем, что делаем (correlation-id в schema, tool-call id, content-hash и т.п.); может, стоит выбрать один канонический приём и применять везде.

Связано

  • validity-classifier — использует вариант A (позиционный индекс); там же gap про LatestObservation без fhirObservationId
  • biomarker-analysis-pipeline — использует вариант B (имя + carry-alongside)
  • fhir-observation — FHIR-ресурс, к которому линкуем; note[] для AI-комментариев
  • biomarker-actuality-integration — нужно ли вообще персистить validity-результат (если нет — линк только в памяти)
  • zero-extensions-fhir / fhir-modeling-ai-content — extension fhirObservationId на ClinicalImpression — один из тех минимизируемых extension’ов

Источники