⚠️ Эта страница superseded 2026-05-20. Решение «единица интерпретации — пациент, не тест» актуально и принято, но раскрыто полнее в трёх отдельных страницах:
- health-report-vision — paradigm shift тест-центрик → snapshot-центрик (Level 1-3 видения)
- health-report-pipeline — 4-фазный pipeline (Snapshot Building / Biomarker Analysis / Insight Synthesis / Persist Report)
- interpretation-scope-axes — horizontal + vertical scope axes на дату интерпретации
Эта страница смешала decision-level фрейминг с глубокой технической describe-текущего-состояния информацией (legacy V2 ветка,
SKIP_LEGACY_ANALYSISenv, phantom BloodTest rows). Material для current state логичнее в pipeline-странице, для vision — в vision-странице. Здесь оставлено как историческая фиксация момента 2026-05-10 («в коде ещё конкурируют V2 и V2.5»).Внизу — оригинальный текст для archeology.
В V2 интерпретация была привязана к конкретному тесту (один загруженный документ → один анализ → один отчёт). В V2.5 единица интерпретации — пациент: pipeline читает все Observation’ы с Patient/{id} (вся история биомаркеров + контекст) и пересобирает summary. Тест-scoped запуск остаётся как legacy-вход.
Контекст
V2.5-интерпретация (staged parameter analysis → triage → Composition «Blood Test Overview», см. biomarker-analysis-pipeline, patient-summary) по дизайну работает с полным FHIR-снимком пациента, а не с одним DiagnosticReport: чтобы интерпретировать панель, нужны тренды по биомаркеру, ранее измеренные параметры, vitals, ранее выявленные паттерны. «Какой именно документ загружен последним» для самого анализа нерелевантно — релевантен агрегированный пациент.
При этом analysisPipeline (apps/analysis-worker/.../interpretation-analysis.function.ts) исторически ключует всё на testId: статус (BloodTest.status через prisma.bloodTest.update), Langfuse trace (traceId = testId, span analysis-${testId}), event-log, и — отдельная legacy V2-ветка, которая внутри той же функции бежит параллельно с V2.5→FHIR-веткой и реально тест-scoped. Legacy V2 короткозамыкается переменной SKIP_LEGACY_ANALYSIS (=true локально → локально V2.5-only; в prod/stage не выставлена → legacy V2 ещё работает).
Триггеры интерпретации:
POST /api/v1/interpret/(b2b-api, безtestId) →triggerInterpretationByPatient— пациент-scoped. Это то, что зовёт кнопка «Update analysis» в b2c-dashboard (черезapps/b2c-dashboard/app/api/interpret/route.ts, которая инлайнит ту же логику локально из-за отсутствия org API-key).POST /api/v1/interpret/:testId(b2b-api) →triggerInterpretation— тест-scoped, legacy-вход.
Оба пути, чтобы накормить testId-контракт пайплайна, создают «лёгкую» BloodTest-строку (externalId = manual-interpret-{ts}-..., status waiting_for_analysis, с скопированными fhirPatientId / fhirDiagnosticReportId из последнего реального теста). Эта строка проходит фильтр /api/tests (status ∈ {completed, interpreting, recognize_completed}) и показывается в списке тестов пациента как фантомный «New test · N biomarkers …». На профиле это даёт расхождение «N blood tests uploaded» vs реально загруженных PDF.
Позиции
Тест-scoped (V2):
- Простая адресация: один тест → один анализ → один отчёт, всё ключуется естественно.
- Каждый загруженный документ получает «свой» отчёт.
- Не масштабируется на «интерпретация = понимание пациента»: панель в вакууме без истории интерпретируется хуже; fan-out по тестам дублирует работу.
Пациент-scoped (V2.5):
- Совпадает с тем, как V2.5 реально устроена (читает Observation’ы с пациента).
- Нет fan-out-per-test; повторный запуск = пересборка одного
Composition(versioning in-place,_historyнакапливает v1, v2, …). - Нужна точка-handle для bookkeeping (статус/trace) — сейчас это синтетическая
BloodTest-строка, что и порождает фантомы.
Выбрали
Пациент-scoped. Forward-путь — «обновить summary пациента» без выбора конкретного теста; это то, что делает кнопка «Update analysis» и triggerInterpretationByPatient. Тест-scoped запуск (POST /api/v1/interpret/:testId) остаётся как legacy-вход (b2b-клиенты, которые могут на него полагаться), но помечен явно как legacy, не как основной путь.
Почему: V2.5-анализ предметно пациент-scoped — «тест» как единица интерпретации не имеет смысла, когда модель читает всю историю. Тест-scoped адресация — артефакт V2-эпохи «1 файл = 1 анализ». Это согласуется с направлением STORAGE_ARCHITECTURE.md (FHIR — source of truth, Postgres — только бизнес/pipeline-метаданные; новые clinical-данные не вешать на Postgres).
Реализация (2026-05-10)
Два чистых трека, без перемешивания:
- OLD / test-scoped (legacy V2) —
analysisPipeline(idinterpretation-analysis, событиеfhir.analysis.requested). Слим: убраны V2.5→FHIR-step иSKIP_LEGACY_ANALYSIS-ветвление — всегдаBloodParameterAnalysisService→ PostgresParameterAnalysis→ enrichment fan-out, всё наtestId. Триггеры:POST /api/v1/recognize(legacy flow) иPOST /api/v1/interpret/:testId(помечен legacy). Контракт b2b-клиентов на старом shape не тронут. - NEW / patient-scoped (V2.5) —
patientSummaryPipeline(idpatient-summary-pipeline, событиеanalysis/patient-summary.requested, файлpatient-summary.function.ts). Только V2.5:buildV2PatientContextFromFhir→V2PipelineService.analyze→writeV25RichOutputToFhir(ClinicalImpression/Provenance/Deviceв FHIR) → bridgedualWriteEnrichmentDraftFromV25→ enrichment fan-out (наanchorTestId) →save-fhir-ai-resourcesпишетComposition/CarePlan. НикакогоBloodTest. - Status-handle —
AnalysisRun(новая таблица){id, organizationId, patientId(cuid), fhirPatientId?, fhirDiagnosticReportId?, status, source, langfuseTraceId, errorMessage, startedAt, completedAt}.Patient.lastSummaryStatus / lastSummaryAt / lastSummaryRunKey— дешёвый denorm последнего прогона (для бейджа на хедере профиля). Никакихmanual-interpret-*BloodTest-строк больше не создаётся; исторические — почищеныdeleteMany. anchorTestId= последний реальный тест пациента с FHIR (исключаяmanual-interpret-*). Передаётся в новый pipeline только как плумбинг для всё ещёtestId-keyed enrichment fan-out +dualWriteEnrichmentDraftFromV25— это НЕ выбор теста, иBloodTest.statusanchor-теста не трогается.- Эндпоинты: b2b
POST /api/v1/summary {patient_id}(новый,modules/summary/route.ts); b2cPOST /api/summary(инлайн, заменилPOST /api/interpret); кнопкаRefreshAnalysisButton→/api/summary. Дедуп «двойной клик» — ~5-мин Inngest event-id bucket (patient-summary-{patientId}-{floor(now/5min)}) в callers; batch-тулинг (synthetic-test.ts run --all) шлёт уникальные per-run id и не дросселируется. SKIP_LEGACY_ANALYSISудалён (из.envи кода)./api/tests+handleUserProfileTestsфильтруютexternalId LIKE 'manual-interpret-%'(guard для исторических строк; новые не создаются).
Проверено локально: synthetic-test.ts interpret на профиле «Анна Тестова» → AnalysisRun analyzing→completed, Patient.lastSummary* обновляются, Composition/1006 v5→v6, enrichment fan-out отработал, save-fhir-ai-resources+output/pdf.requested сработали, ноль manual-interpret-* строк. Legacy /api/v1/interpret/:testId — проверить отдельно (локально /recognize-flow не гоняется).
Следствия
recognizeOrchestrator(image-first/api/v1/upload) НЕ авто-триггерит интерпретацию — явный пользовательский шаг (кнопка «Update analysis» /POST /api/v1/summary). (Авто-триггер послеrecognize_completedчерезanalysis-queue-gate— это legacy/recognize-путь, не/upload.)AnalysisRun.status = completedставится после фан-аута enrichments, а самComposition-write доезжает чуть позже через enrichment-chain (save-fhir-ai-resources) — та же async-форма, что у legacy-пути; UI читаетCompositionиз FHIR (появляется когда записан). Можно позже перенести флипcompletedвsave-fhir-ai-resources.- enrichments всё ещё
testId-keyed (Stage-2 BG-1337 = читать FHIR напрямую — отложено) → новый pipeline вынужден таскатьanchorTestId. - Два трека теперь пишут разные Composition (chain
6bfe41d1, 2026-05-10): legacyanalysisPipeline→ test-scoped «Blood Test Overview» (identifier=…/test-id|{testId}), V2.5patientSummaryPipeline→ patient-scoped «Patient Summary» (identifier=…/patient-summary|{patientId}). Реализовано черезscope-флаг в enrichment-событиях →save-fhir-ai-resourcesветвится. До этого оба трека затирали один патиентский Composition. См. patient-summary-composition-naming.
Открытые вопросы
- Когда выключить legacy V2 в prod (
/api/v1/recognize+POST /api/v1/interpret/:testIdвообще удалить) — упирается в «все b2b-клиенты съехали на/upload+/summary?». - Stage-2 BG-1337: enrichments читают FHIR напрямую → можно убрать
anchorTestId-плумбинг и bridgedualWriteEnrichmentDraftFromV25. migrate devсломан на migration-history веткиfeat/v2-5-on-staging(P3006/P1014 на20260410001225_add_evaluation_scores) — новые миграции пришлось накатывать черезdb execute+migrate resolve; стоит починить.- Где живут тренды: секция
[Trends]вComposition«test-overview» наблюдалась пустой даже при Hb на 3 timepoints; trends-данные идут черезenrichment/trends.requested/ PostgresParameterTrends/ панель «Dynamics since last test» на Summary-странице — нужно зафиксировать где именно. - Per-user re-run quotas / billing — отдельный слой поверх дедупа (Ильдар: «давать людям ограниченное количество перезагрузок»).
Связано
- patient-summary — пациентский summary как продукт интерпретации
- biomarker-analysis-pipeline — staged V2.5 pipeline (Reasoner → Writer → Personalizer → triage)
- ai-enrichment-separate-step — AI-enrichment отдельным PUT после FHIR-save
- staged-output-fhir-storage — куда складывать rich-output в FHIR (contested)
- health-report-versioning-model — create-vs-update семантика для patient summary
- zero-extensions-fhir — минимизация custom extensions (контекст для FHIR-моделирования)