⚠️ Эта страница 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_ANALYSIS env, 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 (id interpretation-analysis, событие fhir.analysis.requested). Слим: убраны V2.5→FHIR-step и SKIP_LEGACY_ANALYSIS-ветвление — всегда BloodParameterAnalysisService → Postgres ParameterAnalysis → enrichment fan-out, всё на testId. Триггеры: POST /api/v1/recognize (legacy flow) и POST /api/v1/interpret/:testId (помечен legacy). Контракт b2b-клиентов на старом shape не тронут.
  • NEW / patient-scoped (V2.5)patientSummaryPipeline (id patient-summary-pipeline, событие analysis/patient-summary.requested, файл patient-summary.function.ts). Только V2.5: buildV2PatientContextFromFhirV2PipelineService.analyzewriteV25RichOutputToFhir (ClinicalImpression/Provenance/Device в FHIR) → bridge dualWriteEnrichmentDraftFromV25 → 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.status anchor-теста не трогается.
  • Эндпоинты: b2b POST /api/v1/summary {patient_id} (новый, modules/summary/route.ts); b2c POST /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 analyzingcompleted, 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): legacy analysisPipeline → test-scoped «Blood Test Overview» (identifier=…/test-id|{testId}), V2.5 patientSummaryPipeline → 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-плумбинг и bridge dualWriteEnrichmentDraftFromV25.
  • 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 / Postgres ParameterTrends / панель «Dynamics since last test» на Summary-странице — нужно зафиксировать где именно.
  • Per-user re-run quotas / billing — отдельный слой поверх дедупа (Ильдар: «давать людям ограниченное количество перезагрузок»).

Связано

Источники