Модуль решает один вопрос: какие из абнормальных биомаркеров пациента — главные прямо сейчас, чтобы Highlights Insight в Health Report показал именно их, а не все 20-50 значений за пределами референса из лаб-отчёта. На каждый биомаркер выдаёт ровно одну из двух меток — main либо not_main — плюс аудит-след (какой сигнал сработал, цитаты, дата последнего измерения). Без LLM в момент принятия решения: правила детерминистические, калибровка через врача-в-цикле с Катей.

Что именно определяет «main», какие альтернативы рассматривались и почему отвергнуты — отдельная страница defining-main-biomarker. Здесь описано как модуль устроен и подключён, не почему он именно такой.

Контракт

Чистая функция classify(input: ClassifierInput): ClassifierOutput. Без состояния и без сайд-эффектов — никакого I/O, FHIR-клиента, БД или сетевых вызовов. Caller строит observations[] с последним измерением + всеми предыдущими одного пациента в одном bundle, каждое наблюдение несёт retrieved-блок (результат работы Ретривера из biomarker-analysis-pipeline):

type Observation = {
  biomarkerId: string;        // canonical LOINC code или internal id
  value: number;
  unit: string;
  date: string;               // ISO YYYY-MM-DD
  refRange: { lower: number | null; upper: number | null };
  interpretation: "N" | "L" | "H";   // FHIR ObservationInterpretation
  retrieved:
    | { graphFound: false }
    | { graphFound: true;
        biomarkerKey: string;
        relatedParameters: string[];                // сиблинги из графа для S1
        matchedConditions: { relationKind: "condition"; name: string }[];   // пациент × граф для S2
        matchedMedications: { relationKind: "medication"; name: string }[]; }
};
type ClassifierInput  = { asOfDate: string; observations: Observation[] };
type ClassifierOutput = {
  asOfDate: string;
  classifications: {
    biomarkerId: string;
    label: "main" | "not_main";
    trace: TraceEntry[];        // длина 1 (sanity short-circuit) или 3 ([S1, S2, S3])
    citations: { biomarkerKey: string; relationKind: "condition"|"medication"; name: string }[];
    latestObservationDate: string;
  }[];
};

Внутри classify() — четыре последовательных шага:

  1. Свёртка до одного последнего измерения на биомаркер. Группировка по biomarkerId, берётся самое свежее по date. Остальные измерения этого же биомаркера остаются в bundle’е как предыдущие, но не классифицируются независимо.

  2. Sanity-проверки (short-circuit, дают not_main без оценки сигналов). Если refRange.lower и refRange.upper оба nullsanity:missing-ref-range (проблема качества данных, не баг классификатора). Если interpretation === "N" на последнем измерении — sanity:latest-normal (Main now — про текущее состояние; ретроспективная абнормальность игнорируется).

  3. Три сигнала на abnormal последнее измерение — каждый поиск чистой функцией. Любой сработал → main; ни одного → not_main.

  4. Trace и цитаты. trace[] фиксирует исход каждого из 3 сигналов (или единственный sanity-код на short-circuit). citations[] несёт graph-цитаты для тех сигналов, которые сработали — это и есть аудит-след на каждую классификацию.

Сигналы

Три независимых сигнала; OR-комбинация (хотя бы один сработал → main). Полное обоснование каждого + альтернативы, которые рассматривались и отвергнуты — defining-main-biomarker.

S1 — фенотип-кластер. Биомаркер abnormal И ≥ N = 2 его relatedParameters-сиблингов (из графа, предварительно резолвнутых Ретривером) тоже abnormal в bundle’е. Мысленная модель — «синдром не пара, а кластер»: три сходящихся находки начинают рассказывать историю; два — низший защищаемый минимум, прежде чем шум забивает сигнал.

N = 2 — единственный калибруемый параметр всего sieve’a. Стартовая гипотеза, валидация через Telegram-цикл с Катей на ~50 doctor-reviewed отчётов. Если over-flags — поднять до 3; если under-flags — опустить до 1.

S2 — patient-context match. Биомаркер abnormal И Ретривер вернул ≥ 1 typed match против patient FHIR — реальный диагноз/состояние из matchedConditions либо лекарство из matchedMedications. Чистый поиск, без LLM. Каждое совпадение несёт quotes + source из графа через Ретривера — это и есть аудит-след для медицинского утверждения.

