Сервис (biomarker-actuality-service) — gate перед биомаркер-анализом в healthReportPipeline; это известно с самого начала и тут не обсуждается. Страница картирует открытые вопросы и готчи, которые надо разобрать при подключении к проду. Свойства самого классификатора (имя, asOfDate, schema, eval-фикстуры, варианты RAW/Mastra/code-execution) живут на entity-страницах: biomarker-actuality-service, diagnostician, retriever — не дублируем.

Контекст в одной строке

buildPatientContextFromFhirДиагностик + Ретривер (один прогон patient-wide)actuality GATE (фильтрует likely_outdated / lifelong_no_retest, метит caveat) → per биомаркер: Reasoner → EvidenceBuilder → Writers → save-fhir-ai-resources. Поле retrieved? у классификатора заполняется выводом Ретривера (relatedFound*/relatedMissing*) — переиспользуем, не строим свой граф-ретривер.

Дилеммы — индекс

На странице раскрыты две связанные дилеммы; третья вынесена в отдельный decision:

  1. Один проход Ретривера или два — сколько раз гонять Diag+Retr, если gate потом отфильтровывает часть биомаркеров. Один прогон (output переиспользуется в gate’е и downstream’е) vs два прохода (ephemeral pass-1 для gate’а + чистый pass-2 для downstream’а) vs feedback loop.
  2. На что отвечает классификатор — на биомаркер или на наблюдение — на каком уровне работает классификатор и распространяется его вердикт. Уровень биомаркера (latest-per-analyte дедуп) vs уровень наблюдения.
  3. Покрытие нормальных биомаркеров — нужны ли retrieved-данные для нормальных биомаркеров и как их получить. Зафиксировано на normal-biomarker-pipeline-coverage — нормалы идут через тот же pipeline, что и аномальные (Diag+Retr+Reasoner+Writer+Personalizer), UI-only difference (для нормалов карта связей не показывается).

Дилеммы связаны: ответ на (2) разворачивает (1) и (3) на уровень биомаркеров; решения по (1) и (3) пересекаются (один Ретривер на всех = один прогон с переиспользованием; двойной Ретривер для покрытия нормальных = то же что Вариант B по числу проходов).

Один проход Ретривера или два

Сервис актуальности нужен relatedFound* от Ретривера как input. Ретривер сегодня берёт все биомаркеры пациента за один прогон. Если gate потом отфильтровывает часть как устаревшие — выбор: ничего больше не делать (1 проход) или вызвать Диагностик+Ретривер ещё раз на отфильтрованном наборе (2 прохода).

  • Вариант A — один проход (leading). Диагностик и Ретривер отрабатывают по полному набору биомаркеров один раз. Этот же output переиспользуется в двух местах: classifier читает его как retrieved per биомаркер для actuality-решения, а downstream Reasoner — как relatedFound* для survivor’ов после gate’а. Ничего «не пропадает»: чтобы узнать, какие биомаркеры устарели, нам в любом случае надо прогнать Ретривер по всем — заранее этого определить нельзя. Вопрос только в том, переиспользуем ли мы этот output дальше или выбрасываем. Требование к реализации — держать package в памяти между gate’ом и Reasoner’ом (если границы Inngest-шагов проходят между ними, нужна reduce/rehydrate-механика, иначе full package не пересечёт boundary).

  • Вариант B — два прохода, ephemeral pass-1. Pass-1 Diag+Retr на полном наборе биомаркеров — выход ephemeral, живёт только в памяти, идёт в классификатор и нигде не сохраняется. После gate’а — pass-2 Diag+Retr на отфильтрованном (актуальном) наборе, и этот выход идёт в Reasoner / Writer / FHIR-write. Между проходами сохранять ничего не нужно — pass-1 расходный для gate’а, pass-2 «настоящий» package downstream’а. Удобство — downstream видит чистый persistent package без шума устаревших связей; не нужно think’ать про «package переживает step-boundary». Оправданность — функция стоимости вызова: если Ретривер дёшев в проде (по latency и токенам), double-call приемлем; если LLM-cost заметный — нужны замеры. Это не absolute downside, а cost-benefit вопрос.

  • Вариант C — feedback loop (отложено). Тоже два прохода, но с принципиально иной логикой: pass-1 классифицирует актуальность, и её выход (finalStatus per биомаркер) пробрасывается обратно в Диагностик/Ретривер на вход pass-2. Биомаркеры outdated в pass-2 не запрашиваются вообще; pass-2 использует actuality как дополнительный сигнал, не просто фильтр. В отличие от B, между проходами есть что сохранять — актуальность-вердикты на вход следующего шага (не ephemeral). Меняет I/O-контракт Ретривера, существенно сложнее реализовать; нет видения, как это устроено end-to-end. Артур отложил.

