Pipeline, который из медкарты пациента строит Health Report — AI-артефакт уровня пациента (ClinicalImpression + Composition + CarePlan). Все термины расшифрованы в health-report-vocabulary; зачем нам patient-scoped artifact — health-report-vision; место в общей системе — нижний конус hourglass’а (recognition-enrichment-hourglass).
Триггер и Run
Pipeline запускается явно — не автоматически по факту upload’a. На сейчас это POST /api/summary (b2c) / POST /api/v1/summary (b2b-api) с patient_id + опциональным as_of_date (YYYY-MM-DD; при отсутствии — max(observation.date)). От test page asOfDate приходит = test.testDate, и Health Report генерируется «для состояния пациента на момент этого теста».
Carry-over: расширенная taxonomy триггеров (upload / cron / accumulation / questionnaire / chat) — отдельный workstream, см. health-report-vision § Level 2 и § Level 3. Здесь — только триггер вида «явный API-вызов».
Каждый запрос создаёт HealthReportRun — Postgres-запись со статусом прогона. Конкретный набор статусов (waiting / analyzing / rendering / completed / error и какие-то ещё) — открыт; в текущем коде используются waiting_for_analysis → analyzing → report_rendering → completed, но это не финальное design-решение. Дедуп близких кликов делается через ~5-минутный Inngest event-id bucket (health-report-{patientId}-{floor(now/5min)}). Caller получает 202 + analysis_run_id; UI узнаёт о готовности Report’а через polling.
4 фазы
вся медкарта пациента в FHIR
↓
┌─ Phase 1 ────────────────────────────┐
│ Snapshot Building │
│ Load Patient Context │
│ Dedup latest-per-Biomarker │
│ Diagnostician + Retriever │
│ (one pass, patient-wide; │
│ output reused in Phase 2) │
│ Actuality Classifier │
│ (Retriever output as `retrieved`)│
│ drops likely_outdated / │
│ lifelong_no_retest │
└──────────────────────────────────────┘
↓ Snapshot + Diag+Retr package
┌─ Phase 2 ────────────────────────────┐
│ Biomarker Analysis │
│ reuse Diag+Retr package from P1 │
│ abnormals → Reasoner │
│ → DoctorWriter │
│ → Personalizer │
│ normals → Generator-Normals │
└──────────────────────────────────────┘
↓ per-биомаркер Biomarker Analyses
┌─ Phase 3 ────────────────────────────┐
│ Insight Synthesis │
│ 5 секций (fan-out per section) │
└──────────────────────────────────────┘
↓ Insights
┌─ Phase 4 ────────────────────────────┐
│ Persist Report │
│ CI + Composition + CarePlan │
└──────────────────────────────────────┘
↓
Health Report
На вход в Pipeline идёт вся доступная медкарта пациента в FHIR (все Observations, Conditions, Medications, allergies, demographics). Snapshot — это уже output Phase 1 (curated подмножество после dedup и actuality-фильтра); все downstream-фазы работают на нём, не на raw-FHIR.
Та же схема в Mermaid (точки fan-out на actuality-классификатор и downstream + reuse Diag+Retr-package’a — explicit):
flowchart TD FHIR[Patient FHIR record<br/>Observations + Conditions + Medications + Demographics] subgraph PH1["Phase 1 — Snapshot Building"] direction TB Load[Load Patient Context] Dedup[Dedup latest-per-Biomarker<br/>+ filter date ≤ asOfDate] DiagRetr[Diagnostician + Retriever<br/>one pass, patient-wide] Classifier{Actuality Classifier<br/>per biomarker} Load --> Dedup --> DiagRetr --> Classifier end FHIR --> Load Classifier -->|"outdated / lifelong"| Drop[Drop from AI analysis<br/>stays in FHIR for Tests view / trends] Classifier -->|"representative / with caveat"| Snapshot[Snapshot of survivors] subgraph PH2["Phase 2 — Biomarker Analysis"] direction TB Abnormal[Abnormal<br/>Reasoner → DoctorWriter → Personalizer] Normal[Normal<br/>Generator-Normals] end Snapshot --> Abnormal Snapshot --> Normal DiagRetr -. "reused package" .-> Abnormal DiagRetr -. "reused package" .-> Normal Abnormal --> Analyses[Per-biomarker Analyses] Normal --> Analyses subgraph PH3["Phase 3 — Insight Synthesis"] Insights[5 sections fan-out<br/>Highlights · More to Watch · Trends · What's Missing · Doctor Talk] end Analyses --> Insights subgraph PH4["Phase 4 — Persist Report"] Persist[ClinicalImpression + Composition + CarePlan<br/>upsert by patient · history via FHIR _history] end Insights --> Persist Persist --> Report[Health Report]
Carry-over: если pipeline разрастётся sub-steps’ами (особенно внутри Phase 2 для abnormal-chain’а и Phase 3 fan-out’а) — перенести в drawio с слоями; пока mermaid вмещает.
Phase 1 — Snapshot Building
Цель: подготовить Snapshot (curated patient context) — input для всех downstream LLM-агентов.
Стадии:
- Load Patient Context (
buildV2PatientContextFromFhir) — читает всю доступную медкарту пациента в FHIR (без фильтра по дате): Observations, Conditions, MedicationStatements, MedicationRequests, Patient demographics.asOfDateна этой стадии используется только для вычисления возраста пациента (сколько ему было на эту дату), а не для отсечения данных — отсечение делает следующий шаг. - Apply Actuality Filter (biomarker-actuality-integration):
- dedup latest-per-Biomarker (по LOINC, fallback по name) — оставляет одну Observation на каждый Biomarker
- filter
obs.date <= asOfDate— отсекает observations после as-of date (snapshot-relative semantics) - Diagnostician + Retriever — один прогон, не per-биомаркер. Diagnostician строит plan по
biomarker_graph.json(сегодня код всё ещё abnormal-only; по решению normal-biomarker-pipeline-coverage должен покрывать все биомаркеры — миграция Diagnostician-промпта под «plan for all, deep-analyze only abnormals» в работе), Retriever по этому plan’у достаёт из FHIRrelatedFound*/relatedMissing*per биомаркер. Этот жеgenerationReadyPackageпереиспользуется в Phase 2 — single Diag+Retr pass, two consumers (Вариант A в biomarker-actuality-integration). - Biomarker Actuality classifier (biomarker-actuality-service) — получает Retriever output как
retrievedper биомаркер (через## Graph-derived relevanceсекцию в user-message); классифицирует каждый Biomarker (currently_representative / with_caveat / likely_outdated / lifelong_no_retest / unknown); dropslikely_outdated+lifelong_no_retest
Output Phase 1: Snapshot — { demographics, abnormalObservations, normalObservations, activeConditions, activeMedications, allergies } (curated, post-gate) плюс Diag+Retr package для downstream. Diag и Retriever — structured-output агенты (DiagnosticPlan / GenerationReadyPackage); narrative-prose в Phase 1 не генерируется.
Phase 2 — Biomarker Analysis
Цель: для каждого биомаркера в Snapshot’е создать Biomarker Analysis — structured rich-output (analysis + reasoning + recommended workup + citations).
Diagnostician + Retriever уже отработали в Phase 1 — Phase 2 переиспользует их generationReadyPackage (relatedFound* / relatedMissing* per биомаркер), не запускает повторно (Вариант A, см. biomarker-actuality-integration).
Сегодня two-tier (миграция в работе — см. normal-biomarker-pipeline-coverage):
- Abnormal biomarkers — Reasoner → DoctorWriter → Personalizer (Variant D, см. health-facts-as-generation-substrate) поверх Diag+Retr package из Phase 1
- Normal biomarkers — пока короткий путь: Generator-Normals (single-LLM call без Reasoner), Diag+Retr для них не запускается. По решению 2026-05-18 нормалы должны идти через тот же Reasoner→Writer→Personalizer chain, что и аномальные (UI-only difference — для нормалов карта связей в
BiomarkerAnalysisрендере не показывается). Миграция — carry-over: переписать Diagnostician-промпт под all-biomarker scope + удалить отдельныйGenerator-Normalsпуть.
Reference-implementation для всех агентов — ветка feat/v2-5 (Артур). Подключать в основной orchestrator — наша задача; собственных агентов мы не пишем. В референсе живут: Diagnostician, Retriever, Generator (для abnormals), Generator-Normals (для normals), Reasoner + Writer (Variant D), Personalizer.
Detail per-biomarker chain — biomarker-analysis-pipeline. Internal types — Evidence + Inference layer split (см. evidence-vs-inference-два-слоя-внутри-biomarker-analysis).
Output: массив Biomarker Analyses — по одному на каждый Biomarker, каждый со structured fields (parameterName, value, unit, clinicalInterpretation, foundContext, missingContext, citations, testDate, fhirObservationId).
Phase 3 — Insight Synthesis
Цель: на основе Biomarker Analyses + Snapshot создать Insights — тематические narrative-секции для UI.
Список из пяти секций (имена-кандидаты и трейдоффы — слой-3—секции-внутри-health-report-insights; что внутри каждой и кому адресовано — my-health):
- Highlights — главные биомаркеры, hero-блок; subset биомаркеров для этой секции выбирает отдельный детерминистический classifier main-biomarkers-detection на основе трёх сигналов (cluster / patient-context / trend), no LLM at decision time
- More to Watch / Other Notable — остальные abnormal’ы вне Highlights
- Trends — longitudinal view биомаркеров (stable + changing)
- What’s Missing / Suggested Tests — data gaps, чего AI’у не хватает чтобы дать более точную картину
- Doctor Talk — что обсудить с врачом, специалисты, темы
Эта фаза ещё не реализована. Текущий V2.5 pipeline пишет старую structure (patternedIdentified / healthConsiderations / recommendedSteps / panelOverviews / trends), не пять тематических Insight’ов. Конкретная реализация — fan-out на 5 LLM-генераций, одна функция на секцию, событие на каждый запуск — концептуально та же модель, что в нынешнем enrichment/patient.<section>.requested fan-out’е, но с другим списком секций и другими промптами. Дизайн открыт.
Каждый Insight на выходе несёт narrative-текст и (опционально) ссылки на конкретные Biomarker Analyses или Observations, на которые он опирается — чтобы пациент мог раскрыть «откуда это» и посмотреть данные.
Phase 4 — Persist Report
Цель: записать Health Report в FHIR.
Три «container»-ресурса per Health Report:
- ClinicalImpression — несёт Biomarker Analyses (rich-output по каждому биомаркеру)
- Composition — несёт Insights (narrative sections для UI). Naming решения и rationale — patient-summary-composition-naming.
- CarePlan — несёт follow-up recommendations (преимущественно из «Чего не хватает» + «С врачом»)
Все три — patient-scoped (один на пациента, identifier по fhirPatientId), upsert через conditional PUT — re-run pipeline’a replaces in place; run history живёт в FHIR _history.
Сопровождающие ресурсы (общие для всех write’ов AI-output):
- Provenance — source attribution каждой записи (кто/когда/чем сгенерил). Подробности — fhir-provenance.
- Device — singleton, identity AI-pipeline’a (
bloodgpt-pipeline-v2-5). Подробности — fhir-modeling-ai-content. - AIAST tag (HL7 AI Transparency IG security label) — на всех AI-asserted resource’ах. Подробности — fhir-meta-tagging.
Run accumulation через meta.tag analysisRunId — каждый прогон stamp’ит свой id; HAPI accumulates на PUT (см. fix e025b436). Pairing Composition ↔ CI делается через intersection meta.tag-set, не «last write wins».
Carry-over: точная shape хранения (как раскладывать Insights по section’ам Composition, как именно линковать CarePlan на конкретные Insights, нужна ли отдельная FHIR-структура для Highlights subset) — пока не финализирована. Будем уточнять параллельно со spec’ом 5 секций.
После Persist’a UI читает Composition через /api/patient-summary?patient_id=… и рендерит секции на My Health page.
Read-side для тест-страницы
Тест-страница (test-page-content) показывает Health Report, созданный для as-of-date этого теста (as_of_date = test.testDate). У одного пациента может быть несколько Report’ов за разные as-of даты — тест-страница ассоциирована с конкретным Report’ом и достаёт его.
Open: связка test → Report выглядит как «по as-of-дате» (test.testDate должна совпасть с Report’овой asOfDate) — но возможно правильнее держать explicit id-связку (Test несёт
healthReportIdили Report несётtriggeringTestId), чтобы не зависеть от случайного совпадения дат. Эта связка не зафиксирована в коде. Carry-over: решить как именно тест указывает на свой Report.
По дефолту такого Report’a может не быть. Когда пациент загружает десяток тестов, мы не генерируем Report под каждый — это лишний расход, и пользователю серединный тест может быть неинтересен. Поэтому на тест-странице, если Report под эту дату не найден, отображается кнопка «Сгенерировать» — пользователь явно запускает Health Report Pipeline с asOfDate = test.testDate.
Когда Report для даты теста существует, страница отображает его Insights и Biomarker Analyses, ограниченные по as-of-дате. Биомаркер, измеренный позже даты теста, на эту страницу не попадёт (он не входил в Snapshot этого Report’a). Биомаркер, который сдавался раньше теста и был всё ещё валиден по actuality на дату теста — попадёт (он был в Snapshot’е, на нём построен Insight).
Текущая картина пациента (по последним данным) живёт на отдельной поверхности — my-health.
Legacy test-scoped track — рядом, но не часть Health Report Pipeline
Помимо Health Report Pipeline в коде продолжает жить отдельный legacy track — analysisPipeline (event fhir.analysis.requested, файл interpretation-analysis.function.ts). Он привязан к одному тесту, пишет test-scoped Composition («Blood Test Overview») и используется b2b-эндпоинтом /api/v1/interpret/:testId. Это не часть Health Report Pipeline — отдельный track для legacy-клиентов, которые получают результат «на свой тест» а не на пациента.
Кандидат на rename — Test Analysis Pipeline (parallel с Health Report Pipeline; «test analysis» = patient-scoped result привязанный к одному документу-тесту). История перехода test-scoped → patient-scoped — interpretation-scope-patient-vs-test; конкретный naming-конфликт Composition.type — patient-summary-composition-naming.
Открытые вопросы
- Patient Writer wiring — scaffold существует в
feat/v2-5, в основной pipeline ещё не подключён. - Same-biomarker history flow в Reasoner + Personalizer — на текущий момент в коде отсутствует. Personalizer имеет
formatHistory(hist)-слот, который вызывается с пустым массивом. Подача упорядоченной серии same-biomarker measurements в агенты — open архитектурный вопрос (откуда брать — Bundle, FHIR_history, отдельный fetcher). - Phase 3 (Insight Synthesis) — не реализована. Текущий pipeline пишет старую structure, не пять тематических Insight’ов.
- Highlights selection через main-biomarkers-detection — classifier готов отдельным standalone-репо (Артур, 2026-05-18), не подключён. Открыт способ интеграции: post-Phase-2 priority filter (Phase 2 анализирует все abnormals, classifier выбирает subset для Highlights, остальное в More to Watch) vs pre-Phase-2 priority gate (Phase 2 делает deep analysis только для
label === "main", дляnot_main— лёгкий путь). Также open: adapter Retriever-output → classifierretrievedBlockSchemaи подача priors для S3 (сейчас Phase 1 dedup-стадия priors отбрасывает, без них S3 не fire’ит). - Composition.section[].entry → Reference(ClinicalImpression) — literal cross-references ещё не написаны.
Связано
- health-report-vocabulary — словарь всех терминов в этой странице
- health-report-vision — paradigm motivation (test-центрик → patient-центрик)
- biomarker-analysis-pipeline — Phase 2 detail
- biomarker-actuality-integration — Phase 1 stage detail
- biomarker-actuality-service — actuality classifier entity
- main-biomarkers-detection — детерминистический classifier
main/not_mainдля Phase 3 Highlights selection - health-facts-as-generation-substrate — Variant D Reasoner/Writer split
- patient-summary-composition-naming — FHIR Composition type-naming sub-decision
- interpretation-scope-patient-vs-test — почему patient-scoped + параллельный legacy test-scoped track
- recognition-enrichment-hourglass — system-level фрейм
- my-health — UI поверхность для финального Health Report’a