S3 — trend signal. Биомаркер abnormal И один из:

  • Первый раз abnormal — нормальный во всех предыдущих измерениях, теперь abnormal (новая находка достойна внимания).
  • Усугубление — прогрессивно дальше от референса.
  • Разворот направления — был high, стал low (или наоборот) — редкое, очень значимое.

Не срабатывает на стабильно хроническом (abnormal во многих предыдущих измерениях, без изменений — пациент и врач знают) и на улучшающемся (abnormal, но движется к норме — уже под контролем). Сравнение предыдущих измерений с последним — через структурно-позиционную нормализацию (value − upper) / (upper − lower), чтобы тренды были сравнимы между лабами с разными reference ranges; почему именно эта формула и какие альтернативы (конвертация единиц, z-score, сравнение без нормализации) отвергнуты — biomarker-observations-comparability. Корректный пропуск: если предыдущих измерений нет — S3 не срабатывает, S1 и S2 остаются.

Что классификатор НЕ делает

Граница одной ответственности: классификатор не открывает граф (это работа Ретривера), не читает FHIR, не пишет прозу (audience-specific формулировки — writer’ы вниз по потоку health-facts-as-generation-substrate), не диагностирует (приоритет показа, не «у пациента DM2»), не нормализует имена / единицы (слой нормализации выше по потоку), не считает ритм следующего теста (отдельный workstream).

Где сидит в Health Report Pipeline

Подаёт подмножество в Phase 3 Highlights Insight health-report-pipeline. Текущий код health-report-pipeline не подключает main-detection — Phase 3 Insight Synthesis в целом ещё не реализована, классификатор актуальности занимает аналогичный sieve-слот в Phase 1 для другого вопроса (см. biomarker-actuality-service).

Архитектурно самый естественный вариант — priority-фильтр после Phase 2: Phase 2 уже сейчас бежит на всех биомаркерах, переживших фильтр актуальности (Reasoner → Writer → Personalizer), классификатор поверх готового списка выбирает подмножество для Highlights, остальное направляется в «More to Watch». Этот вариант ничего не меняет в существующем pipeline’е. Альтернатива — priority-gate до Phase 2 (Phase 2 deep-analyze только для label === "main", для not_main — лёгкий путь) — дешевле по вычислениям, но связывает границы фаз; рассматривать только если вариант после Phase 2 даст доказанные избыточные расходы.

Конвертер Retriever → classifier на границе. Наш Ретривер выдаёт relatedFound* / relatedMissing* на каждый биомаркер в generationReadyPackage — те же сущности (пациент × граф), но под другими именами полей и в чуть другой форме, чем ждёт классификатор. Конвертер на границе Phase 1 переименовывает поля и собирает discriminator’ы; новой логики не несёт, только адаптирует уже готовые данные.

Подача предыдущих измерений для S3 — open-вопрос интеграции, не самой схемы. health-report-pipeline Phase 1 сейчас делает latest-per-biomarker свёртку и предыдущие измерения отбрасывает. Для рабочего S3 нужно либо подгружать их параллельно с последним, либо переделывать логику свёртки. Обсуждается отдельно от основного подключения.

Один и тот же вердикт для пациента и врача

Один и тот же биомаркер либо main, либо нет, независимо от того, кто читает Report (пациент или врач). Audience-specific формулировки — работа writer’ов вниз по потоку. Этот инвариант зафиксирован, чтобы различие не пере-обсуждалось.

Калибровка через врача-в-цикле

N = 2 для S1 и веса паттернов для S3 не выводятся из гайдлайнов — они эмпирические. Калибровка идёт через общий механизм medical-expert-loop: synthetic-профиль пациента → Telegram-тред с Катей → дословный захват ответа → дистилляция правила → tier-1 cross-check → энкод в classifier. У модуля свой ритм (RULES_VERSION отдельный от *_PROMPT_VERSION основного pipeline’a), но рабочий процесс тот же.

Отдельный GitHub-репозиторий