Что нужно для уверенного выбора — замерить, какой процент биомаркеров типично попадает в likely_outdated на реальном пациенте. Если порядка десятков процентов — A очевидно достаточен. Если заметно больше — взвесить B.

На что отвечает классификатор — на биомаркер или на наблюдение

Вопрос актуальности имеет смысл на уровне биомаркера, не отдельного наблюдения. У пациента могут быть три измерения холестерина (2021, 2022, 2023), но клинически релевантен только самый свежий — остальные либо устарели биологически, либо просто исторический контекст для трендов. Спросить «актуален ли холестерин 2021 года?» когда есть холестерин 2023 года — бессмысленно: ответ всегда «нет, есть свежее».

В контракте классификатора (entity-page) явно прописан latest-per-analyte дедуп: на вход — по одному latest-наблюдению на уникальный биомаркер (ключ LOINC, иначе lower-trim-name). Это снимает повторный счёт (один LLM-вызов на биомаркер вместо N на N исторических измерений) и переводит решение на нужный уровень. Решение классификатора на latest распространяется на весь биомаркер: если latest = outdated → выбрасываем биомаркер целиком из downstream-интерпретации; если latest = representative/caveat → включаем биомаркер, downstream работает по нему.

Это разворачивает дилеммы «один проход Ретривера или два» и «покрытие нормальных» на уровень биомаркеров, а не observation-list’a.

Downstream-семантика. Reasoner / DoctorWriter / GeneratorNormals / Personalizer видят latest-per-biomarker observation: одна запись на уникальный биомаркер, самая свежая по дате. Historical observations остаются в FHIR (доступны для Tests view и trends), в AI-анализ не идут — устаревший биомаркер (likely_outdated / lifelong_no_retest) выбрасывается целиком; representative/caveat — попадает одной свежей записью. Дедуп выполняется один раз сразу после buildPatientContextFromFhir (общий util packages/analysis-core/src/lib/biomarker-key.tsbiomarkerKey + dedupLatestPerBiomarker), и тот же дедуплицированный dedupedPatientContext идёт во все последующие стадии (Диагност, Ретривер, классификатор, filterByActuality, Reasoner и далее). Когда LOINC отсутствует и срабатывает name-key fallback, оркестратор эмитит warn-log loinc-missing-fallback со списком имён — чтобы фиксировать coverage-гэп в источниках, не имеющих LOINC.

Покрытие нормальных биомаркеров

Зафиксировано на normal-biomarker-pipeline-coverage (status: active, Артур+Ильдар 2026-05-18).

В одну строку: нормальные биомаркеры идут через тот же pipeline, что и аномальные — Diagnostician + Retriever + Reasoner + Writer + Personalizer. Причина — actuality-классификатору нужны связи симметрично для нормальных и аномальных, ему неважно норма/абнорма. Различие — UI-only: для нормалов connection map / карта связей не отображается.

Следствие для Diagnostician: промпт сегодня heavily abnormal-focused (abnormal_observations — input-поле, “analyze abnormal lab findings”, “Process ALL abnormal_observations”), нужно расширить scope на all biomarkers с лёгкой обработкой нормалов. Это carry-over из решения, не блокер — fallback корректный (нормалы получают default materialRelevance: no для всех active states), но семантически пропускает caveat «значение нормально благодаря препарату».

