Сервис (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:
- Один проход Ретривера или два — сколько раз гонять Diag+Retr, если gate потом отфильтровывает часть биомаркеров. Один прогон (output переиспользуется в gate’е и downstream’е) vs два прохода (ephemeral pass-1 для gate’а + чистый pass-2 для downstream’а) vs feedback loop.
- На что отвечает классификатор — на биомаркер или на наблюдение — на каком уровне работает классификатор и распространяется его вердикт. Уровень биомаркера (latest-per-analyte дедуп) vs уровень наблюдения.
- Покрытие нормальных биомаркеров — нужны ли
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 читает его как
retrievedper биомаркер для 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.ts — biomarkerKey + 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-прокидка
fhirObservationId—BiomarkerObservation.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.urltop-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-observationsPUT на raw Observation — известный анти-паттерн; релевантен только если выберем FHIR-host для output (fhir-resource-origin-and-lifecycle).
Связано
- biomarker-actuality-service — как устроен сам классификатор
- storing-biomarker-actuality — куда (и нужно ли) сохранять output
- biomarker-analysis-pipeline — пайплайн, который actuality гейтит
- diagnostician / retriever / .biomarker-graph — компоненты, которые actuality-gate переиспользует
- patient-summary —
healthReportPipeline, куда встраивается - interpretation-scope-patient-vs-test — patient-scoped трек
- biomarker-actuality-thresholds — статичный TTL-фильтр, заменяется новым сервисом
- phi-in-fhir-not-sql / multi-tenant-fhir-storage — медданные в FHIR, не в Postgres
- staged-output-fhir-storage / health-report-versioning-model — куда biomarker-analysis rich-output ложится в FHIR
- no-self-rolled-queues / inngest — оркестрация анти-паттерн / step vs function
- bifrost / llm-proxy-choice — кандидат на вынос retry / fallback / nonce
- gemini-doom-loop —
maxOutputTokenscarry-over - fhir-resource-origin-and-lifecycle —
meta.tagстратегия пометки служебных vs клинических - health-report-vocabulary — терминология, кандидат на переименование «параметр-анализ» → «биомаркер-анализ»
- Branch
origin/feat/v2-5(Артур), HEAD64d83432— детали кода в biomarker-actuality-service § «Где в коде». - Plan:
~/projects/claude-archive/plans/understand-validity-classifier.md
Сноски
-
Командный созвон «Сервис валидности / репрезентативности / релевантности биомаркеров», 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 вариант. ↩