Модуль живёт в отдельном GitHub-репозитории 1, а не в нашем monorepo. Артур + Катя итерируют независимо — свои зависимости, свой test-стек, свой ритм врача-в-цикле на 38 фикстурах-пациентах. ARCHITECTURE.md в репозитории 2 явно проектирует совместимость под наш стек (TS strict + ESM + pnpm + Turborepo + Vitest + Zod), чтобы merge стал «копи-пастом», как сформулировал Артур в Slack 3:

«Я в этот раз напряг клода составить дев-план так, чтобы интегрировалось потом легко. Вроде копи паст почти что должен быть. Даже ллм нет.»

При merge ложится как packages/main-biomarkers-detection/ (сосед analysis-core). Финального коммита на форму merge ещё нет — решается одновременно со стратегией интеграции (после Phase 2 vs до Phase 2).

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

  • Стратегия интеграции в health-report-pipeline — priority-фильтр после Phase 2 (естественный вариант под текущий код) vs priority-gate до Phase 2.
  • Форма конвертера Retriever → retrievedBlockSchema классификатора — точное соответствие полей + где живёт конвертер на границе.
  • Подача предыдущих измерений для S3 — отдельный fetcher параллельно свёртке, переделка свёртки Phase 1, или жить без S3 (только S1+S2). Этот вопрос отделён от основного подключения — обсуждается параллельно.
  • Калибровка N = 2 через врача-в-цикле с Катей; готов ли граф к этому моменту по полноте.
  • Каталог sanity reason codesunit / preanalytic / unknown-biomarker пока не финализирован.
  • Pediatric / pregnancy охват — v0.1 adult-only; pediatric диапазоны и pediatric-specific S2-записи — отдельное развёртывание.

Связано

  • defining-main-biomarker — что именно делает биомаркер main: 3 OR-сигнала, sanity-проверки, отвергнутые альтернативы (Q1 panic / Q2 binary / Q3 explainability / reflex / числовые шкалы тяжести / LOINC-panel) с плюсами/минусами — active
  • biomarker-observations-comparability — структурно-позиционная нормализация для сравнения предыдущих измерений между лабами; альтернативы (конвертация единиц / z-score / сравнение без нормализации) рассмотрены и отвергнуты — active
  • health-report-pipeline — куда классификатор подаёт подмножество (Phase 3 Highlights Insight)
  • medical-expert-loop — общий механизм калибровки через Катю; sieve опирается на него для N и весов паттернов S3
  • retriever — источник retrieved-блока, который классификатор читает на каждое наблюдение
  • diagnostician — собирает diagnostic plan через biomarker-graph; Ретривер потом резолвит против FHIR
  • biomarker-graph — single source of truth для biomarker↔condition связей; классификатор сам граф не открывает
  • biomarker-actuality-service — родственный детерминистический-поверх-LLM-фактов сервис; разный вопрос («можно ли опираться на значение сейчас» vs «main-биомаркер для Highlights»), но архитектурно сосед
  • biomarker-actuality-integration — детали стадии Phase 1; естественное место для конвертера Retriever → classifier
  • health-report-vocabulary — словарь Snapshot / Insights / Biomarker Analysis; модуль производит вход для Highlights Insight
  • normal-biomarker-pipeline-coverage — параллельный workstream про единый scope Диагностика для всех биомаркеров; пересекается с тем, как Phase 1 кормит main-detection
  • biomarker — entity-страница про сам термин «биомаркер»
  • no-self-rolled-queues — общий принцип «чистая функция вместо самописных retry/worker-pool», на котором классификатор естественно сидит

Сноски

  1. Репозиторий Realai-plus/main-biomarkers-detection, accessed 2026-05-19. https://github.com/Realai-plus/main-biomarkers-detection

  2. ARCHITECTURE v0.1 (2026-05-12), docs/ARCHITECTURE.md, accessed 2026-05-19. https://github.com/Realai-plus/main-biomarkers-detection/blob/main/docs/ARCHITECTURE.md

  3. Slack-тред с Артуром, 2026-05-18T15:53Z — push шага main/non-main, готовность к интеграции, ожидание Катиной валидации полноты графа. https://realaicorp.slack.com/archives/D094D5Y5NGJ/p1779119588157809