Открытые вопросы

  • Output: ephemeral / Postgres-кэш / FHIR — sub-decision storing-biomarker-actuality. Текущая позиция — ephemeral по умолчанию.
  • Inngest-нарезка. Внутри classifyPatient сидит самодельный worker-pool + process-global семафор + ручной retry-loop. Свои очереди — анти-паттерн. Leading вариант на первую итерацию — один Inngest-шаг на всю классификацию, внутренние ретраи как сейчас; убедиться, что работает в Inngest-окружении и в проде, потом, если нужно, разбить на per-observation шаги. Семантическая retry-логика (validate → retry-with-hint → degrade) в текущей версии без изменений — вопрос только про размещение в Inngest, не про сам алгоритм (Классы сбоев). Counters policy на первую итерацию — просто логировать { failedBatches, warnings }.
  • Семантика «пометки» для caveat-биомаркеров. Идут в анализ с пометкой; что значит «пометка» технически (поле EnrichedBiomarker / Inngest payload / FHIR annotation) — решим по ходу, на первую итерацию минимально достаточно.
  • maxOutputTokens в generateObject не задан явно — выставить (gemini-doom-loop); уменьшает окно mid-decode timeout.
  • id-прокидка fhirObservationIdBiomarkerObservation.fhirObservationId уже на нашей ветке; LatestObservation (вход классификатора) его роняет — протащить, иначе при FHIR-host выходов не сможем связать с исходным замером (llm-fhir-linkback).
  • finalStatus → triage / retrieval feedback-loop — отложено Артуром («у меня нет ни capacity, ни видения»1). Циклическая постановка («устаревший биомаркер → не запрашивать его связи в следующем проходе») возможна как развитие; сегодня — ephemeral gate, finalStatus в Reasoner / Ретривер обратно не доходит.
  • Обновить biomarker-actuality-thresholds — статичный TTL-фильтр заменяется новым сервисом (динамический, учитывает контекст). Согласовать формулировки на обеих decision-страницах, не оставлять конкурирующие.
  • Что показываем пользователю про неактуальные наблюдения (отложено). Биомаркеры, классифицированные как likely_outdated / lifelong_no_retest, выпадают из AI-интерпретации, но физически в FHIR остаются — пользователь видит их в Tests view и в Timeline. На patient summary (где живёт интерпретация) их нет, и нет сигнала о том, что часть была сочтена устаревшей. Открытый вопрос — сообщать ли пользователю, что у него есть данные, которые не вошли в интерпретацию и почему; или patient summary = всегда «текущее состояние», а исторический контекст — отдельный сценарий. Возможно потребуются дополнительные генерации, которые работают отдельно по устаревшим данным. Решение продуктовое, не техническое; пока отложено.

Готчи имплементации

Эти места известны и при порте встретятся — фиксируем чтобы не наступить:

  • import.meta.url top-level в classifier.mastra.ts. Работает из tsx script.ts, ломается в CJS-бандле analysis-worker. Переписать на loadPrompt / loadDataFile-паттерн. Та же грабля у Диагностика и Ретривера (Failure modes).
  • Свой worker-pool + семафор в classifyPatient — выкинуть, отдать оркестрацию Inngest (см. Inngest-нарезку выше).
  • Ручной retry-loop в callOnce для транспортных ошибок — кандидат на вынос в bifrost (model-fallback / cascade / nonce-perturbation как функции прокси). Это отдельный workstream, не путать с Inngest-нарезкой.
  • enrich-fhir-observations PUT на raw Observation — известный анти-паттерн; релевантен только если выберем FHIR-host для output (fhir-resource-origin-and-lifecycle).

Связано

Сноски

  1. Командный созвон «Сервис валидности / репрезентативности / релевантности биомаркеров», 2026-05-12, https://github.com/Realai-plus/meeting-digests/blob/main/data/digest/2026/05/2026-05-12T11%3A45%3A00.000Z_%D0%A1%D0%B5%D1%80%D0%B2%D0%B8%D1%81_%D0%B2%D0%B0%D0%BB%D0%B8%D0%B4%D0%BD%D0%BE%D1%81%D1%82%D0%B8-%D1%80%D0%B5%D0%BF%D1%80%D0%B5%D0%B7%D0%B5%D0%BD%D1%82%D0%B0%D1%82%D0%B8%D0%B2%D0%BD%D0%BE%D1%81%D1%82%D0%B8-%D1%80%D0%B5%D0%BB%D0%B5%D0%B2%D0%B0%D0%BD%D1%82%D0%BD%D0%BE%D1%81%D1%82%D0%B8_%D0%B1%D0%B8%D0%BE%D0%BC%D0%B0%D1%80%D0%BA%D0%B5%D1%80%D0%BE%D0%B2_01KRE02872CTM2V6CEERAQ8EVD.md — Артур формулировал отложенный feedback-loop вариант.