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-агентов.

Стадии:

  1. Load Patient Context (buildV2PatientContextFromFhir) — читает всю доступную медкарту пациента в FHIR (без фильтра по дате): Observations, Conditions, MedicationStatements, MedicationRequests, Patient demographics. asOfDate на этой стадии используется только для вычисления возраста пациента (сколько ему было на эту дату), а не для отсечения данных — отсечение делает следующий шаг.
  2. 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’у достаёт из FHIR relatedFound* / relatedMissing* per биомаркер. Этот же generationReadyPackage переиспользуется в Phase 2 — single Diag+Retr pass, two consumers (Вариант A в biomarker-actuality-integration).
    • Biomarker Actuality classifier (biomarker-actuality-service) — получает Retriever output как retrieved per биомаркер (через ## Graph-derived relevance секцию в user-message); классифицирует каждый Biomarker (currently_representative / with_caveat / likely_outdated / lifelong_no_retest / unknown); drops likely_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 → classifier retrievedBlockSchema и подача priors для S3 (сейчас Phase 1 dedup-стадия priors отбрасывает, без них S3 не fire’ит).
  • Composition.section[].entry → Reference(ClinicalImpression) — literal cross-references ещё не написаны.

Связано

Источники