Когда 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— только warninganalyte_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.fhirObservationId—TEXT 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’ов